diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index feb2cd4f99..3d1ba88afd 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -60,6 +60,7 @@ overrides of these variables are rejected. | Foundry Anthropic API key | `x-api-key: ` | | Gemini API key | `x-goog-api-key: ` | | Bedrock bearer token | `authorization: Bearer ` | +| Vertex AI credential | `authorization: Bearer `, minted by the configured provider class from the Secret | | RemoteMCPServer Secret-backed header | Configured header; Secret contains its full value | Provider endpoint overrides determine the allowed HTTP(S) origin. Egress rules @@ -77,9 +78,32 @@ Harness and SandboxTemplate environment entries accept only literal `value` strings, including empty strings. Configure Secret-backed credentials on ModelConfig, RemoteMCPServer, or [EgressPolicy](egress-policies.md) for gateway injection. -AWS IAM signing keys, Google service-account keys, and OAuth client credentials -require mechanisms beyond static header injection and are rejected rather than -serialized into runtimes. +Each ModelConfig provider resolves its `apiKeySecret` through a Substrate +credential provider class, the authority of the `ate-secret://` URI. The class +is `k8s.io` unless the controller setting +`runtimeDefaults.modelCredentialAuthorities` (Helm value +`config.runtimeDefaults.modelCredentialAuthorities`) names another one for that +provider; `k8s.io` injects the Secret value as stored. Another class needs a +Substrate egress gateway that routes it to a credential provider deployed +beside Substrate, or the gateway fails the request closed. RemoteMCPServer and +EgressPolicy credentials always use `k8s.io`. + +A `GeminiVertexAI` or `AnthropicVertexAI` ModelConfig with `apiKeySecret` binds +`authorization: Bearer` on the Vertex AI origin for its location. Its provider +needs a class in `runtimeDefaults.modelCredentialAuthorities` that mints access +tokens: `k8s.io` would set the stored key itself as the bearer token, so the +compiler rejects a Vertex AI `apiKeySecret` that resolves to it. The provider +class owns the Secret's format, typically a service account key, so the +compiler does not inspect it. Vertex AI ModelConfigs do not support +`tokenPassthrough`. The runtime never holds +the Secret: the Go and Python ADKs run with `KAGENT_SKIP_VERTEX_AUTH=true` and +Claude Code with `CLAUDE_CODE_SKIP_VERTEX_AUTH=1`, and each sends the +placeholder `Authorization: Bearer kagent-credential-injected` for the gateway +to replace, since the gateway replaces a header a request carries and adds +none. + +AWS IAM signing keys and OAuth client credentials require mechanisms beyond +static header injection and are rejected rather than serialized into runtimes. Caller-token passthrough is set with `tokenPassthrough` on ModelConfig or RemoteMCPServer. It forwards the bearer token from the incoming A2A request @@ -111,6 +135,5 @@ TLS settings and warn when ignoring RemoteMCPServer TLS settings. Runtime trust for gateway injection is configured by the platform. `apiKeySecret` remains available for supported gateway credentials. Secret-backed -AWS IAM signing, Vertex service-account, and SAP OAuth credentials still fail -compilation; sharing this field with supported providers does not enable those -credential modes. +AWS IAM signing and SAP OAuth credentials still fail compilation; sharing this +field with supported providers does not enable those credential modes. diff --git a/docs/env.md b/docs/env.md index 0fb372eef2..bc27912f42 100644 --- a/docs/env.md +++ b/docs/env.md @@ -31,6 +31,7 @@ This reference covers user-configurable settings for the controller, CLI, standa | `GOOGLE_CLOUD_PROJECT` | String | `(none)` | Google Cloud project ID for Vertex AI. | | `GOOGLE_CLOUD_REGION` | String | `(none)` | Go ADK Vertex AI region fallback when GOOGLE_CLOUD_LOCATION is unset. | | `GOOGLE_GENAI_USE_VERTEXAI` | String | `(none)` | When set to 'true', use Vertex AI for Gemini models. | +| `KAGENT_SKIP_VERTEX_AUTH` | Boolean | `false` | When true, call Vertex AI without Google credentials of the runtime's own; the egress gateway sets the access token. | | `MISTRAL_API_BASE` | String | `(none)` | Custom base URL for the Mistral AI API (defaults to https://api.mistral.ai/v1). | | `MISTRAL_API_KEY` | String | `(none)` | API key for Mistral AI. | | `OLLAMA_API_BASE` | String | `(none)` | Base URL for the Ollama API endpoint; falls back to http://localhost:11434 when model configuration and this variable are unset. | diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 5cd142fb28..cd5cccad38 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -319,23 +319,15 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e }) case *configv1.ADKModelConfig_GeminiVertexAi: - project := env.GoogleCloudProject.Get() - location := env.GoogleCloudLocation.Get() - if location == "" { - location = env.GoogleCloudRegion.Get() - } - if project == "" || location == "" { - return nil, fmt.Errorf("GeminiVertexAI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION) environment variables") - } modelName := m.Model if modelName == "" { modelName = DefaultGeminiModel } - return adkgemini.NewModel(ctx, modelName, &genai.ClientConfig{ - Backend: genai.BackendVertexAI, - Project: project, - Location: location, - }) + clientConfig, err := geminiVertexAIClientConfig(m) + if err != nil { + return nil, err + } + return adkgemini.NewModel(ctx, modelName, clientConfig) case *configv1.ADKModelConfig_Anthropic: modelName := m.Model @@ -426,7 +418,8 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e case *configv1.ADKModelConfig_GeminiAnthropic: // GeminiAnthropic = Claude models accessed through Google Cloud Vertex AI. - // Uses the Anthropic SDK's built-in Vertex AI support with Application Default Credentials. + // Uses the Anthropic SDK's built-in Vertex AI support with Application + // Default Credentials, or with the egress gateway's access token. project := env.GoogleCloudProject.Get() region := env.GoogleCloudLocation.Get() if region == "" { @@ -481,6 +474,37 @@ func CreateLLM(ctx context.Context, m *configv1.ADKModelConfig) (adkmodel.LLM, e } } +// geminiVertexAIClientConfig is the genai client configuration for Gemini on +// Vertex AI. Under KAGENT_SKIP_VERTEX_AUTH the egress gateway sets the access +// token on every call: handed an HTTP client, the SDK sends the request as it +// is and never looks for Application Default Credentials, and the request +// carries the placeholder bearer, since the gateway replaces the Authorization +// header a request carries and adds none. +func geminiVertexAIClientConfig(m *configv1.ADKModelConfig) (*genai.ClientConfig, error) { + project := env.GoogleCloudProject.Get() + location := env.GoogleCloudLocation.Get() + if location == "" { + location = env.GoogleCloudRegion.Get() + } + if project == "" || location == "" { + return nil, fmt.Errorf("GeminiVertexAI requires GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION (or GOOGLE_CLOUD_REGION) environment variables") + } + clientConfig := &genai.ClientConfig{ + Backend: genai.BackendVertexAI, + Project: project, + Location: location, + } + if env.SkipVertexAuth.Get() { + httpClient, err := models.BuildHTTPClient(transportConfigFromBase(m, nil)) + if err != nil { + return nil, fmt.Errorf("failed to build HTTP client for Gemini on Vertex AI: %w", err) + } + clientConfig.HTTPClient = httpClient + clientConfig.HTTPOptions.Headers = http.Header{"Authorization": {"Bearer " + models.VertexGatewayToken}} + } + return clientConfig, nil +} + // transportConfigFromBase builds a TransportConfig from the shared model fields. func transportConfigFromBase(b *configv1.ADKModelConfig, timeout *time.Duration) models.TransportConfig { return models.TransportConfig{ diff --git a/go/adk/pkg/agent/agent_test.go b/go/adk/pkg/agent/agent_test.go index 92c949e48e..8337593117 100644 --- a/go/adk/pkg/agent/agent_test.go +++ b/go/adk/pkg/agent/agent_test.go @@ -2,10 +2,13 @@ package agent import ( "context" + "net/http" + "net/http/httptest" "testing" "time" "github.com/stretchr/testify/require" + "google.golang.org/genai" "google.golang.org/protobuf/types/known/durationpb" runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" @@ -592,3 +595,36 @@ func TestUsesRawOutputSchema(t *testing.T) { }) } } + +// Under KAGENT_SKIP_VERTEX_AUTH the Gemini Vertex AI client is built without +// Application Default Credentials and carries the placeholder bearer, since +// the gateway replaces the Authorization header a request carries and adds +// none. +func TestGeminiVertexAIClientConfigSendsGatewayPlaceholder(t *testing.T) { + t.Setenv("GOOGLE_CLOUD_PROJECT", "project") + t.Setenv("GOOGLE_CLOUD_LOCATION", "europe-west4") + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/credentials.json") + var got *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]}}]}`)) + })) + t.Cleanup(server.Close) + + model := &configv1.ADKModelConfig{Model: "gemini-2.5-flash", Provider: &configv1.ADKModelConfig_GeminiVertexAi{GeminiVertexAi: &configv1.ADKGeminiVertexAI{}}} + llm, err := CreateLLM(t.Context(), model) + require.NoError(t, err) + require.NotNil(t, llm) + + clientConfig, err := geminiVertexAIClientConfig(model) + require.NoError(t, err) + clientConfig.HTTPOptions.BaseURL = server.URL + client, err := genai.NewClient(t.Context(), clientConfig) + require.NoError(t, err) + _, err = client.Models.GenerateContent(t.Context(), "gemini-2.5-flash", genai.Text("hi"), nil) + require.NoError(t, err) + require.Equal(t, "/v1beta1/projects/project/locations/europe-west4/publishers/google/models/gemini-2.5-flash:generateContent", got.URL.Path) + require.Equal(t, "Bearer "+models.VertexGatewayToken, got.Header.Get("Authorization")) +} diff --git a/go/adk/pkg/models/anthropic.go b/go/adk/pkg/models/anthropic.go index 483c081473..9a71b34f7f 100644 --- a/go/adk/pkg/models/anthropic.go +++ b/go/adk/pkg/models/anthropic.go @@ -8,6 +8,8 @@ import ( "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" "github.com/anthropics/anthropic-sdk-go/vertex" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" "github.com/kagent-dev/kagent/go/core/pkg/env" "github.com/kagent-dev/kagent/go/pkg/logging" @@ -94,21 +96,27 @@ func newAnthropicModelFromConfig(ctx context.Context, config *AnthropicConfig, a }, nil } -// NewAnthropicVertexAIModel creates an Anthropic model that authenticates -// via Google Cloud Vertex AI using Application Default Credentials (ADC). -// This is used for the GeminiAnthropic / AnthropicVertexAI provider type. +// NewAnthropicVertexAIModel creates an Anthropic model for Claude on Google +// Cloud Vertex AI, the GeminiAnthropic / AnthropicVertexAI provider type. It +// authenticates with Application Default Credentials (ADC), unless +// KAGENT_SKIP_VERTEX_AUTH says the egress gateway sets the access token on +// every call: then the client never looks for ADC and sends the placeholder +// bearer the gateway replaces. func NewAnthropicVertexAIModel(ctx context.Context, config *AnthropicConfig, region, projectID string) (*AnthropicModel, error) { logger := logging.FromContext(ctx) - opts := []option.RequestOption{ - vertex.WithGoogleAuth(ctx, region, projectID), - } - // Create HTTP client with timeout, custom headers, TLS, and passthrough httpClient, err := BuildHTTPClient(config.TransportConfig) if err != nil { return nil, err } - opts = append(opts, option.WithHTTPClient(httpClient)) + // The Vertex option wraps the HTTP client passed before it with its + // authorization; a client passed after it would replace the authorized one. + opts := []option.RequestOption{option.WithHTTPClient(httpClient)} + if env.SkipVertexAuth.Get() { + opts = append(opts, vertex.WithCredentials(ctx, region, projectID, gatewayCredentials())) + } else { + opts = append(opts, vertex.WithGoogleAuth(ctx, region, projectID)) + } client := anthropic.NewClient(opts...) logger.InfoContext(ctx, "initialized Anthropic Vertex AI model", "model", config.Model, "region", region, "project", projectID) @@ -119,3 +127,17 @@ func NewAnthropicVertexAIModel(ctx context.Context, config *AnthropicConfig, reg Logger: logger, }, nil } + +// VertexGatewayToken is the placeholder access token a Vertex AI call carries +// when the egress gateway authenticates it (KAGENT_SKIP_VERTEX_AUTH). The +// gateway replaces the Authorization header a request carries with the access +// token its credential provider serves and adds none, so a call without the +// placeholder leaves unauthenticated. +const VertexGatewayToken = "kagent-credential-injected" + +// gatewayCredentials stand in for Google credentials when the egress gateway +// authenticates the Vertex AI call: the SDK's Vertex adaptation needs a token +// source to build its client, and sends the placeholder as its bearer. +func gatewayCredentials() *google.Credentials { + return &google.Credentials{TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: VertexGatewayToken})} +} diff --git a/go/adk/pkg/models/anthropic_vertex_test.go b/go/adk/pkg/models/anthropic_vertex_test.go new file mode 100644 index 0000000000..9fa5421583 --- /dev/null +++ b/go/adk/pkg/models/anthropic_vertex_test.go @@ -0,0 +1,94 @@ +package models + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/stretchr/testify/require" +) + +// Under KAGENT_SKIP_VERTEX_AUTH the Vertex AI client is built without +// Application Default Credentials and sends the Vertex-shaped request with the +// placeholder bearer the gateway replaces. +func TestNewAnthropicVertexAIModelWithGatewayCredential(t *testing.T) { + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/credentials.json") + var got *http.Request + var body []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + body, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + t.Cleanup(server.Close) + + model, err := NewAnthropicVertexAIModel(t.Context(), &AnthropicConfig{Model: "claude-sonnet-4-5@20250929"}, "us-east5", "project") + require.NoError(t, err) + _, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-5@20250929", MaxTokens: 16, + Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))}, + }, option.WithBaseURL(server.URL)) + require.NoError(t, err) + require.Equal(t, "/v1/projects/project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", got.URL.Path) + require.Equal(t, "Bearer "+VertexGatewayToken, got.Header.Get("Authorization")) + var sent map[string]any + require.NoError(t, json.Unmarshal(body, &sent)) + require.Equal(t, "vertex-2023-10-16", sent["anthropic_version"]) + require.NotContains(t, sent, "model") +} + +// Without KAGENT_SKIP_VERTEX_AUTH the client authorizes requests with +// Application Default Credentials on top of the configured transport, so both +// the ADC access token and the transport's headers reach Vertex AI. +func TestNewAnthropicVertexAIModelWithApplicationDefaultCredentials(t *testing.T) { + tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"adc-access-token","token_type":"Bearer","expires_in":3600}`)) + })) + t.Cleanup(tokenServer.Close) + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + credentials, err := json.Marshal(map[string]string{ + "type": "service_account", + "project_id": "project", + "client_email": "agent@project.iam.gserviceaccount.com", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})), + "token_uri": tokenServer.URL, + }) + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "credentials.json") + require.NoError(t, os.WriteFile(path, credentials, 0o600)) + t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "false") + t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path) + + var got *http.Request + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = r + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`)) + })) + t.Cleanup(server.Close) + + config := &AnthropicConfig{Model: "claude-sonnet-4-5@20250929", TransportConfig: TransportConfig{Headers: map[string]string{"X-Team": "platform"}}} + model, err := NewAnthropicVertexAIModel(t.Context(), config, "us-east5", "project") + require.NoError(t, err) + _, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{ + Model: "claude-sonnet-4-5@20250929", MaxTokens: 16, + Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))}, + }, option.WithBaseURL(server.URL)) + require.NoError(t, err) + require.Equal(t, "Bearer adc-access-token", got.Header.Get("Authorization")) + require.Equal(t, "platform", got.Header.Get("X-Team")) +} diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index d7667f7463..5d01693a1e 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1712,8 +1712,14 @@ type RuntimeDefaultsConfig struct { MaxContentLength *int64 `protobuf:"varint,5,opt,name=max_content_length,json=maxContentLength,proto3,oneof" json:"max_content_length,omitempty"` TokenPropagation *TokenPropagationConfig `protobuf:"bytes,6,opt,name=token_propagation,json=tokenPropagation,proto3" json:"token_propagation,omitempty"` SkillTools *SkillToolsConfig `protobuf:"bytes,7,opt,name=skill_tools,json=skillTools,proto3" json:"skill_tools,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The Substrate credential provider class, the authority of the + // ate-secret:// URI, that the egress gateway resolves a ModelConfig's + // apiKeySecret through, keyed by ModelConfig provider. A provider absent + // here uses k8s.io, which injects the Secret value as it is stored. + // GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + ModelCredentialAuthorities map[string]string `protobuf:"bytes,8,rep,name=model_credential_authorities,json=modelCredentialAuthorities,proto3" json:"model_credential_authorities,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RuntimeDefaultsConfig) Reset() { @@ -1795,6 +1801,13 @@ func (x *RuntimeDefaultsConfig) GetSkillTools() *SkillToolsConfig { return nil } +func (x *RuntimeDefaultsConfig) GetModelCredentialAuthorities() map[string]string { + if x != nil { + return x.ModelCredentialAuthorities + } + return nil +} + // SkillToolsConfig configures the built-in tools available with local skills. type SkillToolsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2121,7 +2134,8 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\bendpoint\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01R\bendpoint\x12;\n" + "\bprotocol\x18\x02 \x01(\tB\x1a\xbaH\x17r\x15R\x04grpcR\rhttp/protobufH\x00R\bprotocol\x88\x01\x01\x12G\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationB\x12\xbaH\x0f\xaa\x01\f\"\x04\b\x80\xa3\x052\x04\x10\xc0\x84=R\atimeoutB\v\n" + - "\t_protocol\"\xbb\x06\n" + + "\t_protocol\"\xd9\n" + + "\n" + "\x15RuntimeDefaultsConfig\x12L\n" + "\x11control_plane_url\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x00R\x0fcontrolPlaneUrl\x88\x01\x01\x12A\n" + "\vgateway_url\x18\x02 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x01R\n" + @@ -2132,7 +2146,12 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\x12max_content_length\x18\x05 \x01(\x03B\a\xbaH\x04\"\x02(\x00H\x02R\x10maxContentLength\x88\x01\x01\x12U\n" + "\x11token_propagation\x18\x06 \x01(\v2(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12C\n" + "\vskill_tools\x18\a \x01(\v2\".kagent.config.v1.SkillToolsConfigR\n" + - "skillToolsB\x14\n" + + "skillTools\x12\xcc\x03\n" + + "\x1cmodel_credential_authorities\x18\b \x03(\v2G.kagent.config.v1.RuntimeDefaultsConfig.ModelCredentialAuthoritiesEntryB\xc0\x02\xbaH\xbc\x02\x9a\x01\xb8\x02\"{ryR\tAnthropicR\x11AnthropicVertexAIR\vAzureOpenAIR\aBedrockR\aFoundryR\x06GeminiR\x0eGeminiVertexAIR\aMistralR\x06OllamaR\x06OpenAIR\tSAPAICore*\xb8\x01\xba\x01m\n" + + "\x1fmodel_credential_authorities.ip\x12 kagent.config.v1.ConnectionConfig @@ -2197,7 +2217,7 @@ var file_kagent_config_v1_config_proto_depIdxs = []int32{ 3, // 2: kagent.config.v1.CLIConfig.logging:type_name -> kagent.config.v1.LoggingConfig 4, // 3: kagent.config.v1.CLIConfig.install:type_name -> kagent.config.v1.InstallConfig 11, // 4: kagent.config.v1.CLIConfig.database:type_name -> kagent.config.v1.DatabaseConfig - 28, // 5: kagent.config.v1.ConnectionConfig.timeout:type_name -> google.protobuf.Duration + 29, // 5: kagent.config.v1.ConnectionConfig.timeout:type_name -> google.protobuf.Duration 5, // 6: kagent.config.v1.InstallConfig.kagent:type_name -> kagent.config.v1.ChartConfig 5, // 7: kagent.config.v1.InstallConfig.substrate:type_name -> kagent.config.v1.ChartConfig 5, // 8: kagent.config.v1.InstallConfig.pod_certificate:type_name -> kagent.config.v1.ChartConfig @@ -2220,33 +2240,34 @@ var file_kagent_config_v1_config_proto_depIdxs = []int32{ 12, // 25: kagent.config.v1.DatabaseConfig.owner:type_name -> kagent.config.v1.DatabaseLoginConfig 12, // 26: kagent.config.v1.DatabaseConfig.read_write:type_name -> kagent.config.v1.DatabaseLoginConfig 13, // 27: kagent.config.v1.DatabaseConfig.pool:type_name -> kagent.config.v1.PostgresPoolConfig - 28, // 28: kagent.config.v1.PostgresPoolConfig.max_conn_idle_time:type_name -> google.protobuf.Duration - 28, // 29: kagent.config.v1.PostgresPoolConfig.max_conn_lifetime:type_name -> google.protobuf.Duration - 28, // 30: kagent.config.v1.SessionsConfig.idle_ttl:type_name -> google.protobuf.Duration - 28, // 31: kagent.config.v1.SessionsConfig.share_max_ttl:type_name -> google.protobuf.Duration - 28, // 32: kagent.config.v1.SessionsConfig.expiration_interval:type_name -> google.protobuf.Duration - 28, // 33: kagent.config.v1.SessionsConfig.quiescence_recovery_interval:type_name -> google.protobuf.Duration - 28, // 34: kagent.config.v1.PushConfig.recovery_interval:type_name -> google.protobuf.Duration - 28, // 35: kagent.config.v1.SandboxConfig.default_ttl:type_name -> google.protobuf.Duration - 28, // 36: kagent.config.v1.SandboxConfig.max_ttl:type_name -> google.protobuf.Duration - 28, // 37: kagent.config.v1.SandboxConfig.expiration_interval:type_name -> google.protobuf.Duration - 28, // 38: kagent.config.v1.ScheduledRunsConfig.poll_interval:type_name -> google.protobuf.Duration - 28, // 39: kagent.config.v1.ScheduledRunsConfig.execution_interval:type_name -> google.protobuf.Duration - 28, // 40: kagent.config.v1.RuntimeRevisionsConfig.gc_interval:type_name -> google.protobuf.Duration + 29, // 28: kagent.config.v1.PostgresPoolConfig.max_conn_idle_time:type_name -> google.protobuf.Duration + 29, // 29: kagent.config.v1.PostgresPoolConfig.max_conn_lifetime:type_name -> google.protobuf.Duration + 29, // 30: kagent.config.v1.SessionsConfig.idle_ttl:type_name -> google.protobuf.Duration + 29, // 31: kagent.config.v1.SessionsConfig.share_max_ttl:type_name -> google.protobuf.Duration + 29, // 32: kagent.config.v1.SessionsConfig.expiration_interval:type_name -> google.protobuf.Duration + 29, // 33: kagent.config.v1.SessionsConfig.quiescence_recovery_interval:type_name -> google.protobuf.Duration + 29, // 34: kagent.config.v1.PushConfig.recovery_interval:type_name -> google.protobuf.Duration + 29, // 35: kagent.config.v1.SandboxConfig.default_ttl:type_name -> google.protobuf.Duration + 29, // 36: kagent.config.v1.SandboxConfig.max_ttl:type_name -> google.protobuf.Duration + 29, // 37: kagent.config.v1.SandboxConfig.expiration_interval:type_name -> google.protobuf.Duration + 29, // 38: kagent.config.v1.ScheduledRunsConfig.poll_interval:type_name -> google.protobuf.Duration + 29, // 39: kagent.config.v1.ScheduledRunsConfig.execution_interval:type_name -> google.protobuf.Duration + 29, // 40: kagent.config.v1.RuntimeRevisionsConfig.gc_interval:type_name -> google.protobuf.Duration 23, // 41: kagent.config.v1.TelemetryConfig.traces:type_name -> kagent.config.v1.OTLPConfig 23, // 42: kagent.config.v1.TelemetryConfig.metrics:type_name -> kagent.config.v1.OTLPConfig 23, // 43: kagent.config.v1.TelemetryConfig.logs:type_name -> kagent.config.v1.OTLPConfig 27, // 44: kagent.config.v1.TelemetryConfig.resource_attributes:type_name -> kagent.config.v1.TelemetryConfig.ResourceAttributesEntry - 28, // 45: kagent.config.v1.OTLPConfig.timeout:type_name -> google.protobuf.Duration + 29, // 45: kagent.config.v1.OTLPConfig.timeout:type_name -> google.protobuf.Duration 3, // 46: kagent.config.v1.RuntimeDefaultsConfig.logging:type_name -> kagent.config.v1.LoggingConfig 22, // 47: kagent.config.v1.RuntimeDefaultsConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig 26, // 48: kagent.config.v1.RuntimeDefaultsConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig 25, // 49: kagent.config.v1.RuntimeDefaultsConfig.skill_tools:type_name -> kagent.config.v1.SkillToolsConfig - 50, // [50:50] is the sub-list for method output_type - 50, // [50:50] is the sub-list for method input_type - 50, // [50:50] is the sub-list for extension type_name - 50, // [50:50] is the sub-list for extension extendee - 0, // [0:50] is the sub-list for field type_name + 28, // 50: kagent.config.v1.RuntimeDefaultsConfig.model_credential_authorities:type_name -> kagent.config.v1.RuntimeDefaultsConfig.ModelCredentialAuthoritiesEntry + 51, // [51:51] is the sub-list for method output_type + 51, // [51:51] is the sub-list for method input_type + 51, // [51:51] is the sub-list for extension type_name + 51, // [51:51] is the sub-list for extension extendee + 0, // [0:51] is the sub-list for field type_name } func init() { file_kagent_config_v1_config_proto_init() } @@ -2279,7 +2300,7 @@ func file_kagent_config_v1_config_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kagent_config_v1_config_proto_rawDesc), len(file_kagent_config_v1_config_proto_rawDesc)), NumEnums: 0, - NumMessages: 28, + NumMessages: 29, NumExtensions: 0, NumServices: 0, }, diff --git a/go/core/internal/egress/credentials.go b/go/core/internal/egress/credentials.go index f226807c19..d216a7df2a 100644 --- a/go/core/internal/egress/credentials.go +++ b/go/core/internal/egress/credentials.go @@ -2,6 +2,7 @@ package egress import ( "fmt" + "net" "net/url" "slices" "strings" @@ -10,6 +11,23 @@ import ( "k8s.io/apimachinery/pkg/util/validation" ) +// KubernetesSecretAuthority is the Substrate credential provider class, the +// authority of an ate-secret:// URI, that injects a Secret value unchanged. +const KubernetesSecretAuthority = "k8s.io" + +// ValidCredentialAuthority reports whether authority can name a Substrate +// credential provider class: a DNS-1123 subdomain without a port that is not +// an IP address. +func ValidCredentialAuthority(authority string) bool { + return len(validation.IsDNS1123Subdomain(authority)) == 0 && net.ParseIP(authority) == nil +} + +// CredentialURI names one Secret data entry of the default locator for a +// Substrate credential provider class. +func CredentialURI(authority, namespace, name, key string) string { + return "ate-secret://" + authority + "/default/" + namespace + "/" + name + "/" + key +} + // Credential binds a Secret reference to an exact destination and HTTP header. // Values are fetched by the gateway and never enter a runtime revision. type Credential struct { @@ -34,8 +52,8 @@ func CanonicalCredentials(bindings []Credential) ([]Credential, error) { return nil, fmt.Errorf("invalid credential injection header %q", c.Header) } u, err = url.Parse(c.URI) - if err != nil || u.Scheme != "ate-secret" || u.Host != "k8s.io" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { - return nil, fmt.Errorf("invalid Kubernetes credential URI") + if err != nil || u.Scheme != "ate-secret" || !ValidCredentialAuthority(u.Host) || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return nil, fmt.Errorf("invalid credential URI") } parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/") if len(parts) != 4 || parts[0] != "default" || len(validation.IsDNS1123Label(parts[1])) != 0 || len(validation.IsDNS1123Subdomain(parts[2])) != 0 || parts[3] == "" || len(validation.IsConfigMapKey(parts[3])) != 0 { diff --git a/go/core/internal/egress/credentials_test.go b/go/core/internal/egress/credentials_test.go index 453d663065..ffb1b236eb 100644 --- a/go/core/internal/egress/credentials_test.go +++ b/go/core/internal/egress/credentials_test.go @@ -23,6 +23,10 @@ func TestCanonicalCredentials(t *testing.T) { {"provider", func(c *Credential) { c.URI = "https://example.com/secret" }}, {"missing key", func(c *Credential) { c.URI = "ate-secret://k8s.io/default/team/auth" }}, {"old provider", func(c *Credential) { c.URI = "ate-secret://kubernetes.io/team/auth/token" }}, + {"authority with port", func(c *Credential) { c.URI = "ate-secret://k8s.io:50051/default/team/auth/token" }}, + {"uppercase authority", func(c *Credential) { c.URI = "ate-secret://Vault.Example.com/default/team/auth/token" }}, + {"IP authority", func(c *Credential) { c.URI = "ate-secret://192.0.2.1/default/team/auth/token" }}, + {"authority with underscore", func(c *Credential) { c.URI = "ate-secret://vault_provider/default/team/auth/token" }}, {"missing locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/team/auth/token" }}, {"unsupported locator", func(c *Credential) { c.URI = "ate-secret://k8s.io/remote/team/auth/token" }}, {"query", func(c *Credential) { c.URI += "?key=other" }}, @@ -38,4 +42,10 @@ func TestCanonicalCredentials(t *testing.T) { other.URI = "ate-secret://k8s.io/default/team/other/token" _, err = CanonicalCredentials([]Credential{base, other}) require.ErrorContains(t, err, "conflicting credentials") + + // Any provider class the gateway routes is a DNS subdomain authority. + minted := Credential{Origin: "https://us-east5-aiplatform.googleapis.com", Header: "authorization", Prefix: "Bearer ", URI: CredentialURI("tokens.example.com", "team", "vertex", "credentials.json")} + got, err = CanonicalCredentials([]Credential{minted}) + require.NoError(t, err) + require.Equal(t, "ate-secret://tokens.example.com/default/team/vertex/credentials.json", got[0].URI) } diff --git a/go/core/internal/translator/adkconfig/builder.go b/go/core/internal/translator/adkconfig/builder.go index e52854360f..ff1c4cf396 100644 --- a/go/core/internal/translator/adkconfig/builder.go +++ b/go/core/internal/translator/adkconfig/builder.go @@ -103,9 +103,6 @@ func (c *Builder) compileAgent(ctx context.Context, input *translator.AgentInput return nil, fmt.Errorf("render ModelConfig %q: %w", modelConfig.Name, err) } } - if modelRuntime.HasUnsupportedVolumes { - return nil, translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") - } stream := new(true) if modelConfig != nil && modelConfig.Spec.Stream != nil { stream = modelConfig.Spec.Stream @@ -331,6 +328,10 @@ func agentConfigDestinations(cfg *configv1.ADKAgentConfig, modelConfig *v1alpha3 destinations = append(destinations, "https://api.anthropic.com:443") case v1alpha3.ModelProviderGemini: destinations = append(destinations, "https://generativelanguage.googleapis.com:443") + case v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI: + if vertex := translator.VertexAIConfig(&modelConfig.Spec); vertex != nil { + destinations = append(destinations, translator.VertexAIOrigin(vertex.Location)) + } case v1alpha3.ModelProviderMistral: if mistral := modelConfig.Spec.Mistral; mistral == nil || mistral.BaseURL == nil || *mistral.BaseURL == "" { destinations = append(destinations, "https://api.mistral.ai:443") diff --git a/go/core/internal/translator/adkconfig/builder_test.go b/go/core/internal/translator/adkconfig/builder_test.go index 2babf5a0cb..6b00924605 100644 --- a/go/core/internal/translator/adkconfig/builder_test.go +++ b/go/core/internal/translator/adkconfig/builder_test.go @@ -278,6 +278,49 @@ func TestOllamaEgressDestination(t *testing.T) { }) } +// A Vertex AI ModelConfig with apiKeySecret compiles for the Substrate runtime: +// no volume, no Secret in the environment, and the location's endpoint allowed. +func TestBuildVertexAIGatewayCredential(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"} + for _, model := range []v1alpha3.ModelConfigSpec{ + {Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}, + {Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + } { + t.Run(string(model.Provider), func(t *testing.T) { + build := func(t *testing.T, key string) (*Result, error) { + t.Helper() + spec := model + spec.APIKeySecret, spec.APIKeySecretKey = "vertex-auth", "credentials.json" + config := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "vertex", Namespace: "test"}, Spec: spec} + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "vertex-auth", Namespace: "test"}, Data: map[string][]byte{"credentials.json": []byte(key)}} + mock := krttest.NewMock(t, []any{config, secret}) + collections := translator.Collections{Secrets: krttest.GetMockCollection[*corev1.Secret](mock)} + resolved, err := translator.ResolveModelConfig(krt.TestingDummyContext{}, collections, krttest.GetMockCollection[*v1alpha3.ModelConfig](mock).List()[0]) + require.NoError(t, err) + collections.ResolvedModelConfigs = krttest.GetMockCollection[translator.ResolvedModelConfig](krttest.NewMock(t, []any{*resolved})) + return NewBuilder(krt.TestingDummyContext{}, collections).Build(t.Context(), &translator.HarnessInput{ + Harness: &translator.HarnessConfiguration{Spec: v1alpha3.HarnessSpec{Kagent: &v1alpha3.KagentHarness{}}}, + Root: &translator.AgentInput{Template: &translator.TemplateConfiguration{Name: "pi", Namespace: "test"}, ResolvedModelConfig: resolved}, + }) + } + // The provider class that fetches the Secret owns its format, so an + // access token and a service account key both compile. + for _, data := range []string{"ya29.access-token", `{"type":"service_account","private_key":"private"}`} { + result, err := build(t, data) + require.NoError(t, err) + require.Equal(t, []string{"https://aiplatform.googleapis.com:443"}, result.Egress) + environment := map[string]string{} + for _, variable := range result.Environment { + require.Nil(t, variable.ValueFrom, "%s reads a Secret", variable.Name) + environment[variable.Name] = variable.Value + } + require.Equal(t, "true", environment[env.SkipVertexAuth.Name()]) + require.NotContains(t, environment, env.GoogleApplicationCredentials.Name()) + } + }) + } +} + func contextTestCollections(t *testing.T, models ...*v1alpha3.ModelConfig) translator.Collections { t.Helper() objects := make([]any, 0, 2*len(models)) diff --git a/go/core/internal/translator/adkconfig/model.go b/go/core/internal/translator/adkconfig/model.go index 0118cbef6f..0e2edd654e 100644 --- a/go/core/internal/translator/adkconfig/model.go +++ b/go/core/internal/translator/adkconfig/model.go @@ -18,21 +18,15 @@ import ( ) // modelDeploymentData collects the Kubernetes inputs required by a provider. -// Volumes are retained even though the current Substrate ActorTemplate path -// rejects them, so the compiler can report incompatibility instead of silently -// dropping credentials. type modelDeploymentData struct { - EnvVars []corev1.EnvVar - Volumes []corev1.Volume - VolumeMounts []corev1.VolumeMount + EnvVars []corev1.EnvVar } // modelRuntime is the provider-neutral result consumed by the rest of the // compiler. type modelRuntime struct { - Model *configv1.ADKModelConfig - Environment []corev1.EnvVar - HasUnsupportedVolumes bool + Model *configv1.ADKModelConfig + Environment []corev1.EnvVar } // resolveModel collapses provider-specific translation output into the subset @@ -42,13 +36,22 @@ func resolveModel(resolved *translator.ResolvedModelConfig) (*modelRuntime, erro if err != nil { return nil, err } - return &modelRuntime{ - Model: model, Environment: data.EnvVars, - HasUnsupportedVolumes: len(data.Volumes) > 0 || len(data.VolumeMounts) > 0, - }, nil + return &modelRuntime{Model: model, Environment: data.EnvVars}, nil } -const googleCredsVolumeName = "google-creds" +// vertexAIEnvironment names the Vertex AI project and location. With an +// apiKeySecret the egress gateway sets the access token on every call, so the +// runtime skips Google authentication of its own and never holds the Secret. +func vertexAIEnvironment(model *v1alpha3.ModelConfig, vertex *v1alpha3.BaseVertexAIConfig) []corev1.EnvVar { + environment := []corev1.EnvVar{ + {Name: env.GoogleCloudProject.Name(), Value: vertex.ProjectID}, + {Name: env.GoogleCloudLocation.Name(), Value: vertex.Location}, + } + if model.Spec.APIKeySecret != "" && !model.Spec.TokenPassthrough { + environment = append(environment, corev1.EnvVar{Name: env.SkipVertexAuth.Name(), Value: "true"}) + } + return environment +} // tlsInsecureSkipVerify leaves empty configs unset to preserve SDK client defaults. func tlsInsecureSkipVerify(tlsConfig *v1alpha3.TLSConfig) *bool { @@ -231,36 +234,11 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode if model.Spec.GeminiVertexAI == nil { return nil, nil, fmt.Errorf("GeminiVertexAI model config is required") } - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudProject.Name(), - Value: model.Spec.GeminiVertexAI.ProjectID, - }) - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudLocation.Name(), - Value: model.Spec.GeminiVertexAI.Location, - }) modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ Name: env.GoogleGenAIUseVertexAI.Name(), Value: "true", }) - if model.Spec.APIKeySecret != "" { - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleApplicationCredentials.Name(), - Value: "/creds/" + model.Spec.APIKeySecretKey, - }) - modelDeploymentData.Volumes = append(modelDeploymentData.Volumes, corev1.Volume{ - Name: googleCredsVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: model.Spec.APIKeySecret, - }, - }, - }) - modelDeploymentData.VolumeMounts = append(modelDeploymentData.VolumeMounts, corev1.VolumeMount{ - Name: googleCredsVolumeName, - MountPath: "/creds", - }) - } + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, vertexAIEnvironment(model, &model.Spec.GeminiVertexAI.BaseVertexAIConfig)...) gemini := &configv1.ADKModelConfig{ Model: model.Spec.Model, Headers: model.Spec.DefaultHeaders, @@ -279,32 +257,7 @@ func translateModel(resolved *translator.ResolvedModelConfig) (*configv1.ADKMode if model.Spec.AnthropicVertexAI == nil { return nil, nil, fmt.Errorf("AnthropicVertexAI model config is required") } - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudProject.Name(), - Value: model.Spec.AnthropicVertexAI.ProjectID, - }) - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleCloudLocation.Name(), - Value: model.Spec.AnthropicVertexAI.Location, - }) - if model.Spec.APIKeySecret != "" { - modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, corev1.EnvVar{ - Name: env.GoogleApplicationCredentials.Name(), - Value: "/creds/" + model.Spec.APIKeySecretKey, - }) - modelDeploymentData.Volumes = append(modelDeploymentData.Volumes, corev1.Volume{ - Name: googleCredsVolumeName, - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: model.Spec.APIKeySecret, - }, - }, - }) - modelDeploymentData.VolumeMounts = append(modelDeploymentData.VolumeMounts, corev1.VolumeMount{ - Name: googleCredsVolumeName, - MountPath: "/creds", - }) - } + modelDeploymentData.EnvVars = append(modelDeploymentData.EnvVars, vertexAIEnvironment(model, &model.Spec.AnthropicVertexAI.BaseVertexAIConfig)...) anthropic := &configv1.ADKModelConfig{ Model: model.Spec.Model, Headers: model.Spec.DefaultHeaders, diff --git a/go/core/internal/translator/adkconfig/model_test.go b/go/core/internal/translator/adkconfig/model_test.go index 0838e527ba..0c286c12f3 100644 --- a/go/core/internal/translator/adkconfig/model_test.go +++ b/go/core/internal/translator/adkconfig/model_test.go @@ -87,6 +87,53 @@ func TestRenderBedrockCredentialsFromReferences(t *testing.T) { // Secret, never serialized into the agent config, and the host is written only // when the operator set one — an empty value lets the runtime apply its own // cloud/local routing instead of looking like a chosen endpoint. +// The egress gateway sets a Vertex AI access token: the runtime skips Google +// authentication of its own and gets neither the Secret nor a credential file. +func TestTranslateVertexAIEnvironment(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"} + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + want []string + }{ + { + name: "Gemini with a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", APIKeySecret: "vertex-auth", APIKeySecretKey: "credentials.json", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleGenAIUseVertexAI.Name(), env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name(), env.SkipVertexAuth.Name()}, + }, + { + name: "Anthropic with a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", APIKeySecret: "vertex-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name(), env.SkipVertexAuth.Name()}, + }, + { + name: "Anthropic without a key", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name()}, + }, + { + name: "Anthropic passthrough", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude", APIKeySecret: "vertex-auth", APIKeySecretKey: "credentials.json", TokenPassthrough: true, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}, + want: []string{env.GoogleCloudProject.Name(), env.GoogleCloudLocation.Name()}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + resolved := &translator.ResolvedModelConfig{Config: &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "test"}, Spec: tt.spec}} + _, data, err := translateModel(resolved) + require.NoError(t, err) + names := make([]string, 0, len(data.EnvVars)) + for _, variable := range data.EnvVars { + require.Nil(t, variable.ValueFrom, "%s reads a Secret", variable.Name) + names = append(names, variable.Name) + if variable.Name == env.SkipVertexAuth.Name() { + require.Equal(t, "true", variable.Value) + } + } + require.ElementsMatch(t, tt.want, names) + }) + } +} + func TestTranslateOllamaEnvironment(t *testing.T) { tests := []struct { name string diff --git a/go/core/internal/translator/adkconfig/runtime.go b/go/core/internal/translator/adkconfig/runtime.go index 82a9f133e4..90f8ef62cf 100644 --- a/go/core/internal/translator/adkconfig/runtime.go +++ b/go/core/internal/translator/adkconfig/runtime.go @@ -38,9 +38,6 @@ func (c *Builder) buildModel(namespace, name string) (*modelResult, error) { if err != nil { return nil, err } - if runtime.HasUnsupportedVolumes { - return nil, translator.NewValidationError("ModelConfig requires volume mounts unsupported by Substrate ActorTemplate") - } return &modelResult{ Resolved: resolved, Model: runtime.Model, Environment: runtime.Environment, Egress: agentConfigDestinations(&configv1.ADKAgentConfig{}, resolved.Config, runtime.Model), diff --git a/go/core/internal/translator/byo/compiler.go b/go/core/internal/translator/byo/compiler.go index 53f90f7fb9..3162f5794b 100644 --- a/go/core/internal/translator/byo/compiler.go +++ b/go/core/internal/translator/byo/compiler.go @@ -70,7 +70,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/claude/compiler.go b/go/core/internal/translator/claude/compiler.go index 0ae31bb582..321ca2acca 100644 --- a/go/core/internal/translator/claude/compiler.go +++ b/go/core/internal/translator/claude/compiler.go @@ -71,7 +71,8 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) environment := append([]corev1.EnvVar(nil), providerEnvironment...) environment = append(environment, mcp.environment...) for _, variable := range input.Harness.Spec.Env { - if claudeconfig.OwnsEnvironment(variable.Name) { + providerOwned := slices.ContainsFunc(providerEnvironment, func(owned corev1.EnvVar) bool { return owned.Name == variable.Name }) + if claudeconfig.OwnsEnvironment(variable.Name) || providerOwned { return nil, translator.NewValidationError("Harness env %q conflicts with Claude-owned runtime configuration", variable.Name) } environment = append(environment, corev1.EnvVar{Name: variable.Name, Value: variable.Value}) @@ -110,7 +111,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Claude revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment, c.settings) if err != nil { return nil, err } @@ -191,7 +192,7 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ return nil, nil, translator.NewValidationError("Claude does not support Anthropic provider options beyond baseUrl yet") } } - if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, false); err != nil { + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey); err != nil { return nil, nil, err } environment := []corev1.EnvVar{secretEnvironment(claudeconfig.AnthropicAPIKeyEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey)} @@ -254,14 +255,19 @@ func (c *Compiler) provider(ctx context.Context, model *v1alpha3.ModelConfig) ([ if !reflect.DeepEqual(options, v1alpha3.AnthropicVertexAIConfig{}) { return nil, nil, translator.NewValidationError("Claude does not support AnthropicVertexAI provider options beyond projectID and location yet") } - if err := c.requireGoogleCredentials(ctx, model); err != nil { + if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey); err != nil { return nil, nil, err } cfg := model.Spec.AnthropicVertexAI + // The gateway replaces the placeholder bearer with the access token its + // configured provider class serves, so Claude Code skips its own Google + // authentication and the Secret never enters the runtime. The gateway + // replaces a header a request carries and adds none. return []corev1.EnvVar{ - {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, - secretEnvironment(claudeconfig.GoogleCredentialsJSONEnvName, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey), - }, []string{"https://" + vertexHostname(cfg.Location) + ":443", "https://oauth2.googleapis.com:443"}, nil + {Name: claudeconfig.UseVertexEnvName, Value: "1"}, {Name: claudeconfig.SkipVertexAuthEnvName, Value: "1"}, + {Name: claudeconfig.VertexProjectEnvName, Value: cfg.ProjectID}, {Name: claudeconfig.VertexRegionEnvName, Value: cfg.Location}, + {Name: claudeconfig.AnthropicCustomHeadersEnvName, Value: "Authorization: Bearer " + translator.CredentialPlaceholder}, + }, []string{translator.VertexAIOrigin(cfg.Location)}, nil default: return nil, nil, translator.NewValidationError("Claude does not support ModelConfig provider %q", model.Spec.Provider) } @@ -275,7 +281,7 @@ func anthropicBaseURLOrigin(raw string) (string, error) { return egress.Origin(parsed), nil } -func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelConfig, name, key string, requireJSON bool) error { +func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelConfig, name, key string) error { if name == "" || key == "" { return translator.NewValidationError("Claude %s requires apiKeySecret and apiKeySecretKey", model.Spec.Provider) } @@ -287,40 +293,6 @@ func (c *Compiler) requireSecretKey(ctx context.Context, model *v1alpha3.ModelCo if !ok || len(value) == 0 { return translator.NewValidationError("Claude credential Secret %q does not contain a non-empty key %q", name, key) } - if requireJSON && !json.Valid(value) { - return translator.NewValidationError("Claude Vertex credential Secret %q key %q must contain valid JSON", name, key) - } - return nil -} - -func (c *Compiler) requireGoogleCredentials(ctx context.Context, model *v1alpha3.ModelConfig) error { - if err := c.requireSecretKey(ctx, model, model.Spec.APIKeySecret, model.Spec.APIKeySecretKey, true); err != nil { - return err - } - secret, err := c.secret(ctx, model.Namespace, model.Spec.APIKeySecret) - if err != nil { - return err - } - var credentials struct { - Type string `json:"type"` - ProjectID string `json:"project_id"` - TokenURI string `json:"token_uri"` - } - if err := json.Unmarshal(secret.Data[model.Spec.APIKeySecretKey], &credentials); err != nil { - return translator.NewValidationError("decode Claude Vertex credentials: %v", err) - } - if credentials.Type != "service_account" { - return translator.NewValidationError("Claude Vertex credentials must be a service_account key in the first release") - } - if credentials.ProjectID != model.Spec.AnthropicVertexAI.ProjectID { - return translator.NewValidationError("Claude Vertex credential project_id must match anthropicVertexAI.projectID") - } - if credentials.TokenURI != "" { - parsed, err := url.Parse(credentials.TokenURI) - if err != nil || parsed.Scheme != "https" || parsed.Hostname() != "oauth2.googleapis.com" { - return translator.NewValidationError("Claude Vertex credential token_uri must use https://oauth2.googleapis.com") - } - } return nil } @@ -338,17 +310,6 @@ func secretEnvironment(environmentName, secretName, key string) corev1.EnvVar { }}} } -func vertexHostname(location string) string { - switch location { - case "global": - return "aiplatform.googleapis.com" - case "us", "eu": - return "aiplatform." + location + ".rep.googleapis.com" - default: - return location + "-aiplatform.googleapis.com" - } -} - type provenanceEntry struct { APIVersion string `json:"apiVersion"` Kind string `json:"kind"` diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index 9440964dee..90421d85a6 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/require" "istio.io/istio/pkg/kube/krt" "istio.io/istio/pkg/kube/krt/krttest" corev1 "k8s.io/api/core/v1" @@ -18,6 +19,7 @@ import ( configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/internal/translator" claudeconfig "github.com/kagent-dev/kagent/go/harness/claude/config" @@ -28,12 +30,14 @@ const credentialValue = "credential-must-not-be-serialized" func TestCompileProviderCredentials(t *testing.T) { tests := []struct { - name string - model v1alpha3.ModelConfigSpec - secretData map[string][]byte - wantEnv map[string]string - wantEgress []string - wantErr string + name string + model v1alpha3.ModelConfigSpec + secretData map[string][]byte + wantEnv map[string]string + wantEgress []string + wantCredentials []egress.Credential + wantErr string + authorities map[string]string }{ { name: "Anthropic", @@ -70,19 +74,43 @@ func TestCompileProviderCredentials(t *testing.T) { wantEgress: []string{"http://kagent-controller.kagent:8083", "https://bedrock-runtime.us-west-2.amazonaws.com:443"}, }, { - name: "Anthropic Vertex AI", + // k8s.io would set the stored service account key as the bearer token. + name: "Anthropic Vertex AI on the Kubernetes class", model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, - secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","project_id":"project","token_uri":"https://oauth2.googleapis.com/token","private_key":"` + credentialValue + `"}`)}, - wantErr: "cannot use gateway header injection", + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","private_key":"` + credentialValue + `"}`)}, + wantErr: "set runtimeDefaults.modelCredentialAuthorities.AnthropicVertexAI", + }, + { + name: "Anthropic Vertex AI minting authority", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","private_key":"` + credentialValue + `"}`)}, + authorities: map[string]string{"AnthropicVertexAI": "tokens.example.com"}, + wantEnv: map[string]string{claudeconfig.UseVertexEnvName: "1", claudeconfig.SkipVertexAuthEnvName: "1", + claudeconfig.VertexProjectEnvName: "project", claudeconfig.VertexRegionEnvName: "us-east5", + claudeconfig.AnthropicCustomHeadersEnvName: "Authorization: Bearer " + translator.CredentialPlaceholder}, + wantEgress: []string{"http://kagent-controller.kagent:8083", "https://us-east5-aiplatform.googleapis.com:443"}, + wantCredentials: []egress.Credential{{Origin: "https://us-east5-aiplatform.googleapis.com:443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://tokens.example.com/default/test/model-auth/credentials.json"}}, + }, + { + // Ambient Google credentials do not reach an actor. + name: "Anthropic Vertex AI without a Secret", + model: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"}}}, + wantErr: "requires apiKeySecret and apiKeySecretKey", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { input, reader := testInput(t, tt.model, tt.secretData) - revision, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(context.Background(), input) + settings := testRuntimeDefaults() + settings.ModelCredentialAuthorities = tt.authorities + revision, err := NewCompiler(krt.TestingDummyContext{}, reader, settings).Compile(t.Context(), input) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected unsupported credential error, got %v", err) @@ -117,6 +145,10 @@ func TestCompileProviderCredentials(t *testing.T) { if !reflect.DeepEqual(revision.EgressDestinations, tt.wantEgress) { t.Errorf("egress = %v", revision.EgressDestinations) } + if tt.wantCredentials != nil { + require.Equal(t, tt.wantCredentials, revision.Credentials) + } + require.NotContains(t, gotEnvironment, claudeconfig.GoogleApplicationCredentialsEnvName) if bytes.Contains(revision.ConfigJSON, []byte(credentialValue)) || bytes.Contains(revision.Provenance, []byte(credentialValue)) { t.Fatal("compiled config or provenance contains credential material") } @@ -124,7 +156,7 @@ func TestCompileProviderCredentials(t *testing.T) { t.Fatalf("provenance contains gateway-managed Secret: %s", revision.Provenance) } - again, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(context.Background(), input) + again, err := NewCompiler(krt.TestingDummyContext{}, reader, settings).Compile(t.Context(), input) if err != nil || !reflect.DeepEqual(revision, again) { t.Fatalf("compilation is not deterministic: %v", err) } @@ -175,6 +207,21 @@ func TestCompileRejectsProviderOwnedHarnessEnvironment(t *testing.T) { } } +// The Vertex AI provider sets ANTHROPIC_CUSTOM_HEADERS to the placeholder +// bearer the gateway replaces, so a Harness cannot set it beside that provider. +func TestCompileRejectsHarnessEnvironmentTheProviderSets(t *testing.T) { + model := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", + APIKeySecret: "model-auth", APIKeySecretKey: "credentials.json", + AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"}}} + input, reader := testInput(t, model, map[string][]byte{"credentials.json": []byte(credentialValue)}) + for _, name := range []string{claudeconfig.AnthropicCustomHeadersEnvName, claudeconfig.SkipVertexAuthEnvName, claudeconfig.GoogleApplicationCredentialsEnvName} { + input.Harness.Spec.Env = []v1alpha3.RuntimeEnvVar{{Name: name, Value: "x-other: value"}} + _, err := NewCompiler(krt.TestingDummyContext{}, reader, testRuntimeDefaults()).Compile(t.Context(), input) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation, name) + } +} + func TestCompileAllowsRuntimeOTELEndpoint(t *testing.T) { model := v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderAnthropic, Model: "claude-sonnet-4-5", diff --git a/go/core/internal/translator/codex/compiler.go b/go/core/internal/translator/codex/compiler.go index c8370aefc6..fab7276a7a 100644 --- a/go/core/internal/translator/codex/compiler.go +++ b/go/core/internal/translator/codex/compiler.go @@ -130,7 +130,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build Codex revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment) + environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, nil, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index 8ce2daebb1..41aa2f8658 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -20,6 +20,7 @@ import ( runtimeconfig "github.com/kagent-dev/kagent/go/adk/pkg/config" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/internal/translator" byotranslator "github.com/kagent-dev/kagent/go/core/internal/translator/byo" @@ -208,14 +209,19 @@ func compiler(t *testing.T, objects ...any) *translator.Compiler { } func compilerWithImages(t *testing.T, images *configv1.HarnessImagesConfig, objects ...any) *translator.Compiler { + t.Helper() + return compilerWithSettings(t, images, testRuntimeDefaults(), objects...) +} + +func compilerWithSettings(t *testing.T, images *configv1.HarnessImagesConfig, settings *configv1.RuntimeDefaultsConfig, objects ...any) *translator.Compiler { t.Helper() collections := mockCollections(t, append(objects, defaultWorkerPool())...) ctx := krt.TestingDummyContext{} return translator.NewCompiler(ctx, collections, map[translator.HarnessType]translator.HarnessCompiler{ - translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeCodex: codextranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeClaude: claudetranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), - translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections, testRuntimeDefaults()), + translator.HarnessTypeKagent: kagenttranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeCodex: codextranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeClaude: claudetranslator.NewCompiler(ctx, collections, settings), + translator.HarnessTypeBYO: byotranslator.NewCompiler(ctx, collections, settings), }, images) } @@ -554,6 +560,120 @@ func TestCompilerPermitsBYOWithoutModelConfig(t *testing.T) { require.Nil(t, adapter.input.Root.ResolvedModelConfig) } +func gatewayCredentialHarness() *v1alpha3.Harness { + return &v1alpha3.Harness{ + ObjectMeta: metav1.ObjectMeta{Name: "kagent", Namespace: "test"}, + Spec: v1alpha3.HarnessSpec{ + Kagent: &v1alpha3.KagentHarness{}, + Workload: v1alpha3.HarnessWorkload{Image: new("example.com/kagent@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")}, + Substrate: v1alpha3.RuntimeSubstratePolicy{ + WorkerPoolRef: corev1.LocalObjectReference{Name: "default"}, + SnapshotPolicy: v1alpha3.RuntimeSnapshotPolicy{Location: "snapshots"}, + }, + }, + } +} + +// A Vertex AI ModelConfig with a service account key compiles for the kagent +// harness: the key is bound on the Google access-token provider class for the +// gateway to exchange, the runtime skips Google authentication of its own, and +// nothing of the key reaches the revision. +func TestCompileAgentBindsVertexAIAtGateway(t *testing.T) { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-east5"} + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + }{ + {"Gemini", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, Model: "gemini-2.5-pro", GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}}}, + {"Anthropic", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, Model: "claude-sonnet-4-5@20250929", AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}}}, + } { + for _, authority := range []struct { + name, set, want string + data string + wantErr string + }{ + // k8s.io would set the stored service account key as the bearer token. + {"default authority", "", "", `{"type":"service_account","private_key":"private-key-must-not-be-serialized"}`, "set runtimeDefaults.modelCredentialAuthorities." + string(tt.spec.Provider)}, + {"minting authority", "tokens.example.com", "tokens.example.com", `{"type":"service_account","private_key":"private-key-must-not-be-serialized"}`, ""}, + } { + t.Run(tt.name+"/"+authority.name, func(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "vertex-auth", Namespace: "test"}, + Data: map[string][]byte{"credentials.json": []byte(authority.data)}, + } + model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"}, Spec: tt.spec} + model.Spec.APIKeySecret, model.Spec.APIKeySecretKey = secret.Name, "credentials.json" + settings := testRuntimeDefaults() + if authority.set != "" { + settings.ModelCredentialAuthorities = map[string]string{string(tt.spec.Provider): authority.set} + } + harness := gatewayCredentialHarness() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, SystemPrompt: "help"}, + } + spec, err := compilerWithSettings(t, testHarnessImages, settings, model, secret, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + if authority.wantErr != "" { + require.ErrorContains(t, err, authority.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, []egress.Credential{{ + Origin: "https://us-east5-aiplatform.googleapis.com:443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://" + authority.want + "/default/test/vertex-auth/credentials.json", + }}, spec.Credentials) + environment := map[string]string{} + for _, variable := range spec.Environment { + require.Nil(t, variable.ValueFrom, "%s is unresolved", variable.Name) + environment[variable.Name] = variable.Value + } + require.Equal(t, "true", environment["KAGENT_SKIP_VERTEX_AUTH"]) + require.NotContains(t, environment, "GOOGLE_APPLICATION_CREDENTIALS") + require.Contains(t, spec.EgressDestinations, "https://us-east5-aiplatform.googleapis.com:443") + require.NotContains(t, spec.EgressDestinations, "https://oauth2.googleapis.com:443") + require.False(t, bytes.Contains(spec.ConfigJSON, []byte("must-not-be-serialized")) || bytes.Contains(spec.Provenance, []byte("must-not-be-serialized")), "runtime revision contains credential material") + }) + } + } +} + +// Credentials the runtime would have to sign or exchange itself have no +// gateway binding, so the compiler refuses them rather than placing them in +// the runtime. +func TestCompileAgentRejectsRuntimeSigningCredentials(t *testing.T) { + for _, tt := range []struct { + name string + spec v1alpha3.ModelConfigSpec + data map[string][]byte + }{ + { + name: "Bedrock IAM keys", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderBedrock, Model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", Bedrock: &v1alpha3.BedrockConfig{Region: "us-east-1"}}, + data: map[string][]byte{"AWS_ACCESS_KEY_ID": []byte("access"), "AWS_SECRET_ACCESS_KEY": []byte("secret")}, + }, + { + name: "SAP AI Core client credentials", + spec: v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderSAPAICore, Model: "gpt-4o", SAPAICore: &v1alpha3.SAPAICoreConfig{BaseURL: "https://api.ai.example.com", ResourceGroup: "default", AuthURL: "https://auth.example.com"}}, + data: map[string][]byte{"client_id": []byte("client"), "client_secret": []byte("secret")}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "model-auth", Namespace: "test"}, Data: tt.data} + model := &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Name: "default-model", Namespace: "test"}, Spec: tt.spec} + model.Spec.APIKeySecret = secret.Name + harness := gatewayCredentialHarness() + template := &v1alpha3.AgentTemplate{ + ObjectMeta: metav1.ObjectMeta{Name: "helper", Namespace: "test"}, + Spec: v1alpha3.AgentTemplateSpec{ModelConfig: &corev1.LocalObjectReference{Name: model.Name}, SystemPrompt: "help"}, + } + _, err := compiler(t, model, secret, harness).CompileAgent(t.Context(), inlineAgent(harness, template)) + var validation *translator.ValidationError + require.ErrorAs(t, err, &validation) + require.ErrorContains(t, err, "cannot use gateway header injection") + }) + } +} + func TestCompileAgentInjectsCredentialsAtGateway(t *testing.T) { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: "mcp-auth", Namespace: "test"}, diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 00ec89fa49..fd00a04639 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -8,6 +8,7 @@ import ( corev1 "k8s.io/api/core/v1" "github.com/kagent-dev/kagent/go/adk/pkg/models" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/env" @@ -20,18 +21,18 @@ const CredentialPlaceholder = "kagent-credential-injected" // CompileCredentials replaces secret-backed environment values with inert SDK // placeholders and compiles their destination-scoped gateway bindings. Models // outside the agent tree (such as memory embeddings) are supplied separately. -func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar) ([]corev1.EnvVar, []egress.Credential, []string, error) { +func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, environment []corev1.EnvVar, settings *configv1.RuntimeDefaultsConfig) ([]corev1.EnvVar, []egress.Credential, []string, error) { var bindings []egress.Credential var tokenPassthroughOrigins []string boundModels := map[string]bool{} boundMCP := map[string]bool{} var passthroughEndpoints []string - bind := func(rawURL, header, prefix, namespace, name, key string) error { + bind := func(rawURL, header, prefix, authority, namespace, name, key string) error { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Fragment != "" { return NewValidationError("credential destination must be an absolute HTTP(S) URL without user information or fragment") } - uri := "ate-secret://k8s.io/default/" + namespace + "/" + name + "/" + key + uri := egress.CredentialURI(authority, namespace, name, key) bindings = append(bindings, egress.Credential{Origin: egress.Origin(u), Header: header, Prefix: prefix, URI: uri}) return nil } @@ -48,7 +49,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, for _, ref := range tool.Server.Spec.HeadersFrom { if ref.ValueFrom != nil && ref.ValueFrom.Type == v1alpha3.SecretValueSource { boundMCP[ref.ValueFrom.Name+"\x00"+ref.ValueFrom.Key] = true - if err := bind(tool.Server.Spec.URL, ref.Name, "", tool.Server.Namespace, ref.ValueFrom.Name, ref.ValueFrom.Key); err != nil { + if err := bind(tool.Server.Spec.URL, ref.Name, "", egress.KubernetesSecretAuthority, tool.Server.Namespace, ref.ValueFrom.Name, ref.ValueFrom.Key); err != nil { return err } } @@ -69,16 +70,19 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, if model.Spec.TokenPassthrough || model.Spec.APIKeySecret == "" { continue } - name, endpoint, header, prefix := modelCredentialTarget(resolved) - if name == "" { + target := modelCredentialTarget(resolved, settings) + if target.name == "" { continue } + if target.name == vertexAICredentialName && target.authority == egress.KubernetesSecretAuthority { + return nil, nil, nil, NewValidationError("ModelConfig %q provider %s needs a credential provider class that serves Vertex AI access tokens; set runtimeDefaults.modelCredentialAuthorities.%s", model.Name, model.Spec.Provider, model.Spec.Provider) + } key := model.Spec.APIKeySecretKey if model.Spec.Provider == v1alpha3.ModelProviderBedrock { key = env.AWSBearerTokenBedrock.Name() bearer := false for _, variable := range environment { - if variable.Name == name && variable.ValueFrom != nil && variable.ValueFrom.SecretKeyRef != nil && variable.ValueFrom.SecretKeyRef.Name == model.Spec.APIKeySecret && variable.ValueFrom.SecretKeyRef.Key == key { + if variable.Name == target.name && variable.ValueFrom != nil && variable.ValueFrom.SecretKeyRef != nil && variable.ValueFrom.SecretKeyRef.Name == model.Spec.APIKeySecret && variable.ValueFrom.SecretKeyRef.Key == key { bearer = true } } @@ -86,17 +90,19 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, continue } } - if err := bind(endpoint, header, prefix, model.Namespace, model.Spec.APIKeySecret, key); err != nil { + if err := bind(target.endpoint, target.header, target.prefix, target.authority, model.Namespace, model.Spec.APIKeySecret, key); err != nil { return nil, nil, nil, err } - boundModels[name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true + boundModels[target.name+"\x00"+model.Spec.APIKeySecret+"\x00"+key] = true } for _, resolved := range models { if !resolved.Config.Spec.TokenPassthrough { continue } - _, endpoint, _, _ := modelCredentialTarget(resolved) - passthroughEndpoints = append(passthroughEndpoints, endpoint) + if VertexAIConfig(&resolved.Config.Spec) != nil { + return nil, nil, nil, NewValidationError("ModelConfig %q provider %s does not support tokenPassthrough", resolved.Config.Name, resolved.Config.Spec.Provider) + } + passthroughEndpoints = append(passthroughEndpoints, modelCredentialTarget(resolved, settings).endpoint) } for _, endpoint := range passthroughEndpoints { u, err := url.Parse(strings.TrimSpace(endpoint)) @@ -133,8 +139,31 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, return result, access.Credentials, tokenPassthroughOrigins, nil } -func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, header, prefix string) { +// ModelCredentialAuthority is the Substrate credential provider class the +// egress gateway resolves a ModelConfig provider's apiKeySecret through. +func ModelCredentialAuthority(settings *configv1.RuntimeDefaultsConfig, provider v1alpha3.ModelProvider) string { + if authority := settings.GetModelCredentialAuthorities()[string(provider)]; authority != "" { + return authority + } + return egress.KubernetesSecretAuthority +} + +// vertexAICredentialName keys a Vertex AI binding. No variable carries the +// Secret, and the space keeps the name from matching any environment variable. +const vertexAICredentialName = "Vertex AI access token" + +// credentialTarget is where a ModelConfig credential is injected and which +// Substrate credential provider class resolves it. name is the SDK variable the +// placeholder replaces and keys the binding; an empty name means the provider +// has no gateway credential. +type credentialTarget struct { + name, endpoint, header, prefix, authority string +} + +func modelCredentialTarget(resolved *ResolvedModelConfig, settings *configv1.RuntimeDefaultsConfig) credentialTarget { spec := resolved.Config.Spec + target := credentialTarget{authority: ModelCredentialAuthority(settings, spec.Provider)} + var name, endpoint, header, prefix string switch spec.Provider { case v1alpha3.ModelProviderOpenAI: name, endpoint, header, prefix = env.OpenAIAPIKey.Name(), "https://api.openai.com", "authorization", "Bearer " @@ -153,6 +182,13 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade } case v1alpha3.ModelProviderGemini: name, endpoint, header = env.GoogleAPIKey.Name(), "https://generativelanguage.googleapis.com", "x-goog-api-key" + case v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI: + // Vertex AI accepts only OAuth 2.0 access tokens, which the configured + // provider class mints from the service account key the Secret holds. + name, header, prefix = vertexAICredentialName, "authorization", "Bearer " + if vertex := VertexAIConfig(&spec); vertex != nil { + endpoint = "https://" + VertexAIHostname(vertex.Location) + } case v1alpha3.ModelProviderMistral: name, endpoint, header, prefix = env.MistralAPIKey.Name(), "https://api.mistral.ai", "authorization", "Bearer " if spec.Mistral != nil && spec.Mistral.BaseURL != nil && *spec.Mistral.BaseURL != "" { @@ -190,5 +226,6 @@ func modelCredentialTarget(resolved *ResolvedModelConfig) (name, endpoint, heade header = "x-api-key" } } - return name, endpoint, header, prefix + target.name, target.endpoint, target.header, target.prefix = name, endpoint, header, prefix + return target } diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index d130a02b31..0ecb5c3a03 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -7,7 +7,9 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/egress" ) func TestCompileCredentialDestinations(t *testing.T) { @@ -34,7 +36,7 @@ func TestCompileCredentialDestinations(t *testing.T) { } input := credentialInput(test.spec) environment := []corev1.EnvVar{credentialEnv(test.env, "auth", test.spec.APIKeySecretKey)} - got, bindings, _, err := CompileCredentials(input, nil, environment) + got, bindings, _, err := CompileCredentials(input, nil, environment, nil) require.NoError(t, err) require.Equal(t, CredentialPlaceholder, got[0].Value) require.Nil(t, got[0].ValueFrom) @@ -48,37 +50,142 @@ func TestCompileCredentialDestinations(t *testing.T) { } } +// A Vertex AI key is bound on the provider class configured for its provider, +// which mints the bearer token the gateway sets; no variable carries the key, +// so the environment compiles unchanged. +func TestCompileCredentialsBindsVertexAIWithoutEnvironment(t *testing.T) { + for _, test := range []struct { + name string + spec v1alpha3.ModelConfigSpec + host string + }{ + {"Gemini regional", vertexSpec(v1alpha3.ModelProviderGeminiVertexAI, "us-central1"), "us-central1-aiplatform.googleapis.com"}, + {"Anthropic global", vertexSpec(v1alpha3.ModelProviderAnthropicVertexAI, "global"), "aiplatform.googleapis.com"}, + {"Anthropic multi-region", vertexSpec(v1alpha3.ModelProviderAnthropicVertexAI, "eu"), "aiplatform.eu.rep.googleapis.com"}, + } { + t.Run(test.name, func(t *testing.T) { + spec := test.spec + spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" + settings := &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{string(spec.Provider): "tokens.example.com"}} + environment := []corev1.EnvVar{{Name: "GOOGLE_CLOUD_LOCATION", Value: "somewhere"}} + got, bindings, _, err := CompileCredentials(credentialInput(spec), nil, environment, settings) + require.NoError(t, err) + require.Equal(t, environment, got) + require.Equal(t, []egress.Credential{{ + Origin: "https://" + test.host + ":443", Header: "authorization", Prefix: "Bearer ", + URI: "ate-secret://tokens.example.com/default/team/auth/credentials.json", + }}, bindings) + }) + } +} + +// k8s.io would set the stored service account key itself as the bearer token, +// so a Vertex AI key needs a class configured for its provider. +func TestCompileCredentialsRejectsVertexAIOnKubernetesClass(t *testing.T) { + for _, provider := range []v1alpha3.ModelProvider{v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI} { + for _, settings := range []*configv1.RuntimeDefaultsConfig{ + nil, + {ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}}, + {ModelCredentialAuthorities: map[string]string{string(provider): "k8s.io"}}, + } { + t.Run(string(provider), func(t *testing.T) { + spec := vertexSpec(provider, "us-central1") + spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" + compiled, bindings, _, err := CompileCredentials(credentialInput(spec), nil, nil, settings) + require.ErrorContains(t, err, "set runtimeDefaults.modelCredentialAuthorities."+string(provider)) + require.Nil(t, compiled) + require.Nil(t, bindings) + }) + } + } +} + +func TestCompileCredentialsRejectsVertexAITokenPassthrough(t *testing.T) { + for _, provider := range []v1alpha3.ModelProvider{v1alpha3.ModelProviderGeminiVertexAI, v1alpha3.ModelProviderAnthropicVertexAI} { + t.Run(string(provider), func(t *testing.T) { + spec := vertexSpec(provider, "us-central1") + spec.TokenPassthrough = true + _, _, origins, err := CompileCredentials(credentialInput(spec), nil, nil, nil) + require.ErrorContains(t, err, "does not support tokenPassthrough") + require.Nil(t, origins) + }) + } +} + +func vertexSpec(provider v1alpha3.ModelProvider, location string) v1alpha3.ModelConfigSpec { + vertex := v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: location} + if provider == v1alpha3.ModelProviderGeminiVertexAI { + return v1alpha3.ModelConfigSpec{Provider: provider, GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: vertex}} + } + return v1alpha3.ModelConfigSpec{Provider: provider, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: vertex}} +} + +func TestCompileCredentialsModelAuthority(t *testing.T) { + settings := &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}} + server := &v1alpha3.RemoteMCPServer{ObjectMeta: metav1.ObjectMeta{Name: "mcp", Namespace: "team"}, Spec: v1alpha3.RemoteMCPServerSpec{ + URL: "https://mcp.example.com/mcp", + HeadersFrom: []v1alpha3.ValueRef{{Name: "X-API-Key", ValueFrom: &v1alpha3.ValueSource{ + Type: v1alpha3.SecretValueSource, Name: "mcp", Key: "token", + }}}, + }} + input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "auth", APIKeySecretKey: "token"}) + input.Root.MCPTools = []ResolvedMCPTool{{Server: server}} + environment := []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token"), credentialEnv("KAGENT_CREDENTIAL_MCP", "mcp", "token")} + + _, bindings, _, err := CompileCredentials(input, nil, environment, settings) + require.NoError(t, err) + require.Equal(t, []egress.Credential{ + {Origin: "https://api.openai.com:443", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://tokens.example.com/default/team/auth/token"}, + // MCP headers are not ModelConfig credentials and keep the Kubernetes class. + {Origin: "https://mcp.example.com:443", Header: "x-api-key", URI: "ate-secret://k8s.io/default/team/mcp/token"}, + }, bindings) + + settings.ModelCredentialAuthorities["OpenAI"] = "192.0.2.1" + compiled, _, _, err := CompileCredentials(input, nil, environment, settings) + require.ErrorContains(t, err, "invalid credential URI") + require.Nil(t, compiled) +} + +func TestCompileCredentialsRejectsRuntimeSigningSecrets(t *testing.T) { + for _, name := range []string{"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "SAP_AI_CORE_CLIENT_ID", "SAP_AI_CORE_CLIENT_SECRET", "GOOGLE_APPLICATION_CREDENTIALS"} { + t.Run(name, func(t *testing.T) { + _, _, _, err := CompileCredentials(credentialInput(v1alpha3.ModelConfigSpec{}), nil, []corev1.EnvVar{credentialEnv(name, "auth", "token")}, nil) + require.ErrorContains(t, err, "cannot use gateway header injection") + }) + } +} + func TestCompileCredentialsRejectsConflictingSharedModels(t *testing.T) { spec := v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "root", APIKeySecretKey: "token"} input := credentialInput(spec) spec.APIKeySecret = "child" input.Root.Shared = []AgentInputBinding{{Agent: credentialInput(spec).Root}} // The ADK environment is deduplicated, but every model still needs its own binding. - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}, nil) require.ErrorContains(t, err, "conflicting credentials") require.Nil(t, environment) input.Root.Shared[0].Agent.ResolvedModelConfig.Config.Spec.OpenAI = &v1alpha3.OpenAIConfig{BaseURL: "https://child.example.com/v1"} - _, bindings, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}) + _, bindings, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "child", "token")}, nil) require.NoError(t, err) require.Len(t, bindings, 2) } func TestCompileCredentialsRejectsLocalSecrets(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{}) - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("AWS_SECRET_ACCESS_KEY", "auth", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("AWS_SECRET_ACCESS_KEY", "auth", "token")}, nil) require.ErrorContains(t, err, "cannot use gateway header injection") require.Nil(t, environment) } func TestCompileCredentialsPreservesPassthrough(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true}) - _, bindings, _, err := CompileCredentials(input, nil, nil) + _, bindings, _, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Empty(t, bindings) input.Root.Shared = []AgentInputBinding{{Agent: credentialInput(v1alpha3.ModelConfigSpec{ Provider: v1alpha3.ModelProviderOpenAI, APIKeySecret: "auth", APIKeySecretKey: "token", }).Root}} - environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}) + environment, _, _, err := CompileCredentials(input, nil, []corev1.EnvVar{credentialEnv("OPENAI_API_KEY", "auth", "token")}, nil) require.ErrorContains(t, err, "cannot combine caller-token passthrough") require.Nil(t, environment) } @@ -167,16 +274,16 @@ func TestModelCredentialTargetOllamaCloud(t *testing.T) { Spec: tt.spec, }, } - name, endpoint, _, _ := modelCredentialTarget(resolved) - require.Equal(t, tt.wantName, name) - require.Equal(t, tt.wantEndpoint, endpoint) + target := modelCredentialTarget(resolved, nil) + require.Equal(t, tt.wantName, target.name) + require.Equal(t, tt.wantEndpoint, target.endpoint) }) } } func TestPassthroughCredentialUsesCanonicalOrigin(t *testing.T) { input := credentialInput(v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderOpenAI, TokenPassthrough: true, OpenAI: &v1alpha3.OpenAIConfig{BaseURL: "https://API.OPENAI.COM.:0443/v1"}}) - environment, credentials, origins, err := CompileCredentials(input, nil, nil) + environment, credentials, origins, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Empty(t, environment) require.Empty(t, credentials) @@ -189,7 +296,7 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }} input := credentialInput(v1alpha3.ModelConfigSpec{}) input.Root.MCPTools = []ResolvedMCPTool{{Server: passthrough}} - _, _, origins, err := CompileCredentials(input, nil, nil) + _, _, origins, err := CompileCredentials(input, nil, nil, nil) require.NoError(t, err) require.Equal(t, []string{"https://mcp.example.com:443"}, origins) @@ -201,7 +308,7 @@ func TestCompileCredentialsMCPTokenPassthrough(t *testing.T) { }}}, }} input.Root.MCPTools = append(input.Root.MCPTools, ResolvedMCPTool{Server: keyed}) - environment, _, _, err := CompileCredentials(input, nil, nil) + environment, _, _, err := CompileCredentials(input, nil, nil, nil) require.ErrorContains(t, err, "cannot combine caller-token passthrough") require.Nil(t, environment) } diff --git a/go/core/internal/translator/egress.go b/go/core/internal/translator/egress.go index 3f8b5271ef..efffb45830 100644 --- a/go/core/internal/translator/egress.go +++ b/go/core/internal/translator/egress.go @@ -119,7 +119,7 @@ func compileEgressPolicy(namespace string, policy *v1alpha3.EgressPolicy, getSec if secret.Name == "" || secret.Key == "" || (secret.Optional != nil && *secret.Optional) { return egress.Access{}, NewValidationError("requires a non-optional Secret name and key") } - binding.URI = "ate-secret://k8s.io/default/" + namespace + "/" + secret.Name + "/" + secret.Key + binding.URI = egress.CredentialURI(egress.KubernetesSecretAuthority, namespace, secret.Name, secret.Key) if _, err := egress.CanonicalCredentials([]egress.Credential{binding}); err != nil { return egress.Access{}, err } diff --git a/go/core/internal/translator/kagent/compiler.go b/go/core/internal/translator/kagent/compiler.go index 3593770b7e..cd43cbbbd1 100644 --- a/go/core/internal/translator/kagent/compiler.go +++ b/go/core/internal/translator/kagent/compiler.go @@ -63,7 +63,7 @@ func (c *Compiler) Compile(ctx context.Context, input *translator.HarnessInput) if err != nil { return nil, fmt.Errorf("build revision provenance: %w", err) } - environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment) + environment, credentials, tokenPassthroughOrigins, err := translator.CompileCredentials(input, compiled.Models, environment, c.settings) if err != nil { return nil, err } diff --git a/go/core/internal/translator/vertex.go b/go/core/internal/translator/vertex.go new file mode 100644 index 0000000000..9190013cf2 --- /dev/null +++ b/go/core/internal/translator/vertex.go @@ -0,0 +1,40 @@ +package translator + +import ( + "github.com/kagent-dev/kagent/go/api/v1alpha3" +) + +// VertexAIConfig is the project and location of a Vertex AI ModelConfig, for +// either provider that reaches Vertex AI; nil for every other provider or an +// incomplete spec. +func VertexAIConfig(spec *v1alpha3.ModelConfigSpec) *v1alpha3.BaseVertexAIConfig { + switch spec.Provider { + case v1alpha3.ModelProviderGeminiVertexAI: + if spec.GeminiVertexAI != nil { + return &spec.GeminiVertexAI.BaseVertexAIConfig + } + case v1alpha3.ModelProviderAnthropicVertexAI: + if spec.AnthropicVertexAI != nil { + return &spec.AnthropicVertexAI.BaseVertexAIConfig + } + } + return nil +} + +// VertexAIHostname is the Vertex AI endpoint for a ModelConfig location: the +// global endpoint, a multi-region endpoint for us and eu, or the regional one. +func VertexAIHostname(location string) string { + switch location { + case "global": + return "aiplatform.googleapis.com" + case "us", "eu": + return "aiplatform." + location + ".rep.googleapis.com" + default: + return location + "-aiplatform.googleapis.com" + } +} + +// VertexAIOrigin is the egress origin of VertexAIHostname. +func VertexAIOrigin(location string) string { + return "https://" + VertexAIHostname(location) + ":443" +} diff --git a/go/core/pkg/config/helm_test.go b/go/core/pkg/config/helm_test.go index 24d08b38f1..b585b76faa 100644 --- a/go/core/pkg/config/helm_test.go +++ b/go/core/pkg/config/helm_test.go @@ -105,6 +105,10 @@ func TestHelmControllerConfig(t *testing.T) { require.True(t, push.Push.AllowHttp) require.True(t, push.Push.AllowPrivateNetworks) require.NotEqual(t, checksum, pushChecksum) + authorities, _, err := render("config:\n runtimeDefaults:\n modelCredentialAuthorities: {GeminiVertexAI: tokens.example.com}\n") + require.NoError(t, err) + require.Equal(t, map[string]string{"GeminiVertexAI": "tokens.example.com"}, authorities.RuntimeDefaults.GetModelCredentialAuthorities()) + require.Equal(t, "http://demo-controller.team.svc:8083", authorities.RuntimeDefaults.GetControlPlaneUrl()) _, _, err = render("config:\n loging: {level: debug}\n") require.ErrorContains(t, err, "unknown field") } diff --git a/go/core/pkg/config/load_test.go b/go/core/pkg/config/load_test.go index aae976bbe9..153d27ade6 100644 --- a/go/core/pkg/config/load_test.go +++ b/go/core/pkg/config/load_test.go @@ -1,10 +1,13 @@ package config_test import ( + "os" + "slices" "testing" "time" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" configv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/config/v1" "github.com/kagent-dev/kagent/go/core/pkg/config" @@ -51,6 +54,11 @@ func TestControllerConfig(t *testing.T) { {"runtime service name", "runtimeDefaults: {telemetry: {resourceAttributes: {service.name: shared}}}", true}, {"runtime service namespace", "runtimeDefaults: {telemetry: {resourceAttributes: {service.namespace: shared}}}", true}, {"runtime resource attributes", "runtimeDefaults: {telemetry: {resourceAttributes: {deployment.environment.name: production}}}", false}, + {"model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {GeminiVertexAI: tokens.example.com, AnthropicVertexAI: tokens.example.com}}", false}, + {"model credential authority for an unknown provider", "runtimeDefaults: {modelCredentialAuthorities: {Vertex: tokens.example.com}}", true}, + {"model credential authority with a port", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: 'tokens.example.com:50051'}}", true}, + {"uppercase model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: Tokens.Example.com}}", true}, + {"IP model credential authority", "runtimeDefaults: {modelCredentialAuthorities: {OpenAI: 192.0.2.1}}", true}, {"controller service labels", "telemetry: {resourceAttributes: {service.name: controller, service.namespace: control-plane}}", false}, } { t.Run(tc.name, func(t *testing.T) { @@ -70,6 +78,40 @@ func TestControllerConfig(t *testing.T) { } } +// The provider keys modelCredentialAuthorities accepts are the ModelConfig +// provider enum of the published CRD. +func TestModelCredentialAuthorityProvidersMatchModelConfig(t *testing.T) { + type schema struct { + Enum []string `yaml:"enum"` + Properties map[string]schema `yaml:"properties"` + } + type version struct { + Name string `yaml:"name"` + Schema struct { + OpenAPIV3Schema schema `yaml:"openAPIV3Schema"` + } `yaml:"schema"` + } + var crd struct { + Spec struct { + Versions []version `yaml:"versions"` + } `yaml:"spec"` + } + data, err := os.ReadFile("../../../../helm/kagent-crds/templates/api.kagent.dev_modelconfigs.yaml") + require.NoError(t, err) + require.NoError(t, yaml.Unmarshal(data, &crd)) + index := slices.IndexFunc(crd.Spec.Versions, func(v version) bool { return v.Name == "v1alpha3" }) + require.GreaterOrEqual(t, index, 0) + providers := crd.Spec.Versions[index].Schema.OpenAPIV3Schema.Properties["spec"].Properties["provider"].Enum + require.NotEmpty(t, providers) + for _, provider := range providers { + cfg := &configv1.ControllerConfig{} + config.DefaultController(cfg) + setLogins(cfg) + cfg.RuntimeDefaults.ModelCredentialAuthorities = map[string]string{provider: "tokens.example.com"} + require.NoError(t, config.Validate(cfg), provider) + } +} + func TestDatabaseSkipMigrations(t *testing.T) { for _, tc := range []struct { name, input string diff --git a/go/core/pkg/env/providers.go b/go/core/pkg/env/providers.go index ea050c1a9b..5769eac928 100644 --- a/go/core/pkg/env/providers.go +++ b/go/core/pkg/env/providers.go @@ -105,6 +105,16 @@ var ( "Path to Google Cloud service account JSON key file.", ComponentAgentRuntime, ) + + // The compiler sets this for a Vertex AI ModelConfig whose service account + // key the egress gateway exchanges for an access token on every call, the + // counterpart of Claude Code's CLAUDE_CODE_SKIP_VERTEX_AUTH. + SkipVertexAuth = RegisterBoolVar( + "KAGENT_SKIP_VERTEX_AUTH", + false, + "When true, call Vertex AI without Google credentials of the runtime's own; the egress gateway sets the access token.", + ComponentAgentRuntime, + ) ) // AWS / Bedrock diff --git a/go/go.mod b/go/go.mod index e6d9e82690..c48e10b0c6 100644 --- a/go/go.mod +++ b/go/go.mod @@ -65,6 +65,7 @@ require ( go.opentelemetry.io/otel/trace v1.47.0 go.opentelemetry.io/proto/otlp v1.11.1 golang.org/x/net v0.61.0 + golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 golang.org/x/term v0.47.0 google.golang.org/adk/v2 v2.5.0 @@ -454,7 +455,6 @@ require ( golang.org/x/exp v0.0.0-20261011100332-faecfb24eb49 // indirect golang.org/x/exp/typeparams v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/mod v0.42.0 // indirect - golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/telemetry v0.0.0-20261010193011-f71dc92da45e // indirect golang.org/x/text v0.43.0 // indirect diff --git a/go/harness/claude/README.md b/go/harness/claude/README.md index 87a8e52400..ad804bfda6 100644 --- a/go/harness/claude/README.md +++ b/go/harness/claude/README.md @@ -15,12 +15,12 @@ harness. | [`../../core/internal/translator/claude`](../../core/internal/translator/claude) | Translating `Harness`, `AgentTemplate`, model, MCP, plugin, and Secret inputs into a runtime revision and warnings | | [`config/config.go`](config/config.go) | The versioned JSON contract shared by the compiler and runtime, including defaults and reserved environment variables | | [`cmd/main.go`](cmd/main.go) | Actor startup, environment inputs, Claude version validation, continuation-store wiring, and private A2A startup | -| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, MCP config, and ephemeral provider credentials | +| [`internal/adapter/adapter.go`](internal/adapter/adapter.go) | Materializing Claude home, skills, and MCP config | | [`internal/driver`](internal/driver) | Claude CLI arguments, stream-JSON parsing, runtime-event translation, cancellation, and process supervision | ## Working -- [x] Anthropic API keys and Amazon Bedrock bearer tokens, injected by the gateway +- [x] Anthropic API keys, Amazon Bedrock bearer tokens, and Vertex AI access tokens, injected by the gateway - [x] Streaming text, tool calls, and tool results over A2A - [x] Task cancellation - [x] Durable Claude session resume between turns @@ -32,8 +32,12 @@ harness. - [x] Native ask-user questions through durable deferred input Credentials use [Substrate gateway injection](../../../docs/architecture/credential-injection.md). -AWS IAM keys and Vertex service-account keys require local signing and are rejected -by the compiler. Harness environment entries accept only literal values. +The Vertex AI access token comes from the credential provider class configured +for `AnthropicVertexAI`; Claude Code runs with `CLAUDE_CODE_SKIP_VERTEX_AUTH` +and never holds the Secret, and +`ANTHROPIC_CUSTOM_HEADERS` carries the placeholder bearer the gateway replaces. +AWS IAM keys require local signing and are rejected by the compiler. Harness +environment entries accept only literal values. ## Telemetry diff --git a/go/harness/claude/config/config.go b/go/harness/claude/config/config.go index 802d339c9f..218c4e5f03 100644 --- a/go/harness/claude/config/config.go +++ b/go/harness/claude/config/config.go @@ -25,9 +25,9 @@ const ( ClaudeConfigDirEnvName = "CLAUDE_CONFIG_DIR" DisableUpdatesEnvName = "DISABLE_UPDATES" GoogleApplicationCredentialsEnvName = "GOOGLE_APPLICATION_CREDENTIALS" - GoogleCredentialsJSONEnvName = "KAGENT_CLAUDE_GOOGLE_CREDENTIALS_JSON" UseBedrockEnvName = "CLAUDE_CODE_USE_BEDROCK" UseVertexEnvName = "CLAUDE_CODE_USE_VERTEX" + SkipVertexAuthEnvName = "CLAUDE_CODE_SKIP_VERTEX_AUTH" AWSRegionEnvName = "AWS_REGION" AWSAccessKeyEnvName = "AWS_ACCESS_KEY_ID" AWSSecretKeyEnvName = "AWS_SECRET_ACCESS_KEY" @@ -35,6 +35,7 @@ const ( AWSBedrockTokenEnvName = "AWS_BEARER_TOKEN_BEDROCK" AnthropicAPIKeyEnvName = "ANTHROPIC_API_KEY" AnthropicBaseURLEnvName = "ANTHROPIC_BASE_URL" + AnthropicCustomHeadersEnvName = "ANTHROPIC_CUSTOM_HEADERS" VertexProjectEnvName = "ANTHROPIC_VERTEX_PROJECT_ID" VertexRegionEnvName = "CLOUD_ML_REGION" SandboxEnvName = "IS_SANDBOX" @@ -49,7 +50,7 @@ func OwnsEnvironment(name string) bool { } switch name { case ClaudeConfigDirEnvName, DisableUpdatesEnvName, GoogleApplicationCredentialsEnvName, - GoogleCredentialsJSONEnvName, UseBedrockEnvName, UseVertexEnvName, AWSRegionEnvName, + UseBedrockEnvName, UseVertexEnvName, SkipVertexAuthEnvName, AWSRegionEnvName, AWSAccessKeyEnvName, AWSSecretKeyEnvName, AWSSessionTokenEnvName, AWSBedrockTokenEnvName, AnthropicAPIKeyEnvName, AnthropicBaseURLEnvName, VertexProjectEnvName, VertexRegionEnvName, SandboxEnvName, "CLAUDE_CODE_ENABLE_TELEMETRY", diff --git a/go/harness/claude/config/config_test.go b/go/harness/claude/config/config_test.go index 95de1721f8..de8ed12117 100644 --- a/go/harness/claude/config/config_test.go +++ b/go/harness/claude/config/config_test.go @@ -13,6 +13,8 @@ func TestOwnsEnvironment(t *testing.T) { for _, name := range []string{ AnthropicAPIKeyEnvName, ClaudeConfigDirEnvName, + GoogleApplicationCredentialsEnvName, + SkipVertexAuthEnvName, "CLAUDE_CODE_ENHANCED_TELEMETRY_BETA", "OTEL_TRACES_EXPORTER", "OTEL_LOG_RAW_API_BODIES", diff --git a/go/harness/claude/internal/adapter/adapter.go b/go/harness/claude/internal/adapter/adapter.go index 1945cee735..53547cf16d 100644 --- a/go/harness/claude/internal/adapter/adapter.go +++ b/go/harness/claude/internal/adapter/adapter.go @@ -4,7 +4,6 @@ package adapter import ( "context" - "encoding/json" "fmt" "maps" "path/filepath" @@ -84,10 +83,6 @@ func New(ctx context.Context, input Input) (*driver.ProcessDriver, error) { // The image and compiler pin an exact Claude version. Prevent both automatic // and manual update paths from changing that runtime after validation. environment = setEnvironment(environment, config.DisableUpdatesEnvName, "1") - environment, err = materializeGoogleCredentials(environment, input.EphemeralDir) - if err != nil { - return nil, err - } protectedServers := approvalServerNames(cfg.MCPServers) if _, exists := cfg.MCPServers[approvalMCPServerName]; exists { return nil, fmt.Errorf("Claude MCP server name %q is reserved for human approval", approvalMCPServerName) @@ -147,39 +142,6 @@ func approvalServerNames(servers map[string]config.MCPServer) (protected []strin return protected } -func materializeGoogleCredentials(environment []string, directory string) ([]string, error) { - // The compiler injects the Secret value as JSON, while Google ADC expects a - // file path. Keep the credential in ephemeral Actor storage rather than the - // well-known path under /data, which is durable and may be snapshotted. - prefix := config.GoogleCredentialsJSONEnvName + "=" - var credentials string - filtered := make([]string, 0, len(environment)) - for _, item := range environment { - if strings.HasPrefix(item, prefix) { - if credentials != "" { - return nil, fmt.Errorf("%s is configured more than once", config.GoogleCredentialsJSONEnvName) - } - credentials = strings.TrimPrefix(item, prefix) - continue - } - filtered = append(filtered, item) - } - if credentials == "" { - return filtered, nil - } - if !json.Valid([]byte(credentials)) { - return nil, fmt.Errorf("%s must contain valid JSON", config.GoogleCredentialsJSONEnvName) - } - if err := utils.EnsurePrivateDir(directory); err != nil { - return nil, fmt.Errorf("prepare ephemeral credentials directory: %w", err) - } - path := filepath.Join(directory, "google-credentials.json") - if err := utils.ReplacePrivateFile(path, []byte(credentials)); err != nil { - return nil, fmt.Errorf("materialize Google credentials: %w", err) - } - return setEnvironment(filtered, config.GoogleApplicationCredentialsEnvName, path), nil -} - // nativeTelemetryEnvironment turns on Claude Code telemetry for the signals the // controller exports, and its content flags when capture is on. It reports // whether telemetry is on. diff --git a/go/harness/claude/internal/adapter/adapter_test.go b/go/harness/claude/internal/adapter/adapter_test.go index af0154c703..92413511bd 100644 --- a/go/harness/claude/internal/adapter/adapter_test.go +++ b/go/harness/claude/internal/adapter/adapter_test.go @@ -188,29 +188,6 @@ func TestNewRejectsInvalidInput(t *testing.T) { } } -func TestMaterializeGoogleCredentials(t *testing.T) { - dir := t.TempDir() - raw := `{"type":"service_account","project_id":"test"}` - environment, err := materializeGoogleCredentials([]string{"A=1", config.GoogleCredentialsJSONEnvName + "=" + raw}, dir) - if err != nil { - t.Fatal(err) - } - path := filepath.Join(dir, "google-credentials.json") - contents, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(contents) != raw { - t.Fatalf("credentials = %q", contents) - } - if len(environment) != 2 || environment[0] != "A=1" || environment[1] != config.GoogleApplicationCredentialsEnvName+"="+path { - t.Fatalf("environment = %v", environment) - } - if info, err := os.Stat(path); err != nil || info.Mode().Perm() != 0o600 { - t.Fatalf("credential permissions = %v, %v", info, err) - } -} - func TestSetEnvironmentOverridesExistingValue(t *testing.T) { got := setEnvironment([]string{"A=1", "A=2", "B=3"}, "A", "4") want := []string{"B=3", "A=4"} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 7d4b2adedc..3d0fdb22ff 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -200,6 +200,14 @@ config: logging: level: info telemetry: {} + # Credential provider class (the ate-secret:// URI authority) the Substrate + # egress gateway resolves a ModelConfig's apiKeySecret through, keyed by + # ModelConfig provider. A provider absent here uses k8s.io, which injects + # the Secret value as stored. Another class must be routed by the gateway. + # GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + # modelCredentialAuthorities: + # GeminiVertexAI: tokens.example.com + modelCredentialAuthorities: {} # ============================================================================== # CONTROLLER DEPLOYMENT diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 7f1bc27602..c47d7564d2 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -347,6 +347,41 @@ message RuntimeDefaultsConfig { optional int64 max_content_length = 5 [(buf.validate.field).int64.gte = 0]; TokenPropagationConfig token_propagation = 6; SkillToolsConfig skill_tools = 7; + // The Substrate credential provider class, the authority of the + // ate-secret:// URI, that the egress gateway resolves a ModelConfig's + // apiKeySecret through, keyed by ModelConfig provider. A provider absent + // here uses k8s.io, which injects the Secret value as it is stored. + // GeminiVertexAI and AnthropicVertexAI need a class that mints access tokens. + map model_credential_authorities = 8 [(buf.validate.field).map = { + keys: { + string: { + in: [ + "Anthropic", + "AnthropicVertexAI", + "AzureOpenAI", + "Bedrock", + "Foundry", + "Gemini", + "GeminiVertexAI", + "Mistral", + "Ollama", + "OpenAI", + "SAPAICore" + ] + } + } + values: { + cel: { + id: "model_credential_authorities.ip" + message: "a credential authority must be a DNS name, not an IP address" + expression: "!this.isIp()" + } + string: { + max_len: 253 + pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$" + } + } + }]; } // SkillToolsConfig configures the built-in tools available with local skills. diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py b/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py index 3dc1a9fde6..1e965aa387 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_embedding.py @@ -20,6 +20,7 @@ from kagent.adk._bearer_token import bearer_token from kagent.adk.models._ssl import create_ssl_context +from kagent.adk.models._vertex import vertex_client_kwargs from kagent.adk.types import EmbeddingConfig from ._azure import ( @@ -299,7 +300,7 @@ async def _embed_google(self, texts: List[str]) -> List[List[float]]: from google.genai import types as genai_types if self.config.provider.lower() == "vertex_ai": - client = genai.Client(vertexai=True) + client = genai.Client(vertexai=True, **(vertex_client_kwargs() or {})) else: client = genai.Client() diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py new file mode 100644 index 0000000000..f2f7711582 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py @@ -0,0 +1,54 @@ +"""Vertex AI authentication through the Substrate egress gateway.""" + +from __future__ import annotations + +import os +import re +from typing import Any, Optional + +from anthropic import AsyncAnthropicVertex +from google.adk.models.anthropic_llm import Claude as ClaudeLLM +from google.oauth2.credentials import Credentials + +SKIP_VERTEX_AUTH_ENV = "KAGENT_SKIP_VERTEX_AUTH" + +# The egress gateway replaces the Authorization header a request carries with +# the access token its credential provider serves and adds none, so a call +# without this placeholder leaves unauthenticated. +VERTEX_GATEWAY_TOKEN = "kagent-credential-injected" + + +def skip_vertex_auth() -> bool: + """Reports whether the egress gateway sets the Vertex AI access token.""" + return os.environ.get(SKIP_VERTEX_AUTH_ENV, "").strip().lower() in ("1", "t", "true") + + +def vertex_client_kwargs() -> Optional[dict[str, Any]]: + """google-genai Client arguments for Vertex AI under KAGENT_SKIP_VERTEX_AUTH. + + A token without an expiry is never refreshed, so the client looks for no + Application Default Credentials and sends the placeholder bearer. + """ + if not skip_vertex_auth(): + return None + return {"credentials": Credentials(token=VERTEX_GATEWAY_TOKEN)} + + +def claude_vertex_llm(model: str) -> ClaudeLLM: + """Claude on Vertex AI. Under KAGENT_SKIP_VERTEX_AUTH its client sends the + gateway placeholder bearer instead of resolving Google credentials. + + The project and location come from the environment, or from a model given + as a full ``projects//locations//...`` resource name. + """ + if not skip_vertex_auth(): + return ClaudeLLM(model=model) + project_id = os.environ.get("GOOGLE_CLOUD_PROJECT") + location = os.environ.get("GOOGLE_CLOUD_LOCATION") + match = re.search(r"projects/([^/]+)/locations/([^/]+)/", model) + if match: + project_id, location = match.group(1), match.group(2) + if not project_id or not location: + raise ValueError(f"Claude model {model!r} on Vertex AI needs GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION") + client = AsyncAnthropicVertex(project_id=project_id, region=location, access_token=VERTEX_GATEWAY_TOKEN) + return ClaudeLLM(model=model, client=client) diff --git a/python/packages/kagent-adk/src/kagent/adk/types.py b/python/packages/kagent-adk/src/kagent/adk/types.py index df2c3e5307..a22e98f4e7 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -10,7 +10,6 @@ from google.adk.agents.callback_context import CallbackContext from google.adk.agents.llm_agent import ToolUnion from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.models.anthropic_llm import Claude as ClaudeLLM from google.adk.tools.mcp_tool import SseConnectionParams, StreamableHTTPConnectionParams from google.protobuf.json_format import Parse, ParseDict from kagent.config.v1.adk_pb2 import ( @@ -101,6 +100,7 @@ from kagent.adk.models._openai import FoundryOpenAI from kagent.adk.models._openai import OpenAI as OpenAINative from kagent.adk.models._ssl import create_ssl_context +from kagent.adk.models._vertex import claude_vertex_llm, vertex_client_kwargs from kagent.adk.tools.ask_user_tool import AskUserTool logger = logging.getLogger(__name__) @@ -583,9 +583,10 @@ def _create_llm_from_model_config(model_config: ModelConfig): return KAgentGeminiVertexAILlm( model=model_config.model, max_output_tokens=optional_field(settings, "max_output_tokens"), + client_kwargs=vertex_client_kwargs(), ) if provider == "gemini_anthropic": - return ClaudeLLM(model=model_config.model) + return claude_vertex_llm(model_config.model) if provider == "ollama": ollama_options = _convert_ollama_options(dict(settings.options)) # api key passthrough is not applicable for ollama diff --git a/python/packages/kagent-adk/tests/unittests/models/test_vertex.py b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py new file mode 100644 index 0000000000..8949b65604 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py @@ -0,0 +1,111 @@ +"""Tests for Vertex AI authentication through the egress gateway.""" + +import google.auth +import httpx +import httpx2 +import pytest +from google.genai import Client, types + +from kagent.adk.models._vertex import ( + VERTEX_GATEWAY_TOKEN, + claude_vertex_llm, + skip_vertex_auth, + vertex_client_kwargs, +) + + +@pytest.fixture +def vertex_env(monkeypatch): + monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "project") + monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-east5") + + def no_ambient_credentials(*args, **kwargs): + raise AssertionError("Application Default Credentials must not be resolved") + + monkeypatch.setattr(google.auth, "default", no_ambient_credentials) + return monkeypatch + + +@pytest.mark.parametrize( + ("value", "want"), + [("true", True), ("1", True), ("TRUE", True), ("false", False), ("", False), ("yes", False)], +) +def test_skip_vertex_auth(monkeypatch, value, want): + monkeypatch.setenv("KAGENT_SKIP_VERTEX_AUTH", value) + assert skip_vertex_auth() is want + + +def test_vertex_client_kwargs_without_switch(monkeypatch): + monkeypatch.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) + assert vertex_client_kwargs() is None + + +def test_gemini_vertex_sends_gateway_placeholder(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json={"candidates": [{"content": {"role": "model", "parts": [{"text": "ok"}]}}]}) + + client = Client( + vertexai=True, + project="project", + location="us-east5", + http_options=types.HttpOptions(client_args={"transport": httpx.MockTransport(handler)}), + **vertex_client_kwargs(), + ) + client.models.generate_content(model="gemini-2.5-pro", contents="hi") + + assert len(seen) == 1 + assert seen[0].url.host == "us-east5-aiplatform.googleapis.com" + assert seen[0].headers["authorization"] == f"Bearer {VERTEX_GATEWAY_TOKEN}" + + +@pytest.mark.asyncio +async def test_claude_vertex_sends_gateway_placeholder(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + seen = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + seen.append(request) + return httpx2.Response( + 200, + json={ + "id": "msg", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + model = claude_vertex_llm("claude-sonnet-4-5@20250929") + client = model.client.with_options(http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler))) + await client.messages.create( + model="claude-sonnet-4-5@20250929", max_tokens=1, messages=[{"role": "user", "content": "hi"}] + ) + + assert len(seen) == 1 + assert seen[0].url.host == "us-east5-aiplatform.googleapis.com" + assert seen[0].headers["authorization"] == f"Bearer {VERTEX_GATEWAY_TOKEN}" + + +def test_claude_vertex_keeps_google_auth_without_switch(vertex_env): + vertex_env.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) + assert claude_vertex_llm("claude-sonnet-4-5@20250929").client is None + + +def test_claude_vertex_resource_name_sets_project_and_location(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + client = claude_vertex_llm("projects/other/locations/europe-west1/publishers/anthropic/models/claude").client + assert (client.project_id, client.region) == ("other", "europe-west1") + + +def test_claude_vertex_requires_project_and_location(vertex_env): + vertex_env.setenv("KAGENT_SKIP_VERTEX_AUTH", "true") + vertex_env.delenv("GOOGLE_CLOUD_LOCATION") + with pytest.raises(ValueError, match="GOOGLE_CLOUD_LOCATION"): + claude_vertex_llm("claude-sonnet-4-5@20250929")