From d4c1eab28026837ced5b73406eb7c1de353b4e53 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:23:45 +0200 Subject: [PATCH 1/7] feat(adk): call Vertex AI without local Google credentials under KAGENT_SKIP_VERTEX_AUTH With KAGENT_SKIP_VERTEX_AUTH=true the Go ADK builds its Vertex AI clients without Application Default Credentials: the genai client runs on a plain HTTP client and the Anthropic Vertex client on a placeholder token source, and both send the placeholder bearer the egress gateway replaces with the access token its credential provider serves. The HTTP client option now comes before the Vertex option, which wraps only a client passed before it. Co-authored-by: Timo Derstappen Signed-off-by: QuentinBisson --- docs/env.md | 1 + go/adk/pkg/agent/agent.go | 52 ++++++++++++++++------ go/adk/pkg/agent/agent_test.go | 36 +++++++++++++++ go/adk/pkg/models/anthropic.go | 38 ++++++++++++---- go/adk/pkg/models/anthropic_vertex_test.go | 44 ++++++++++++++++++ go/core/pkg/env/providers.go | 10 +++++ go/go.mod | 2 +- 7 files changed, 160 insertions(+), 23 deletions(-) create mode 100644 go/adk/pkg/models/anthropic_vertex_test.go 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..1bc44da29c --- /dev/null +++ b/go/adk/pkg/models/anthropic_vertex_test.go @@ -0,0 +1,44 @@ +package models + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "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") +} 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 From b2494ef1332106f1a134c85e2567852f6638a669 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:25:05 +0200 Subject: [PATCH 2/7] feat(models): Vertex AI credentials at the egress gateway through a configurable credential authority A GeminiVertexAI or AnthropicVertexAI ModelConfig with apiKeySecret now binds authorization: Bearer on the Vertex AI origin of its location, so the Secret never enters the runtime: the ADK translation drops the Secret volume and GOOGLE_APPLICATION_CREDENTIALS and renders KAGENT_SKIP_VERTEX_AUTH, and the Claude compiler renders CLAUDE_CODE_SKIP_VERTEX_AUTH with the placeholder bearer in ANTHROPIC_CUSTOM_HEADERS instead of the key file. The ate-secret:// authority of a ModelConfig credential is the controller setting runtimeDefaults.modelCredentialAuthorities, keyed by ModelConfig provider, default k8s.io (Helm: config.runtimeDefaults). With k8s.io the Secret holds an access token; a credential provider class that mints access tokens can hold a service account key instead. The compiler does not inspect the Secret, since the class that fetches it owns its format. The egress binding check accepts any DNS name without a port as the authority and refuses IP addresses. MCP and EgressPolicy credentials keep k8s.io. Co-authored-by: Timo Derstappen Signed-off-by: QuentinBisson --- docs/architecture/credential-injection.md | 33 ++++- go/api/gen/kagent/config/v1/config.pb.go | 74 +++++++---- go/core/internal/egress/credentials.go | 22 +++- go/core/internal/egress/credentials_test.go | 10 ++ .../internal/translator/adkconfig/builder.go | 7 +- .../translator/adkconfig/builder_test.go | 43 ++++++ .../internal/translator/adkconfig/model.go | 85 +++--------- .../translator/adkconfig/model_test.go | 47 +++++++ .../internal/translator/adkconfig/runtime.go | 3 - go/core/internal/translator/byo/compiler.go | 2 +- .../internal/translator/claude/compiler.go | 67 ++-------- .../translator/claude/compiler_test.go | 73 +++++++++-- go/core/internal/translator/codex/compiler.go | 2 +- go/core/internal/translator/compiler_test.go | 124 +++++++++++++++++- go/core/internal/translator/credentials.go | 55 ++++++-- .../internal/translator/credentials_test.go | 99 ++++++++++++-- go/core/internal/translator/egress.go | 2 +- .../internal/translator/kagent/compiler.go | 2 +- go/core/internal/translator/vertex.go | 40 ++++++ go/core/pkg/config/helm_test.go | 4 + go/core/pkg/config/load_test.go | 5 + go/harness/claude/README.md | 12 +- go/harness/claude/config/config.go | 5 +- go/harness/claude/config/config_test.go | 2 + go/harness/claude/internal/adapter/adapter.go | 38 ------ .../claude/internal/adapter/adapter_test.go | 23 ---- helm/kagent/values.yaml | 7 + proto/kagent/config/v1/config.proto | 34 +++++ 28 files changed, 649 insertions(+), 271 deletions(-) create mode 100644 go/core/internal/translator/vertex.go diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index feb2cd4f99..9804c08b90 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 `; the Secret holds the token, or what the configured provider class mints it from | | 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,30 @@ 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. With `k8s.io` +the Secret holds a short-lived access token. A class that mints access tokens +lets the Secret hold a service account key instead; the provider owns the +Secret's format, so the compiler does not inspect it. The runtime never holds +the Secret: the Go ADK runs 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. The Python +ADK authenticates to Vertex AI itself, so a BYO image on it cannot use this +binding. + +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 +133,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/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index d7667f7463..bd1ca5e52c 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1712,8 +1712,13 @@ 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. + 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 +1800,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 +2133,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 +2145,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 +2216,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 +2239,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 +2299,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..e3d85a1481 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,47 @@ 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", + name: "Anthropic Vertex AI access token", 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(credentialValue)}, + 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://k8s.io/default/test/model-auth/credentials.json"}}, + }, + { + 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 +149,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 +160,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 +211,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..d83713b2b3 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,116 @@ 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 + // The provider class owns the Secret's format: an access token for + // k8s.io, a service account key for a class that mints one. + data string + }{ + {"default authority", "", "k8s.io", "ya29.access-token-must-not-be-serialized"}, + {"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)) + 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..8d045c194a 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,8 +70,8 @@ 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 } key := model.Spec.APIKeySecretKey @@ -78,7 +79,7 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, 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 +87,16 @@ 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) + passthroughEndpoints = append(passthroughEndpoints, modelCredentialTarget(resolved, settings).endpoint) } for _, endpoint := range passthroughEndpoints { u, err := url.Parse(strings.TrimSpace(endpoint)) @@ -133,8 +133,27 @@ 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 +} + +// 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 +172,15 @@ 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: the configured provider + // class serves the Secret's access token as it is, or one it mints from a + // service account key the Secret holds. The runtime holds no credential + // and no variable carries the Secret; the name only keys the binding. + name, header, prefix = env.GoogleApplicationCredentials.Name(), "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 +218,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..9044663ac7 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,110 @@ func TestCompileCredentialDestinations(t *testing.T) { } } +// A Vertex AI key is bound on the Google access-token provider class, 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", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-central1"}}}, "us-central1-aiplatform.googleapis.com"}, + {"Anthropic global", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"}}}, "aiplatform.googleapis.com"}, + {"Anthropic multi-region", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "eu"}}}, "aiplatform.eu.rep.googleapis.com"}, + } { + for _, authority := range []struct { + name string + settings *configv1.RuntimeDefaultsConfig + want string + }{ + {"default", nil, "k8s.io"}, + {"other provider overridden", &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}}, "k8s.io"}, + {"overridden", &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{string(test.spec.Provider): "tokens.example.com"}}, "tokens.example.com"}, + } { + t.Run(test.name+"/"+authority.name, func(t *testing.T) { + spec := test.spec + spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" + environment := []corev1.EnvVar{{Name: "GOOGLE_CLOUD_LOCATION", Value: "somewhere"}} + got, bindings, _, err := CompileCredentials(credentialInput(spec), nil, environment, authority.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://" + authority.want + "/default/team/auth/credentials.json", + }}, bindings) + }) + } + } +} + +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 +242,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 +264,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 +276,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..2719026fa7 100644 --- a/go/core/pkg/config/load_test.go +++ b/go/core/pkg/config/load_test.go @@ -51,6 +51,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) { 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..1422eb868b 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -200,6 +200,13 @@ 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. + # 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..8c02c4ab9f 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -347,6 +347,40 @@ 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. + 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. From 58ec73a1905cab8522060096947868e9ffb5c620 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:38:19 +0200 Subject: [PATCH 3/7] feat(adk): Python ADK calls Vertex AI through the gateway access token Under KAGENT_SKIP_VERTEX_AUTH the Python ADK's Gemini and Claude models on Vertex AI and the Vertex AI embedding client resolve no Application Default Credentials and send the placeholder bearer the egress gateway replaces: the genai client gets a credential holding the placeholder without an expiry, so it is never refreshed, and the Anthropic Vertex client a fixed access token. Co-authored-by: Timo Derstappen Signed-off-by: QuentinBisson --- docs/architecture/credential-injection.md | 11 +- .../src/kagent/adk/models/_embedding.py | 3 +- .../src/kagent/adk/models/_vertex.py | 46 ++++++++ .../kagent-adk/src/kagent/adk/types.py | 5 +- .../tests/unittests/models/test_vertex.py | 103 ++++++++++++++++++ 5 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 python/packages/kagent-adk/src/kagent/adk/models/_vertex.py create mode 100644 python/packages/kagent-adk/tests/unittests/models/test_vertex.py diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index 9804c08b90..db043393ba 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -93,12 +93,11 @@ A `GeminiVertexAI` or `AnthropicVertexAI` ModelConfig with `apiKeySecret` binds the Secret holds a short-lived access token. A class that mints access tokens lets the Secret hold a service account key instead; the provider owns the Secret's format, so the compiler does not inspect it. The runtime never holds -the Secret: the Go ADK runs 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. The Python -ADK authenticates to Vertex AI itself, so a BYO image on it cannot use this -binding. +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. 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..c0a5783685 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py @@ -0,0 +1,46 @@ +"""Vertex AI authentication through the Substrate egress gateway.""" + +from __future__ import annotations + +import os +from functools import cached_property +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)} + + +class KAgentClaudeVertexLlm(ClaudeLLM): + """Claude on Vertex AI that sends the gateway placeholder bearer under + KAGENT_SKIP_VERTEX_AUTH instead of resolving Google credentials.""" + + @cached_property + def _anthropic_client(self) -> AsyncAnthropicVertex: + client = ClaudeLLM._anthropic_client.func(self) + if skip_vertex_auth() and self.client is None: + client.access_token = VERTEX_GATEWAY_TOKEN + return 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..28f13f05fe 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 KAgentClaudeVertexLlm, 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 KAgentClaudeVertexLlm(model=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..4930e95324 --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py @@ -0,0 +1,103 @@ +"""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, + KAgentClaudeVertexLlm, + 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 = KAgentClaudeVertexLlm(model="claude-sonnet-4-5@20250929") + client = (await model._get_anthropic_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}" + + +@pytest.mark.asyncio +async def test_claude_vertex_keeps_google_auth_without_switch(vertex_env): + vertex_env.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) + model = KAgentClaudeVertexLlm(model="claude-sonnet-4-5@20250929") + client = await model._get_anthropic_client() + assert client.access_token is None From 3d52fd79f25f146a490e9ddab3706282ade31201 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:51:47 +0200 Subject: [PATCH 4/7] feat(models): require a minting credential class for Vertex AI k8s.io would set the stored service account key itself as the bearer token, so a Vertex AI apiKeySecret that resolves to it is a validation error naming runtimeDefaults.modelCredentialAuthorities.. Vertex AI ModelConfigs refuse tokenPassthrough, which neither ADK runtime honours on Vertex AI. The Vertex AI binding is keyed by a name no environment variable can carry. Signed-off-by: QuentinBisson --- docs/architecture/credential-injection.md | 13 +-- go/api/gen/kagent/config/v1/config.pb.go | 1 + .../translator/claude/compiler_test.go | 12 +-- go/core/internal/translator/compiler_test.go | 14 ++-- go/core/internal/translator/credentials.go | 18 +++-- .../internal/translator/credentials_test.go | 80 +++++++++++++------ helm/kagent/values.yaml | 1 + proto/kagent/config/v1/config.proto | 1 + 8 files changed, 93 insertions(+), 47 deletions(-) diff --git a/docs/architecture/credential-injection.md b/docs/architecture/credential-injection.md index db043393ba..3d1ba88afd 100644 --- a/docs/architecture/credential-injection.md +++ b/docs/architecture/credential-injection.md @@ -60,7 +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 `; the Secret holds the token, or what the configured provider class mints it from | +| 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 @@ -89,10 +89,13 @@ 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. With `k8s.io` -the Secret holds a short-lived access token. A class that mints access tokens -lets the Secret hold a service account key instead; the provider owns the -Secret's format, so the compiler does not inspect it. The runtime never holds +`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 diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index bd1ca5e52c..5d01693a1e 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1716,6 +1716,7 @@ type RuntimeDefaultsConfig struct { // 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 diff --git a/go/core/internal/translator/claude/compiler_test.go b/go/core/internal/translator/claude/compiler_test.go index e3d85a1481..90421d85a6 100644 --- a/go/core/internal/translator/claude/compiler_test.go +++ b/go/core/internal/translator/claude/compiler_test.go @@ -74,17 +74,13 @@ 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 access token", + // 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(credentialValue)}, - 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://k8s.io/default/test/model-auth/credentials.json"}}, + secretData: map[string][]byte{"credentials.json": []byte(`{"type":"service_account","private_key":"` + credentialValue + `"}`)}, + wantErr: "set runtimeDefaults.modelCredentialAuthorities.AnthropicVertexAI", }, { name: "Anthropic Vertex AI minting authority", diff --git a/go/core/internal/translator/compiler_test.go b/go/core/internal/translator/compiler_test.go index d83713b2b3..41aa2f8658 100644 --- a/go/core/internal/translator/compiler_test.go +++ b/go/core/internal/translator/compiler_test.go @@ -589,12 +589,12 @@ func TestCompileAgentBindsVertexAIAtGateway(t *testing.T) { } { for _, authority := range []struct { name, set, want string - // The provider class owns the Secret's format: an access token for - // k8s.io, a service account key for a class that mints one. - data string + data string + wantErr string }{ - {"default authority", "", "k8s.io", "ya29.access-token-must-not-be-serialized"}, - {"minting authority", "tokens.example.com", "tokens.example.com", `{"type":"service_account","private_key":"private-key-must-not-be-serialized"}`}, + // 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{ @@ -613,6 +613,10 @@ func TestCompileAgentBindsVertexAIAtGateway(t *testing.T) { 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 ", diff --git a/go/core/internal/translator/credentials.go b/go/core/internal/translator/credentials.go index 8d045c194a..fd00a04639 100644 --- a/go/core/internal/translator/credentials.go +++ b/go/core/internal/translator/credentials.go @@ -74,6 +74,9 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, 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() @@ -96,6 +99,9 @@ func CompileCredentials(input *HarnessInput, extraModels []*ResolvedModelConfig, if !resolved.Config.Spec.TokenPassthrough { continue } + 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 { @@ -142,6 +148,10 @@ func ModelCredentialAuthority(settings *configv1.RuntimeDefaultsConfig, provider 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 @@ -173,11 +183,9 @@ func modelCredentialTarget(resolved *ResolvedModelConfig, settings *configv1.Run 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: the configured provider - // class serves the Secret's access token as it is, or one it mints from a - // service account key the Secret holds. The runtime holds no credential - // and no variable carries the Secret; the name only keys the binding. - name, header, prefix = env.GoogleApplicationCredentials.Name(), "authorization", "Bearer " + // 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) } diff --git a/go/core/internal/translator/credentials_test.go b/go/core/internal/translator/credentials_test.go index 9044663ac7..0ecb5c3a03 100644 --- a/go/core/internal/translator/credentials_test.go +++ b/go/core/internal/translator/credentials_test.go @@ -50,44 +50,76 @@ func TestCompileCredentialDestinations(t *testing.T) { } } -// A Vertex AI key is bound on the Google access-token provider class, which -// mints the bearer token the gateway sets; no variable carries the key, so the -// environment compiles unchanged. +// 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", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderGeminiVertexAI, GeminiVertexAI: &v1alpha3.GeminiVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "us-central1"}}}, "us-central1-aiplatform.googleapis.com"}, - {"Anthropic global", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "global"}}}, "aiplatform.googleapis.com"}, - {"Anthropic multi-region", v1alpha3.ModelConfigSpec{Provider: v1alpha3.ModelProviderAnthropicVertexAI, AnthropicVertexAI: &v1alpha3.AnthropicVertexAIConfig{BaseVertexAIConfig: v1alpha3.BaseVertexAIConfig{ProjectID: "project", Location: "eu"}}}, "aiplatform.eu.rep.googleapis.com"}, + {"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"}, } { - for _, authority := range []struct { - name string - settings *configv1.RuntimeDefaultsConfig - want string - }{ - {"default", nil, "k8s.io"}, - {"other provider overridden", &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{"OpenAI": "tokens.example.com"}}, "k8s.io"}, - {"overridden", &configv1.RuntimeDefaultsConfig{ModelCredentialAuthorities: map[string]string{string(test.spec.Provider): "tokens.example.com"}}, "tokens.example.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(test.name+"/"+authority.name, func(t *testing.T) { - spec := test.spec + t.Run(string(provider), func(t *testing.T) { + spec := vertexSpec(provider, "us-central1") spec.APIKeySecret, spec.APIKeySecretKey = "auth", "credentials.json" - environment := []corev1.EnvVar{{Name: "GOOGLE_CLOUD_LOCATION", Value: "somewhere"}} - got, bindings, _, err := CompileCredentials(credentialInput(spec), nil, environment, authority.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://" + authority.want + "/default/team/auth/credentials.json", - }}, bindings) + 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{ diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 1422eb868b..3d0fdb22ff 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -204,6 +204,7 @@ config: # 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: {} diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 8c02c4ab9f..c47d7564d2 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -351,6 +351,7 @@ message RuntimeDefaultsConfig { // 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: { From e4174f0117dbe4a31c813acedc51231afeb148f2 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:52:28 +0200 Subject: [PATCH 5/7] test(adk): pin Application Default Credentials on Claude through Vertex AI The Vertex AI option wraps the configured HTTP client with its authorization. Passed after it, the custom client replaced the authorized one and requests left without an Authorization header; the test asserts both the ADC access token and the transport's headers reach Vertex AI. Signed-off-by: QuentinBisson --- go/adk/pkg/models/anthropic_vertex_test.go | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/go/adk/pkg/models/anthropic_vertex_test.go b/go/adk/pkg/models/anthropic_vertex_test.go index 1bc44da29c..9fa5421583 100644 --- a/go/adk/pkg/models/anthropic_vertex_test.go +++ b/go/adk/pkg/models/anthropic_vertex_test.go @@ -1,10 +1,16 @@ 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" @@ -42,3 +48,47 @@ func TestNewAnthropicVertexAIModelWithGatewayCredential(t *testing.T) { 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")) +} From 555c6cbfffaa324ca904d55c40a59e0b5beb774a Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:54:13 +0200 Subject: [PATCH 6/7] refactor(adk): hand Claude on Vertex AI its gateway client Under KAGENT_SKIP_VERTEX_AUTH the Python ADK builds the AsyncAnthropicVertex client with the placeholder access token and passes it through the public client field, instead of reaching into the parent's cached client property. Signed-off-by: QuentinBisson --- .../src/kagent/adk/models/_vertex.py | 28 ++++++++++++------- .../kagent-adk/src/kagent/adk/types.py | 4 +-- .../tests/unittests/models/test_vertex.py | 28 ++++++++++++------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py index c0a5783685..f2f7711582 100644 --- a/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py +++ b/python/packages/kagent-adk/src/kagent/adk/models/_vertex.py @@ -3,7 +3,7 @@ from __future__ import annotations import os -from functools import cached_property +import re from typing import Any, Optional from anthropic import AsyncAnthropicVertex @@ -34,13 +34,21 @@ def vertex_client_kwargs() -> Optional[dict[str, Any]]: return {"credentials": Credentials(token=VERTEX_GATEWAY_TOKEN)} -class KAgentClaudeVertexLlm(ClaudeLLM): - """Claude on Vertex AI that sends the gateway placeholder bearer under - KAGENT_SKIP_VERTEX_AUTH instead of resolving Google credentials.""" +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. - @cached_property - def _anthropic_client(self) -> AsyncAnthropicVertex: - client = ClaudeLLM._anthropic_client.func(self) - if skip_vertex_auth() and self.client is None: - client.access_token = VERTEX_GATEWAY_TOKEN - return client + 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 28f13f05fe..a22e98f4e7 100644 --- a/python/packages/kagent-adk/src/kagent/adk/types.py +++ b/python/packages/kagent-adk/src/kagent/adk/types.py @@ -100,7 +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 KAgentClaudeVertexLlm, vertex_client_kwargs +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__) @@ -586,7 +586,7 @@ def _create_llm_from_model_config(model_config: ModelConfig): client_kwargs=vertex_client_kwargs(), ) if provider == "gemini_anthropic": - return KAgentClaudeVertexLlm(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 index 4930e95324..8949b65604 100644 --- a/python/packages/kagent-adk/tests/unittests/models/test_vertex.py +++ b/python/packages/kagent-adk/tests/unittests/models/test_vertex.py @@ -8,7 +8,7 @@ from kagent.adk.models._vertex import ( VERTEX_GATEWAY_TOKEN, - KAgentClaudeVertexLlm, + claude_vertex_llm, skip_vertex_auth, vertex_client_kwargs, ) @@ -82,10 +82,8 @@ def handler(request: httpx2.Request) -> httpx2.Response: }, ) - model = KAgentClaudeVertexLlm(model="claude-sonnet-4-5@20250929") - client = (await model._get_anthropic_client()).with_options( - http_client=httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) - ) + 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"}] ) @@ -95,9 +93,19 @@ def handler(request: httpx2.Request) -> httpx2.Response: assert seen[0].headers["authorization"] == f"Bearer {VERTEX_GATEWAY_TOKEN}" -@pytest.mark.asyncio -async def test_claude_vertex_keeps_google_auth_without_switch(vertex_env): +def test_claude_vertex_keeps_google_auth_without_switch(vertex_env): vertex_env.delenv("KAGENT_SKIP_VERTEX_AUTH", raising=False) - model = KAgentClaudeVertexLlm(model="claude-sonnet-4-5@20250929") - client = await model._get_anthropic_client() - assert client.access_token is None + 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") From 93a7f19c57056680dabea7f81314851011be25bf Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sun, 18 Oct 2026 15:55:04 +0200 Subject: [PATCH 7/7] test(config): model credential authority keys follow the ModelConfig provider enum Every provider the published ModelConfig CRD accepts is a valid modelCredentialAuthorities key, so a provider added to the CRD without the controller setting fails the test. Signed-off-by: QuentinBisson --- go/core/pkg/config/load_test.go | 37 +++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/go/core/pkg/config/load_test.go b/go/core/pkg/config/load_test.go index 2719026fa7..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" @@ -75,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