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
4 changes: 2 additions & 2 deletions docs/providers/google/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ Docker Agent reads the first credential it finds from these environment variable
| --------------------------- | ----------------------------------------------------------------------------------- |
| `GOOGLE_API_KEY` | Primary Gemini API key. |
| `GEMINI_API_KEY` | Alternative name for the Gemini API key (also used by the official Google SDK). |
| `GOOGLE_GENAI_USE_VERTEXAI` | When set (any value), routes through Vertex AI instead of the Gemini Developer API. |
| `GOOGLE_CLOUD_PROJECT` | GCP project used when `GOOGLE_GENAI_USE_VERTEXAI` is set or for Vertex AI Model Garden. |
| `GOOGLE_GENAI_USE_VERTEXAI` | When set to a truthy value (`true`, `1`, ...), routes through Vertex AI instead of the Gemini Developer API. `false`, `0` or empty leaves the Gemini Developer API path in use. |
| `GOOGLE_CLOUD_PROJECT` | GCP project used when `GOOGLE_GENAI_USE_VERTEXAI` is truthy or for Vertex AI Model Garden. |
| `GOOGLE_CLOUD_LOCATION` | GCP region for Vertex AI (defaults to the SDK default). |

On the Gemini Developer API, a model or [custom provider](../custom/index.md) that sets `token_key` reads its key from that variable instead of `GOOGLE_API_KEY` / `GEMINI_API_KEY`. The Vertex AI backends use Application Default Credentials and ignore `token_key`.
Expand Down
10 changes: 9 additions & 1 deletion pkg/config/auto.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,15 @@ func AvailableProviders(ctx context.Context, modelsGateway string, env environme

for _, p := range cloudProviders {
for _, envVar := range p.envVars {
if key, _ := env.Get(ctx, envVar); key != "" {
key, _ := env.Get(ctx, envVar)
if envVar == "GOOGLE_GENAI_USE_VERTEXAI" {
if isVertexAIEnabled(key) {
providers = append(providers, p.name)
break
}
continue
}
if key != "" {
providers = append(providers, p.name)
break // found one, no need to check other env vars for this provider
}
Expand Down
12 changes: 11 additions & 1 deletion pkg/config/gather.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"maps"
"os"
"slices"
"strconv"
"strings"

"github.com/docker/docker-agent/pkg/config/latest"
Expand Down Expand Up @@ -280,7 +281,7 @@ func addEnvVarsForCoreProvider(ctx context.Context, providerType string, model *
requiredEnv["ANTHROPIC_API_KEY"] = true
case "google":
if model.ProviderOpts["project"] == nil && model.ProviderOpts["location"] == nil {
if value, _ := env.Get(ctx, "GOOGLE_GENAI_USE_VERTEXAI"); value != "" {
if value, _ := env.Get(ctx, "GOOGLE_GENAI_USE_VERTEXAI"); isVertexAIEnabled(value) {
requiredEnv["GOOGLE_CLOUD_PROJECT"] = true
requiredEnv["GOOGLE_CLOUD_LOCATION"] = true
} else if value, _ := env.Get(ctx, "GEMINI_API_KEY"); value == "" {
Expand Down Expand Up @@ -331,6 +332,15 @@ func GatherEnvVarsForTools(ctx context.Context, cfg *latest.Config) ([]string, e
return sortedKeys(requiredEnv), nil
}

// isVertexAIEnabled interprets GOOGLE_GENAI_USE_VERTEXAI as a boolean,
// mirroring the provider routing in pkg/model/provider/gemini. Only an
// explicit truthy value (per strconv.ParseBool) enables the Vertex AI path,
// so "false", "0" or "" require the direct Gemini API credentials instead.
func isVertexAIEnabled(value string) bool {
enabled, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && enabled
}

func sortedKeys(requiredEnv map[string]bool) []string {
return slices.Sorted(maps.Keys(requiredEnv))
}
66 changes: 66 additions & 0 deletions pkg/config/gather_vertex_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package config

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)

func googleModelConfig() *latest.Config {
return &latest.Config{
Agents: []latest.AgentConfig{{Name: "a", Model: "m"}},
Models: map[string]latest.ModelConfig{
"m": {Provider: "google", Model: "gemini-3.5-flash"},
},
}
}

func TestGatherEnvVarsForModels_GoogleVertexFlagIsBoolean(t *testing.T) {
t.Parallel()

tests := []struct {
name string
present bool
value string
want []string
}{
{"unset uses API key", false, "", []string{"GOOGLE_API_KEY"}},
{"empty uses API key", true, "", []string{"GOOGLE_API_KEY"}},
{"false uses API key", true, "false", []string{"GOOGLE_API_KEY"}},
{"FALSE uses API key", true, "FALSE", []string{"GOOGLE_API_KEY"}},
{"zero uses API key", true, "0", []string{"GOOGLE_API_KEY"}},
{"true uses Vertex", true, "true", []string{"GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT"}},
{"one uses Vertex", true, "1", []string{"GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT"}},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
envVars := map[string]string{}
if tt.present {
envVars["GOOGLE_GENAI_USE_VERTEXAI"] = tt.value
}
got := GatherEnvVarsForModels(t.Context(), googleModelConfig(), environment.NewMapEnvProvider(envVars))
assert.Equal(t, tt.want, got)
})
}
}

func TestGatherEnvVarsForModels_GoogleCustomProviderWithFalseFlag(t *testing.T) {
t.Parallel()

cfg := &latest.Config{
Agents: []latest.AgentConfig{{Name: "a", Model: "m"}},
Models: map[string]latest.ModelConfig{
"m": {Provider: "google", Model: "gemini-3.5-flash", BaseURL: "https://example.invalid", TokenKey: "CUSTOM_GEMINI_KEY"},
},
}
env := environment.NewMapEnvProvider(map[string]string{
"GOOGLE_GENAI_USE_VERTEXAI": "false",
})

assert.Equal(t, []string{"CUSTOM_GEMINI_KEY"}, GatherEnvVarsForModels(t.Context(), cfg, env))
}
13 changes: 12 additions & 1 deletion pkg/model/provider/gemini/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"

"google.golang.org/genai"
Expand Down Expand Up @@ -67,7 +68,8 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
// an HTTP transport wrapper forces a fallback to BackendGeminiAPI.
// The Vertex AI backend relies on ADC-managed HTTP clients that bypass
// http.RoundTripper, so the wrapper cannot be applied there.
_, useVertexAIEnv := env.Get(ctx, "GOOGLE_GENAI_USE_VERTEXAI")
vertexFlag, _ := env.Get(ctx, "GOOGLE_GENAI_USE_VERTEXAI")
useVertexAIEnv := vertexAIEnabled(vertexFlag)
wantVertexAI := cfg.ProviderOpts["project"] != nil || cfg.ProviderOpts["location"] != nil || useVertexAIEnv
useVertexAI := wantVertexAI && globalOptions.TransportWrapper() == nil

Expand Down Expand Up @@ -971,6 +973,15 @@ func parseRerankScoresStrict(raw string, expected int) ([]float64, error) {
return rr.Scores, nil
}

// vertexAIEnabled interprets GOOGLE_GENAI_USE_VERTEXAI as a boolean. Only an
// explicit truthy value (per strconv.ParseBool: "1", "t", "true", etc.)
// enables the Vertex AI path, so "false", "0", "" or an unset variable all
// leave the direct Gemini API path in use.
func vertexAIEnabled(value string) bool {
enabled, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && enabled
}

func providerOption(cfg *latest.ModelConfig, name string) string {
v := cfg.ProviderOpts[name]
if v, ok := v.(string); ok {
Expand Down
140 changes: 140 additions & 0 deletions pkg/model/provider/gemini/vertex_flag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package gemini

import (
"net/http"
"net/http/httptest"
"sync"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/config/latest"
"github.com/docker/docker-agent/pkg/environment"
)

func TestVertexAIEnabled(t *testing.T) {
t.Parallel()

tests := []struct {
value string
want bool
}{
{"", false},
{"false", false},
{"FALSE", false},
{"False", false},
{"0", false},
{"f", false},
{"no", false},
{"off", false},
{"banana", false},
{"true", true},
{"True", true},
{"TRUE", true},
{"1", true},
{"t", true},
{"T", true},
{" true ", true},
}

for _, tt := range tests {
t.Run("value="+tt.value, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, vertexAIEnabled(tt.value))
})
}
}

// TestNewClient_VertexFlagFalseUsesDirectAPI is the regression for issue
// #4292: GOOGLE_GENAI_USE_VERTEXAI=false must not route a Google-typed custom
// provider with an explicit base_url and token_key to the Vertex/ADC path,
// where token_key is ignored. With the flag disabled the client must use the
// direct Gemini API path and send the token_key value as the API key.
func TestNewClient_VertexFlagFalseUsesDirectAPI(t *testing.T) {
t.Parallel()

for _, flag := range []string{"false", "FALSE", "0", ""} {
t.Run("flag="+flag, func(t *testing.T) {
t.Parallel()
var mu sync.Mutex
var seen []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
seen = append(seen, r.Header.Get("X-Goog-Api-Key"))
mu.Unlock()
writeGeminiSSEResponse(w)
}))
t.Cleanup(server.Close)

cfg := &latest.ModelConfig{
Provider: "google",
Model: "gemini-3.5-flash",
BaseURL: server.URL,
TokenKey: "CUSTOM_GEMINI_KEY",
}
env := environment.NewMapEnvProvider(map[string]string{
"GOOGLE_GENAI_USE_VERTEXAI": flag,
"CUSTOM_GEMINI_KEY": "custom-key",
})

client, err := NewClient(t.Context(), cfg, env)
require.NoError(t, err)

stream, err := client.CreateChatCompletionStream(t.Context(), []chat.Message{{Role: chat.MessageRoleUser, Content: "hello"}}, nil)
require.NoError(t, err)
defer stream.Close()
for {
if _, err := stream.Recv(); err != nil {
break
}
}

mu.Lock()
defer mu.Unlock()
require.Equal(t, []string{"custom-key"}, seen, "request must reach the custom base_url with the token_key API key")
})
}
}

// TestNewClient_VertexFlagRouting verifies the two sides of the flag: a
// truthy value keeps the Vertex/ADC path (no API key required), while a
// falsy value falls back to the direct path (missing token_key is reported).
func TestNewClient_VertexFlagRouting(t *testing.T) {
t.Parallel()

t.Run("truthy flag ignores missing token_key", func(t *testing.T) {
t.Parallel()
for _, flag := range []string{"true", "1"} {
cfg := &latest.ModelConfig{
Provider: "google",
Model: "gemini-3.5-flash",
BaseURL: "https://example.invalid",
TokenKey: "MISSING_KEY",
}
env := environment.NewMapEnvProvider(map[string]string{
"GOOGLE_GENAI_USE_VERTEXAI": flag,
})
_, err := NewClient(t.Context(), cfg, env)
assert.NoError(t, err, "flag %q must take the Vertex path, which ignores token_key", flag)
}
})

t.Run("falsy flag reports missing token_key", func(t *testing.T) {
t.Parallel()
for _, flag := range []string{"false", "0", ""} {
cfg := &latest.ModelConfig{
Provider: "google",
Model: "gemini-3.5-flash",
BaseURL: "https://example.invalid",
TokenKey: "MISSING_KEY",
}
env := environment.NewMapEnvProvider(map[string]string{
"GOOGLE_GENAI_USE_VERTEXAI": flag,
})
_, err := NewClient(t.Context(), cfg, env)
require.EqualError(t, err, "MISSING_KEY environment variable is required", "flag %q must take the direct path", flag)
}
})
}
Loading