Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 29 additions & 6 deletions docs/architecture/credential-injection.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ overrides of these variables are rejected.
| Foundry Anthropic API key | `x-api-key: <key>` |
| Gemini API key | `x-goog-api-key: <key>` |
| Bedrock bearer token | `authorization: Bearer <token>` |
| Vertex AI credential | `authorization: Bearer <access token>`, 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
Expand All @@ -77,9 +78,32 @@ Harness and SandboxTemplate environment entries accept only literal `value`
strings, including empty strings. Configure Secret-backed credentials on
ModelConfig, RemoteMCPServer, or [EgressPolicy](egress-policies.md) for gateway injection.

AWS IAM signing keys, Google service-account keys, and OAuth client credentials
require mechanisms beyond static header injection and are rejected rather than
serialized into runtimes.
Each ModelConfig provider resolves its `apiKeySecret` through a Substrate
credential provider class, the authority of the `ate-secret://` URI. The class
is `k8s.io` unless the controller setting
`runtimeDefaults.modelCredentialAuthorities` (Helm value
`config.runtimeDefaults.modelCredentialAuthorities`) names another one for that
provider; `k8s.io` injects the Secret value as stored. Another class needs a
Substrate egress gateway that routes it to a credential provider deployed
beside Substrate, or the gateway fails the request closed. RemoteMCPServer and
EgressPolicy credentials always use `k8s.io`.

A `GeminiVertexAI` or `AnthropicVertexAI` ModelConfig with `apiKeySecret` binds
`authorization: Bearer` on the Vertex AI origin for its location. Its provider
needs a class in `runtimeDefaults.modelCredentialAuthorities` that mints access
tokens: `k8s.io` would set the stored key itself as the bearer token, so the
compiler rejects a Vertex AI `apiKeySecret` that resolves to it. The provider
class owns the Secret's format, typically a service account key, so the
compiler does not inspect it. Vertex AI ModelConfigs do not support
`tokenPassthrough`. The runtime never holds
the Secret: the Go and Python ADKs run with `KAGENT_SKIP_VERTEX_AUTH=true` and
Claude Code with `CLAUDE_CODE_SKIP_VERTEX_AUTH=1`, and each sends the
placeholder `Authorization: Bearer kagent-credential-injected` for the gateway
to replace, since the gateway replaces a header a request carries and adds
none.

AWS IAM signing keys and OAuth client credentials require mechanisms beyond
static header injection and are rejected rather than serialized into runtimes.

Caller-token passthrough is set with `tokenPassthrough` on ModelConfig or
RemoteMCPServer. It forwards the bearer token from the incoming A2A request
Expand Down Expand Up @@ -111,6 +135,5 @@ TLS settings and warn when ignoring RemoteMCPServer TLS settings. Runtime trust
for gateway injection is configured by the platform.

`apiKeySecret` remains available for supported gateway credentials. Secret-backed
AWS IAM signing, Vertex service-account, and SAP OAuth credentials still fail
compilation; sharing this field with supported providers does not enable those
credential modes.
AWS IAM signing and SAP OAuth credentials still fail compilation; sharing this
field with supported providers does not enable those credential modes.
1 change: 1 addition & 0 deletions docs/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
52 changes: 38 additions & 14 deletions go/adk/pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 == "" {
Expand Down Expand Up @@ -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{
Expand Down
36 changes: 36 additions & 0 deletions go/adk/pkg/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"))
}
38 changes: 30 additions & 8 deletions go/adk/pkg/models/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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})}
}
94 changes: 94 additions & 0 deletions go/adk/pkg/models/anthropic_vertex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package models

import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"

"github.com/anthropics/anthropic-sdk-go"
"github.com/anthropics/anthropic-sdk-go/option"
"github.com/stretchr/testify/require"
)

// Under KAGENT_SKIP_VERTEX_AUTH the Vertex AI client is built without
// Application Default Credentials and sends the Vertex-shaped request with the
// placeholder bearer the gateway replaces.
func TestNewAnthropicVertexAIModelWithGatewayCredential(t *testing.T) {
t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "true")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "/nonexistent/credentials.json")
var got *http.Request
var body []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
body, _ = io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))
}))
t.Cleanup(server.Close)

model, err := NewAnthropicVertexAIModel(t.Context(), &AnthropicConfig{Model: "claude-sonnet-4-5@20250929"}, "us-east5", "project")
require.NoError(t, err)
_, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{
Model: "claude-sonnet-4-5@20250929", MaxTokens: 16,
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))},
}, option.WithBaseURL(server.URL))
require.NoError(t, err)
require.Equal(t, "/v1/projects/project/locations/us-east5/publishers/anthropic/models/claude-sonnet-4-5@20250929:rawPredict", got.URL.Path)
require.Equal(t, "Bearer "+VertexGatewayToken, got.Header.Get("Authorization"))
var sent map[string]any
require.NoError(t, json.Unmarshal(body, &sent))
require.Equal(t, "vertex-2023-10-16", sent["anthropic_version"])
require.NotContains(t, sent, "model")
}

// Without KAGENT_SKIP_VERTEX_AUTH the client authorizes requests with
// Application Default Credentials on top of the configured transport, so both
// the ADC access token and the transport's headers reach Vertex AI.
func TestNewAnthropicVertexAIModelWithApplicationDefaultCredentials(t *testing.T) {
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"adc-access-token","token_type":"Bearer","expires_in":3600}`))
}))
t.Cleanup(tokenServer.Close)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
credentials, err := json.Marshal(map[string]string{
"type": "service_account",
"project_id": "project",
"client_email": "agent@project.iam.gserviceaccount.com",
"private_key": string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})),
"token_uri": tokenServer.URL,
})
require.NoError(t, err)
path := filepath.Join(t.TempDir(), "credentials.json")
require.NoError(t, os.WriteFile(path, credentials, 0o600))
t.Setenv("KAGENT_SKIP_VERTEX_AUTH", "false")
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", path)

var got *http.Request
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"msg_1","type":"message","role":"assistant","model":"claude-sonnet-4-5@20250929","content":[{"type":"text","text":"hi"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`))
}))
t.Cleanup(server.Close)

config := &AnthropicConfig{Model: "claude-sonnet-4-5@20250929", TransportConfig: TransportConfig{Headers: map[string]string{"X-Team": "platform"}}}
model, err := NewAnthropicVertexAIModel(t.Context(), config, "us-east5", "project")
require.NoError(t, err)
_, err = model.Client.Messages.New(t.Context(), anthropic.MessageNewParams{
Model: "claude-sonnet-4-5@20250929", MaxTokens: 16,
Messages: []anthropic.MessageParam{anthropic.NewUserMessage(anthropic.NewTextBlock("hi"))},
}, option.WithBaseURL(server.URL))
require.NoError(t, err)
require.Equal(t, "Bearer adc-access-token", got.Header.Get("Authorization"))
require.Equal(t, "platform", got.Header.Get("X-Team"))
}
Loading