From 306a0b8cb09e9e180d0af97bb0f8fe847661982d Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Wed, 28 Oct 2026 20:06:10 +0100 Subject: [PATCH] refactor: centralize providers in an embedded catalog Signed-off-by: Djordje Lukic --- .dockerignore | 1 + Taskfile.yml | 1 + cmd/root/eval.go | 2 +- cmd/root/models.go | 14 +- cmd/root/models_test.go | 12 +- cmd/root/setup.go | 4 +- cmd/wasm/cloud.go | 3 +- docs/features/evaluation/index.md | 43 +-- pkg/config/auto.go | 144 +------ pkg/config/auto_test.go | 53 +-- pkg/config/config_test.go | 10 + pkg/config/discovery.go | 46 ++- pkg/config/discovery_test.go | 20 + pkg/config/first_available.go | 4 +- pkg/config/gather.go | 38 +- pkg/config/runtime.go | 3 +- pkg/evaluation/container_env_test.go | 76 ++++ pkg/evaluation/eval.go | 35 +- pkg/model/provider/aliases.go | 275 -------------- pkg/model/provider/aliases_test.go | 167 -------- pkg/model/provider/bedrock/client.go | 10 +- pkg/model/provider/bedrock/client_test.go | 38 ++ pkg/model/provider/catalog/catalog.go | 152 ++++++++ pkg/model/provider/catalog/catalog_test.go | 91 +++++ pkg/model/provider/catalog/providers.json | 356 ++++++++++++++++++ pkg/model/provider/catalog/providers_test.go | 36 ++ pkg/model/provider/catalog_compat_test.go | 116 ++++++ pkg/model/provider/catalog_defaults_test.go | 37 ++ pkg/model/provider/catalog_test.go | 107 ++++++ pkg/model/provider/contracts/contracts.go | 3 +- pkg/model/provider/defaults.go | 26 +- pkg/model/provider/dmr/client.go | 3 +- pkg/model/provider/dmr/dmrmodels/resolve.go | 5 +- .../provider/dmr/dmrmodels/resolve_test.go | 18 + pkg/model/provider/factory.go | 7 + pkg/model/provider/gemini/client.go | 16 +- pkg/model/provider/gemini/token_key_test.go | 23 ++ .../openai/canonical_provider_test.go | 3 +- pkg/model/provider/openai/catalog_test.go | 93 +++++ pkg/model/provider/openai/client.go | 82 +--- pkg/model/provider/openai/headers.go | 27 +- pkg/model/provider/openai/headers_test.go | 5 + pkg/model/provider/openai/prompt_cache.go | 3 +- pkg/model/provider/openai/response_state.go | 3 +- .../openai/system_message_merge_test.go | 71 +--- .../provider/openai_alias_providers_test.go | 35 +- pkg/model/provider/provider_defaults_test.go | 24 +- pkg/model/provider/provider_test.go | 90 ----- pkg/model/provider/resolve_test.go | 3 +- pkg/modelinfo/modelinfo.go | 14 +- pkg/runtime/lazy_model_store.go | 4 +- pkg/runtime/modelcatalog/catalog.go | 6 +- pkg/runtime/modelcatalog/catalog_test.go | 6 +- 53 files changed, 1421 insertions(+), 1043 deletions(-) create mode 100644 pkg/evaluation/container_env_test.go delete mode 100644 pkg/model/provider/aliases.go delete mode 100644 pkg/model/provider/aliases_test.go create mode 100644 pkg/model/provider/catalog/catalog.go create mode 100644 pkg/model/provider/catalog/catalog_test.go create mode 100644 pkg/model/provider/catalog/providers.json create mode 100644 pkg/model/provider/catalog/providers_test.go create mode 100644 pkg/model/provider/catalog_compat_test.go create mode 100644 pkg/model/provider/catalog_defaults_test.go create mode 100644 pkg/model/provider/catalog_test.go create mode 100644 pkg/model/provider/openai/catalog_test.go delete mode 100644 pkg/model/provider/provider_test.go diff --git a/.dockerignore b/.dockerignore index 79f1be7e2..d6b37dd38 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,5 +11,6 @@ !/pkg/config/sources/builtin-agents/*.yaml !/pkg/safety/safety_patterns.json !/pkg/modelsdev/snapshot.json +!/pkg/model/provider/catalog/providers.json !/pkg/tools/builtin/mcpcatalog/servers.json !/pkg/tui/styles/themes/*.yaml \ No newline at end of file diff --git a/Taskfile.yml b/Taskfile.yml index f92da7a1b..53ac3703b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,6 +22,7 @@ tasks: aliases: [b] sources: - "**/*.go" + - pkg/model/provider/catalog/providers.json - "**/*.template" - "**/*.txt" - "**/*.yml" diff --git a/cmd/root/eval.go b/cmd/root/eval.go index 5ad665619..3c5929909 100644 --- a/cmd/root/eval.go +++ b/cmd/root/eval.go @@ -60,7 +60,7 @@ func newEvalCmd() *cobra.Command { "docker-agent image to inject into eval containers (default: pinned to this CLI's own release version, e.g. docker/docker-agent:1.2.3; falls back to docker/docker-agent:edge for dev builds); pass \"none\" to skip injection and trust the base image's own binary") cmd.Flags().StringVar(&flags.ContainerRuntime, "container-runtime", evaluation.DefaultContainerRuntime, "Container runtime executable for building and running evaluations") cmd.Flags().BoolVar(&flags.KeepContainers, "keep-containers", false, "Keep containers after evaluation (don't use --rm)") - cmd.Flags().StringSliceVarP(&flags.EnvVars, "env", "e", nil, "Environment variables to pass to container (KEY or KEY=VALUE)") + cmd.Flags().StringSliceVarP(&flags.EnvVars, "env", "e", nil, "Environment variables to pass to container (KEY or KEY=VALUE; none forwarded automatically)") cmd.Flags().IntVar(&flags.Repeat, "repeat", 1, "Number of times to repeat each evaluation (useful for computing baselines)") cmd.Flags().StringVar(&flags.baseline, "baseline", "", "Compare against a previously saved run JSON (/.json) and exit non-zero on regression") cmd.Flags().Float64Var(&flags.regressionTolerance, "regression-tolerance", 0, "How far an aggregate quality rate may fall before --baseline reports a regression (0-1)") diff --git a/cmd/root/models.go b/cmd/root/models.go index 596c56a05..6a329a069 100644 --- a/cmd/root/models.go +++ b/cmd/root/models.go @@ -20,7 +20,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/modelsgateway" "github.com/docker/docker-agent/pkg/telemetry" @@ -324,7 +324,7 @@ func (f *modelsListFlags) collectModels(ctx context.Context, env environment.Pro } for providerID, prov := range db.Providers { - if !provider.IsCatalogProvider(providerID) { + if !catalog.IsCatalogProvider(providerID) { continue } if !f.all && !availableProviders[providerID] { @@ -442,16 +442,18 @@ type openAIModelsResponse struct { // provider that requires the API key on /v1/models. // - Bounded by listTimeout (5s) so a slow endpoint cannot stall `models list`. func fetchProviderModels(ctx context.Context, providerName string, env environment.Provider) []string { - alias, ok := provider.LookupAlias(providerName) - if !ok || alias.BaseURL == "" { + alias, ok := catalog.Find(providerName) + if !ok || alias.BaseURL == "" || alias.APIType != "openai" { return nil } var token string // Send the alias's declared API key so providers that require it on // /v1/models authenticate the request instead of returning 401/403. - if alias.TokenEnvVar != "" { - token, _ = env.Get(ctx, alias.TokenEnvVar) + for _, key := range alias.TokenEnvVars { + if token, _ = env.Get(ctx, key); token != "" { + break + } } return fetchOpenAICompatibleModels(ctx, alias.BaseURL, token) diff --git a/cmd/root/models_test.go b/cmd/root/models_test.go index 6a18cc2b4..6cf6df078 100644 --- a/cmd/root/models_test.go +++ b/cmd/root/models_test.go @@ -19,7 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/config" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -814,11 +814,11 @@ func TestModelsListCommand_LegacyProviderFilter(t *testing.T) { t.Run(legacy, func(t *testing.T) { t.Parallel() canonical := modelsdev.CanonicalProviderID(legacy) - alias, ok := provider.LookupAlias(canonical) + alias, ok := catalog.Find(canonical) require.True(t, ok) var buf bytes.Buffer cmd := newModelsCmd(func(rc *config.RuntimeConfig) { - rc.EnvProviderForTests = environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVar: "test-key"}) + rc.EnvProviderForTests = environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVars[0]: "test-key"}) rc.Providers = map[string]latest.ProviderConfig{} rc.ModelsDevStoreOverride = modelsdev.NewDatabaseStore(&modelsdev.Database{Providers: map[string]modelsdev.Provider{ canonical: {Models: map[string]modelsdev.Model{"catalog-only": {Modalities: modelsdev.Modalities{Output: []string{"text"}}}}}, @@ -993,11 +993,11 @@ func TestModelsListCommand_LunarouteLiveDiscovery(t *testing.T) { })) defer server.Close() - alias := provider.Aliases["lunaroute"] - defer func() { provider.Aliases["lunaroute"] = alias }() + alias := catalog.Providers["lunaroute"] + defer func() { catalog.Providers["lunaroute"] = alias }() localAlias := alias localAlias.BaseURL = server.URL + "/v1" - provider.Aliases["lunaroute"] = localAlias + catalog.Providers["lunaroute"] = localAlias tests := []struct { name string diff --git a/cmd/root/setup.go b/cmd/root/setup.go index fa2eab19f..38f4519e4 100644 --- a/cmd/root/setup.go +++ b/cmd/root/setup.go @@ -27,7 +27,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/input" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/dmr" "github.com/docker/docker-agent/pkg/telemetry" "github.com/docker/docker-agent/pkg/userconfig" @@ -438,7 +438,7 @@ func (w *setupWizard) promptProviderName(ctx context.Context) (string, error) { fmt.Fprintln(w.out, "The name is empty; enter one or press Ctrl+C to cancel.") case strings.ContainsAny(name, "/ \t"): fmt.Fprintln(w.out, "The name cannot contain '/' or whitespace.") - case name == "auto" || provider.IsKnownProvider(name): + case name == "auto" || catalog.IsKnownProvider(name): fmt.Fprintf(w.out, "%q is a built-in provider name; pick another one.\n", name) default: return name, nil diff --git a/cmd/wasm/cloud.go b/cmd/wasm/cloud.go index 3aa9b89ac..d63bc52db 100644 --- a/cmd/wasm/cloud.go +++ b/cmd/wasm/cloud.go @@ -13,6 +13,7 @@ import ( "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider" "github.com/docker/docker-agent/pkg/model/provider/bedrock" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/dmr" "github.com/docker/docker-agent/pkg/model/provider/gemini" "github.com/docker/docker-agent/pkg/model/provider/options" @@ -60,7 +61,7 @@ func googleFactory(ctx context.Context, cfg *latest.ModelConfig, env environment // only: there is no host CLI, socket or MODEL_RUNNER_HOST to discover one. func cloudDMR(ctx context.Context, cfg *latest.ModelConfig, _ environment.Provider, opts ...options.Opt) (provider.Provider, error) { u, err := url.Parse(cfg.BaseURL) - if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" || catalog.IsDefaultEndpoint("dmr", cfg.BaseURL) { return nil, errors.New("dmr in the browser requires an explicit http(s) base_url") } return dmr.NewClient(ctx, cfg, opts...) diff --git a/docs/features/evaluation/index.md b/docs/features/evaluation/index.md index e664b7ad7..35bb22efa 100644 --- a/docs/features/evaluation/index.md +++ b/docs/features/evaluation/index.md @@ -388,31 +388,26 @@ passing: a gate that cannot fail is worse than no gate. ### Provider Credentials -Eval containers are isolated from your host environment. Dedicated model -provider API keys (for example `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`) are -forwarded into eval containers automatically, so most provider setups work -without extra flags. +Eval containers do not automatically receive host environment variables, including +provider API keys, gateway settings, Docker tokens, or credentials referenced by +evaluators and hooks. Pass each required variable explicitly with `-e`: -> [!WARNING] -> **`GITHUB_TOKEN` and `GH_TOKEN` are not forwarded automatically** -> -> GitHub tokens are broad credentials (git, `gh`, CI, packages), not dedicated -> model API keys, so for security reasons Docker Agent does not forward them -> into eval containers — even when they are set in your shell or in -> `~/.config/cagent/.env`. If your agent uses the `github-copilot` provider, -> pass the token explicitly by name: -> -> ```bash -> docker agent eval agent.yaml ./evals -e GITHUB_TOKEN -> ``` -> -> When using a custom env file, both flags are required: -> -> ```bash -> docker agent eval agent.yaml ./evals \ -> --env-from-file /path/to/secrets.env \ -> -e GITHUB_TOKEN -> ``` +```bash +docker agent eval agent.yaml ./evals -e OPENAI_API_KEY +``` + +`-e NAME` resolves the value from the configured environment sources; +`-e NAME=VALUE` supplies a literal value. Loading a custom env file does not +implicitly forward its contents: + +```bash +docker agent eval agent.yaml ./evals \ + --env-from-file /path/to/secrets.env \ + -e GITHUB_TOKEN +``` + +To use a models gateway inside the container, explicitly pass +`DOCKER_AGENT_MODELS_GATEWAY` and any authentication variables it requires. Note that the judge runs on the host, not inside the eval container. If the token is not forwarded, judge validation can succeed while every diff --git a/pkg/config/auto.go b/pkg/config/auto.go index e3a3bff83..6e86f7570 100644 --- a/pkg/config/auto.go +++ b/pkg/config/auto.go @@ -5,15 +5,12 @@ import ( "errors" "fmt" "log/slog" - "maps" "slices" - "strconv" "strings" - "github.com/docker/docker-agent/pkg/chatgpt" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // DMRModelLister returns the IDs of the models currently available to Docker @@ -28,14 +25,6 @@ type providerConfig struct { name string // provider name (e.g., "anthropic") envVars []string // env vars to check - provider is available if ANY is set hint string // description for error messages - // apiKeyEnvVar is the single secret API-key env var that authenticates this - // provider and is safe to forward into an isolated environment (e.g. an eval - // container). It is empty when the provider has no forwardable single secret - // (e.g. amazon-bedrock's multi-variable AWS credentials, or DMR which needs - // none). It intentionally differs from envVars, which also contains non-secret - // detection/mode flags (e.g. GOOGLE_GENAI_USE_VERTEXAI) that must never be - // forwarded as credentials. - apiKeyEnvVar string } // cloudProviders defines the available cloud providers in priority order. @@ -46,47 +35,19 @@ type providerConfig struct { // when the key is set, Zen wins auto-selection. A subscriber who only uses Go // should set the provider explicitly (e.g. `--model opencode-go/...`) rather than // relying on auto; see docs/providers/opencode-go for details. -var cloudProviders = []providerConfig{ - {"anthropic", []string{"ANTHROPIC_API_KEY"}, "ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"}, - {"openai", []string{"OPENAI_API_KEY"}, "OPENAI_API_KEY", "OPENAI_API_KEY"}, - // The chatgpt credential is the OAuth login stored by the `docker agent - // setup` sign-in, surfaced as a virtual env var by the "chatgpt-login" - // source. It is ordered after openai so adding a ChatGPT sign-in never - // changes auto-selection for users that already export OPENAI_API_KEY. - {"chatgpt", []string{chatgpt.TokenEnvVar}, "sign in with your ChatGPT account: `docker agent setup`", ""}, - {"github-copilot", []string{"GITHUB_TOKEN", "GH_TOKEN"}, "GITHUB_TOKEN (or GH_TOKEN)", ""}, - {"google", []string{ - "GOOGLE_API_KEY", - "GEMINI_API_KEY", - "GOOGLE_GENAI_USE_VERTEXAI", - }, "GOOGLE_API_KEY (or GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI)", "GOOGLE_API_KEY"}, - {"mistral", []string{"MISTRAL_API_KEY"}, "MISTRAL_API_KEY", "MISTRAL_API_KEY"}, - {"openrouter", []string{"OPENROUTER_API_KEY"}, "OPENROUTER_API_KEY", "OPENROUTER_API_KEY"}, - {"api-route", []string{"API_ROUTE_API_KEY"}, "API_ROUTE_API_KEY", "API_ROUTE_API_KEY"}, - {"lunaroute", []string{"LUNAROUTE_API_KEY"}, "LUNAROUTE_API_KEY", "LUNAROUTE_API_KEY"}, - {"orcarouter", []string{"ORCAROUTER_API_KEY"}, "ORCAROUTER_API_KEY", "ORCAROUTER_API_KEY"}, - {"atlascloud", []string{"ATLASCLOUD_API_KEY"}, "ATLASCLOUD_API_KEY", "ATLASCLOUD_API_KEY"}, - {"opper", []string{"OPPER_API_KEY"}, "OPPER_API_KEY", "OPPER_API_KEY"}, - {"runcrate", []string{"RUNCRATE_API_KEY"}, "RUNCRATE_API_KEY", "RUNCRATE_API_KEY"}, - {"baseten", []string{"BASETEN_API_KEY"}, "BASETEN_API_KEY", "BASETEN_API_KEY"}, - {"ovhcloud", []string{"OVH_AI_ENDPOINTS_ACCESS_TOKEN"}, "OVH_AI_ENDPOINTS_ACCESS_TOKEN", "OVH_AI_ENDPOINTS_ACCESS_TOKEN"}, - {"daoxe", []string{"DAOXE_API_KEY"}, "DAOXE_API_KEY", "DAOXE_API_KEY"}, - {"groq", []string{"GROQ_API_KEY"}, "GROQ_API_KEY", "GROQ_API_KEY"}, - {"fireworks-ai", []string{"FIREWORKS_API_KEY"}, "FIREWORKS_API_KEY", "FIREWORKS_API_KEY"}, - {"deepseek", []string{"DEEPSEEK_API_KEY"}, "DEEPSEEK_API_KEY", "DEEPSEEK_API_KEY"}, - {"cerebras", []string{"CEREBRAS_API_KEY"}, "CEREBRAS_API_KEY", "CEREBRAS_API_KEY"}, - {"togetherai", []string{"TOGETHER_API_KEY"}, "TOGETHER_API_KEY", "TOGETHER_API_KEY"}, - {"huggingface", []string{"HF_TOKEN"}, "HF_TOKEN", "HF_TOKEN"}, - {"moonshotai", []string{"MOONSHOT_API_KEY"}, "MOONSHOT_API_KEY", "MOONSHOT_API_KEY"}, - {"vercel", []string{"AI_GATEWAY_API_KEY"}, "AI_GATEWAY_API_KEY", "AI_GATEWAY_API_KEY"}, - {"amazon-bedrock", []string{ - "AWS_BEARER_TOKEN_BEDROCK", - "AWS_ACCESS_KEY_ID", - "AWS_PROFILE", - "AWS_ROLE_ARN", - }, "AWS_ACCESS_KEY_ID (or AWS_PROFILE, AWS_ROLE_ARN, AWS_BEARER_TOKEN_BEDROCK)", ""}, - {"opencode", []string{"OPENCODE_API_KEY"}, "OPENCODE_API_KEY", "OPENCODE_API_KEY"}, - {"opencode-go", []string{"OPENCODE_API_KEY"}, "OPENCODE_API_KEY", "OPENCODE_API_KEY"}, +var cloudProviders = loadCloudProviders() + +func loadCloudProviders() []providerConfig { + var providers []providerConfig + for _, p := range catalog.CredentialProviders() { + envVars := slices.Concat(p.TokenEnvVars, apiCredentialIndicators(p.APIType)) + providers = append(providers, providerConfig{ + name: p.Name, + envVars: envVars, + hint: p.Hint, + }) + } + return providers } // ProviderEnvVars associates a cloud provider known to auto-selection with @@ -156,75 +117,7 @@ func (e *AutoModelFallbackError) Error() string { // with errors.Is/errors.As. func (e *AutoModelFallbackError) Unwrap() error { return e.Cause } -var DefaultModels = map[string]string{ - "openai": "gpt-5.6", - "chatgpt": "gpt-5.6", - "github-copilot": "gpt-5.6-sol", - "anthropic": "claude-sonnet-5", - "google": "gemini-3.8-flash", - "dmr": "ai/qwen3:latest", - "mistral": "mistral-small-latest", - "openrouter": "meta-llama/llama-4-maverick", - "api-route": "gpt-6.1-sol", - "orcarouter": "orcarouter/auto", - "lunaroute": "glm-5.3", - "atlascloud": "qwen/qwen3.8-max", - "opper": "claude-sonnet-4-6", - "runcrate": "deepseek-ai/DeepSeek-V4.1-Flash", - "baseten": "deepseek-ai/DeepSeek-V4-Pro-0813", - "ovhcloud": "Qwen3.5-397B-A17B", - "daoxe": "claude-sonnet-4-6", - "groq": "llama-3.3-70b-versatile", - "fireworks-ai": "accounts/fireworks/models/kimi-k3", - "deepseek": "deepseek-v4-pro", - "cerebras": "gpt-oss-120b", - "togetherai": "meta-llama/Llama-3.3-70B-Instruct-Turbo", - "huggingface": "meta-llama/Llama-3.3-70B-Instruct", - "moonshotai": "kimi-k3", - "vercel": "openai/gpt-5.6-sol", - "amazon-bedrock": "global.anthropic.claude-sonnet-5", - "opencode-go": "deepseek-v4.1-flash", - "opencode": "deepseek-v4-flash-free", -} - -// nonForwardableTokenEnvVars lists provider token env vars that are NOT safe to -// forward as model credentials into an isolated environment (e.g. an eval -// container), even though a provider alias uses them for auth. GITHUB_TOKEN is -// a broad, general-purpose GitHub credential (git, gh, CI, packages) that the -// github-copilot alias happens to reuse; forwarding it would leak far more -// access than a dedicated model API key. CHATGPT_OAUTH_TOKEN is a short-lived -// OAuth access token bound to a ChatGPT subscription, not an API key. -var nonForwardableTokenEnvVars = map[string]bool{ - "GITHUB_TOKEN": true, - chatgpt.TokenEnvVar: true, -} - -// ProviderAPIKeyEnvVars returns the deduplicated, sorted set of environment -// variables that hold a dedicated model-provider API key. Callers that need to -// forward provider credentials (e.g. into a container) should use this instead -// of hard-coding a list of API key names, so it stays in sync as providers are -// added. -// -// It only includes single-secret API keys: it deliberately excludes non-secret -// detection/mode flags (e.g. GOOGLE_GENAI_USE_VERTEXAI), multi-variable -// credential sets that cannot be forwarded as one secret (e.g. AWS/Bedrock), -// and broad general-purpose tokens (see nonForwardableTokenEnvVars). Providers -// needing those must be given credentials explicitly. -func ProviderAPIKeyEnvVars() []string { - seen := map[string]bool{} - add := func(name string) { - if name != "" && !nonForwardableTokenEnvVars[name] { - seen[name] = true - } - } - for _, p := range cloudProviders { - add(p.apiKeyEnvVar) - } - for _, alias := range provider.EachAlias() { - add(alias.TokenEnvVar) - } - return slices.Sorted(maps.Keys(seen)) -} +var DefaultModels = catalog.DefaultModels() func AvailableProviders(ctx context.Context, modelsGateway string, env environment.Provider) []string { if modelsGateway != "" { @@ -236,12 +129,7 @@ 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 != "" { - if envVar == "GOOGLE_GENAI_USE_VERTEXAI" { - if enabled, _ := strconv.ParseBool(key); !enabled { - continue - } - } + if key, _ := env.Get(ctx, envVar); credentialConfigured(envVar, key) { providers = append(providers, p.name) break // found one, no need to check other env vars for this provider } diff --git a/pkg/config/auto_test.go b/pkg/config/auto_test.go index f79cdc86a..2b6a95657 100644 --- a/pkg/config/auto_test.go +++ b/pkg/config/auto_test.go @@ -10,10 +10,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/docker/docker-agent/pkg/chatgpt" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -1223,52 +1222,6 @@ func (e *stubPullError) Error() string { return e.fullDetail } func (e *stubPullError) ModelPullErrorSummary() string { return e.summary } -func TestProviderAPIKeyEnvVars(t *testing.T) { - t.Parallel() - - vars := ProviderAPIKeyEnvVars() - - // Sorted and deduplicated for reproducibility. - assert.True(t, slices.IsSorted(vars), "env vars must be sorted, got %v", vars) - assert.Equal(t, slices.Compact(slices.Clone(vars)), vars, "env vars must be deduplicated") - - // The dedicated single-secret model API keys must be present. - for _, name := range []string{ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "GOOGLE_API_KEY", - "MISTRAL_API_KEY", - "OPENROUTER_API_KEY", - "ATLASCLOUD_API_KEY", - "LUNAROUTE_API_KEY", - "XAI_API_KEY", - "NEBIUS_API_KEY", - } { - assert.Contains(t, vars, name) - } - - // Non-secret detection/mode flags and multi-variable credential sets must - // never be exposed as forwardable API keys. - for _, name := range []string{ - "GOOGLE_GENAI_USE_VERTEXAI", - "GEMINI_API_KEY", - "AWS_ACCESS_KEY_ID", - "AWS_PROFILE", - "AWS_ROLE_ARN", - "AWS_BEARER_TOKEN_BEDROCK", - } { - assert.NotContains(t, vars, name) - } - - // Broad, general-purpose tokens must not be forwarded as model credentials. - assert.NotContains(t, vars, "GITHUB_TOKEN") - assert.NotContains(t, vars, "GH_TOKEN") - - // The ChatGPT OAuth access token is a subscription credential, not an - // API key, and must never be forwarded into isolated environments. - assert.NotContains(t, vars, chatgpt.TokenEnvVar) -} - func TestCloudProviderEnvVars(t *testing.T) { t.Parallel() @@ -1300,7 +1253,7 @@ func TestDefaultModelsExistInModelsDev(t *testing.T) { for providerID, model := range DefaultModels { t.Run(providerID, func(t *testing.T) { t.Parallel() - require.True(t, provider.IsKnownProvider(providerID)) + require.True(t, catalog.IsKnownProvider(providerID)) require.Equal(t, providerID, modelsdev.CanonicalProviderID(providerID)) if modelsDevAbsentProviders[providerID] { return // Providers with their own catalogs cannot be validated against models.dev. @@ -1319,7 +1272,7 @@ func TestCloudProvidersCanonical(t *testing.T) { t.Parallel() for _, cfg := range cloudProviders { assert.Equal(t, cfg.name, modelsdev.CanonicalProviderID(cfg.name)) - assert.True(t, provider.IsKnownProvider(cfg.name), cfg.name) + assert.True(t, catalog.IsKnownProvider(cfg.name), cfg.name) assert.Contains(t, DefaultModels, cfg.name) } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 1b8612087..328617a8f 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -211,6 +211,16 @@ func (p *fakeEnvProvider) Get(_ context.Context, name string) (string, bool) { return v, ok } +func TestCheckRequiredEnvVarsCredentialFreeCatalogProvider(t *testing.T) { + t.Parallel() + + cfg := &latest.Config{Models: map[string]latest.ModelConfig{ + "local": {Provider: "ollama", Model: "llama3"}, + }, Agents: latest.Agents{{Name: "root", Model: "local"}}} + assert.Empty(t, GatherEnvVarsForModels(t.Context(), cfg, environment.NewNoEnvProvider())) + require.NoError(t, CheckRequiredEnvVars(t.Context(), cfg, "", environment.NewNoEnvProvider())) +} + func TestCheckRequiredEnvVars(t *testing.T) { t.Parallel() diff --git a/pkg/config/discovery.go b/pkg/config/discovery.go index 0bdf3465c..c4c3cbeb5 100644 --- a/pkg/config/discovery.go +++ b/pkg/config/discovery.go @@ -2,11 +2,13 @@ package config import ( "context" + "slices" + "strconv" "sync" "github.com/docker/docker-agent/pkg/concurrent" "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // bedrockCredentialIndicators are quick heuristics for AWS credentials, which @@ -28,6 +30,26 @@ var bedrockCredentialIndicators = []string{ "AWS_ROLE_ARN", } +// apiCredentialIndicators supplements token detection for APIs with native auth. +func apiCredentialIndicators(apiType string) []string { + switch apiType { + case "google": + return []string{"GOOGLE_GENAI_USE_VERTEXAI"} + case "amazon-bedrock": + return bedrockCredentialIndicators + default: + return nil + } +} + +func credentialConfigured(name, value string) bool { + if name == "GOOGLE_GENAI_USE_VERTEXAI" { + enabled, _ := strconv.ParseBool(value) + return enabled + } + return value != "" +} + // DiscoveryAvailableProviders reports which providers are directly usable // from local credentials, for model discovery/listing purposes only. Unlike // [AvailableProviders] — which drives auto model selection and applies a @@ -48,11 +70,9 @@ func DiscoveryAvailableProviders(ctx context.Context, env environment.Provider) for _, p := range cloudProviders { names = append(names, p.envVars...) } - names = append(names, bedrockCredentialIndicators...) - for _, alias := range provider.EachAlias() { - if alias.TokenEnvVar != "" { - names = append(names, alias.TokenEnvVar) - } + for _, alias := range catalog.Providers { + names = append(names, alias.TokenEnvVars...) + names = append(names, apiCredentialIndicators(alias.APIType)...) // A templated base URL (e.g. Cloudflare's account/gateway-scoped // endpoint) also needs the vars it references. names = append(names, environment.Refs(alias.BaseURL)...) @@ -63,15 +83,16 @@ func DiscoveryAvailableProviders(ctx context.Context, env environment.Provider) available := make(map[string]bool) for _, p := range cloudProviders { for _, envVar := range p.envVars { - if values[envVar] != "" { + if credentialConfigured(envVar, values[envVar]) { available[p.name] = true break } } } - for name, alias := range provider.EachAlias() { - if alias.TokenEnvVar == "" || values[alias.TokenEnvVar] == "" { + for name, alias := range catalog.Providers { + keys := slices.Concat(alias.TokenEnvVars, apiCredentialIndicators(alias.APIType)) + if !slices.ContainsFunc(keys, func(key string) bool { return credentialConfigured(key, values[key]) }) { continue } // Without the vars its base URL references the alias resolves to a @@ -83,13 +104,6 @@ func DiscoveryAvailableProviders(ctx context.Context, env environment.Provider) available[name] = true } - for _, indicator := range bedrockCredentialIndicators { - if values[indicator] != "" { - available["amazon-bedrock"] = true - break - } - } - return available } diff --git a/pkg/config/discovery_test.go b/pkg/config/discovery_test.go index bf2f6f188..63413737c 100644 --- a/pkg/config/discovery_test.go +++ b/pkg/config/discovery_test.go @@ -33,6 +33,26 @@ func TestDiscoveryAvailableProviders(t *testing.T) { envVars: map[string]string{"GEMINI_API_KEY": "gm-test"}, wantPresent: []string{"google"}, }, + { + name: "vertex mode enabled", + envVars: map[string]string{"GOOGLE_GENAI_USE_VERTEXAI": "true"}, + wantPresent: []string{"google"}, + }, + { + name: "vertex mode disabled", + envVars: map[string]string{"GOOGLE_GENAI_USE_VERTEXAI": "false"}, + wantAbsent: []string{"google"}, + }, + { + name: "copilot alternative token", + envVars: map[string]string{"GH_TOKEN": "test-token"}, + wantPresent: []string{"github-copilot"}, + }, + { + name: "bedrock profile", + envVars: map[string]string{"AWS_PROFILE": "development"}, + wantPresent: []string{"amazon-bedrock"}, + }, { name: "alias token surfaces the alias", envVars: map[string]string{"XAI_API_KEY": "xai-test"}, diff --git a/pkg/config/first_available.go b/pkg/config/first_available.go index 5569b7ce4..1ae563a0e 100644 --- a/pkg/config/first_available.go +++ b/pkg/config/first_available.go @@ -9,7 +9,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" - modelprovider "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // ResolveFirstAvailableModels resolves reachable `first_available` models in the @@ -228,7 +228,7 @@ func resolveCandidate(cfg *latest.Config, ref string, selectors map[string]bool) } func validateCandidateProvider(cfg *latest.Config, ref, provider string) error { - if provider == "" || modelprovider.IsKnownProvider(provider) { + if provider == "" || catalog.IsKnownProvider(provider) { return nil } if _, exists := cfg.Providers[provider]; exists { diff --git a/pkg/config/gather.go b/pkg/config/gather.go index 79704c772..5f996160f 100644 --- a/pkg/config/gather.go +++ b/pkg/config/gather.go @@ -13,7 +13,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/gateway" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" ) @@ -244,19 +244,25 @@ func addEnvVarsForModelConfig(ctx context.Context, model *latest.ModelConfig, cu if effective == "" { effective = "openai" } - addEnvVarsForCoreProvider(ctx, effective, model, requiredEnv, env) + addEnvVarsForAPI(ctx, effective, model, requiredEnv, env) } return } } - if alias, exists := provider.LookupAlias(model.Provider); exists { + if alias, exists := catalog.Find(model.Provider); exists { // Check built-in aliases - if alias.TokenEnvVar != "" { - if model.Provider == "github-copilot" { - requiredEnv[githubCopilotTokenEnvVar(ctx, env)] = true - } else { - requiredEnv[alias.TokenEnvVar] = true + switch { + case alias.APIType == "google" || alias.APIType == "amazon-bedrock": + addEnvVarsForAPI(ctx, alias.APIType, model, requiredEnv, env) + case len(alias.TokenEnvVars) > 0: + key := alias.TokenEnvVars[0] + for _, candidate := range catalog.TokenKeys(model.Provider, key) { + if value, _ := env.Get(ctx, candidate); value != "" { + key = candidate + break + } } + requiredEnv[key] = true } // A templated alias base URL (e.g. Cloudflare's account/gateway-scoped // endpoint) references env vars that must resolve when the provider is @@ -265,22 +271,12 @@ func addEnvVarsForModelConfig(ctx context.Context, model *latest.ModelConfig, cu requiredEnv[name] = true } } else { - addEnvVarsForCoreProvider(ctx, model.Provider, model, requiredEnv, env) + addEnvVarsForAPI(ctx, model.Provider, model, requiredEnv, env) } } -func githubCopilotTokenEnvVar(ctx context.Context, env environment.Provider) string { - if value, _ := env.Get(ctx, "GITHUB_TOKEN"); value != "" { - return "GITHUB_TOKEN" - } - if value, _ := env.Get(ctx, "GH_TOKEN"); value != "" { - return "GH_TOKEN" - } - return "GITHUB_TOKEN" -} - -// addEnvVarsForCoreProvider adds the required env vars for a core provider type. -func addEnvVarsForCoreProvider(ctx context.Context, providerType string, model *latest.ModelConfig, requiredEnv map[string]bool, env environment.Provider) { +// addEnvVarsForAPI adds credentials required by the implemented API's auth scheme. +func addEnvVarsForAPI(ctx context.Context, providerType string, model *latest.ModelConfig, requiredEnv map[string]bool, env environment.Provider) { switch providerType { case "openai": requiredEnv["OPENAI_API_KEY"] = true diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index bb86a2133..0927e8619 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -10,6 +10,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -146,7 +147,7 @@ func (runConfig *RuntimeConfig) ModelsDevStore() (*modelsdev.Store, error) { } runConfig.modelsDevStoreOnce.Do(func() { runConfig.modelsDevStore, runConfig.modelsDevStoreErr = modelsdev.NewStore( - modelsdev.WithKnownProvider(provider.IsKnownProvider), + modelsdev.WithKnownProvider(catalog.IsKnownProvider), ) }) return runConfig.modelsDevStore, runConfig.modelsDevStoreErr diff --git a/pkg/evaluation/container_env_test.go b/pkg/evaluation/container_env_test.go new file mode 100644 index 000000000..a5e53bfb3 --- /dev/null +++ b/pkg/evaluation/container_env_test.go @@ -0,0 +1,76 @@ +package evaluation + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config" + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" +) + +func TestContainerEnvironmentIsExplicit(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("the fake container runtime is a POSIX shell script") + } + t.Parallel() + + const script = `#!/bin/sh +printf '%s\n' "$@" > "${0%/*}/args" +printf '%s' "${OPENAI_API_KEY-}" > "${0%/*}/token" +printf '%s' "${LITERAL-}" > "${0%/*}/literal" +echo '{"type":"agent_choice","content":"hello"}' +` + for _, gateway := range []string{"", "https://gateway.example"} { + for _, explicit := range []bool{false, true} { + t.Run(fmt.Sprintf("gateway=%s/explicit=%t", gateway, explicit), func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + fakeRuntime := filepath.Join(dir, "runtime") + writeMockCLI(t, fakeRuntime, script) + runConfig := &config.RuntimeConfig{ + Config: config.Config{ModelsGateway: gateway}, + EnvProviderOverride: environment.NewMapEnvProvider(map[string]string{ + "OPENAI_API_KEY": "model-secret", + "DOCKER_AGENT_MODELS_GATEWAY": "https://gateway.example", + environment.DockerDesktopTokenEnv: "docker-secret", + "JUDGE_KEY": "judge-secret", + }), + } + cfg := Config{ContainerRuntime: fakeRuntime} + if explicit { + cfg.EnvVars = []string{"OPENAI_API_KEY", "LITERAL=explicit-value", "DOCKER_AGENT_MODELS_GATEWAY", environment.DockerDesktopTokenEnv, "JUDGE_KEY"} + } + runner := newRunner(config.NewFileSource(filepath.Join(dir, "agent.yaml")), runConfig, cfg) + runner.agentConfig = &latest.Config{Evaluators: map[string]latest.EvaluatorConfig{ + "check": {Provider: "openai", Model: "gpt-4o", TokenKey: "JUDGE_KEY"}, + }} + _, err := runner.runDockerAgentInContainer(t.Context(), "image", []string{"hello"}, "") + require.NoError(t, err) + args, err := os.ReadFile(filepath.Join(dir, "args")) + require.NoError(t, err) + token, err := os.ReadFile(filepath.Join(dir, "token")) + require.NoError(t, err) + literal, err := os.ReadFile(filepath.Join(dir, "literal")) + require.NoError(t, err) + if explicit { + for _, name := range []string{"OPENAI_API_KEY", "LITERAL", "DOCKER_AGENT_MODELS_GATEWAY", environment.DockerDesktopTokenEnv, "JUDGE_KEY"} { + assert.Contains(t, strings.Split(string(args), "\n"), name) + } + assert.Equal(t, "model-secret", string(token)) + assert.Equal(t, "explicit-value", string(literal)) + } else { + assert.NotContains(t, strings.Split(string(args), "\n"), "-e") + assert.Empty(t, literal) + } + }) + } + } +} diff --git a/pkg/evaluation/eval.go b/pkg/evaluation/eval.go index fdf2bf5c5..acb5eb661 100644 --- a/pkg/evaluation/eval.go +++ b/pkg/evaluation/eval.go @@ -12,7 +12,6 @@ import ( "fmt" "io" "log/slog" - "maps" "os" "os/exec" "path/filepath" @@ -27,8 +26,6 @@ import ( "github.com/docker/docker-agent/pkg/config" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/config/sources" - "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/session" ) @@ -455,35 +452,7 @@ func (r *Runner) runDockerAgentInContainer(ctx context.Context, imageID string, env = append(env, name+"="+value) } - if r.runConfig.ModelsGateway != "" { - addEnv("DOCKER_AGENT_MODELS_GATEWAY", r.runConfig.ModelsGateway) - - if token, ok := r.runConfig.EnvProvider().Get(ctx, environment.DockerDesktopTokenEnv); ok && token != "" { - addEnv(environment.DockerDesktopTokenEnv, token) - } - } else { - for _, name := range config.ProviderAPIKeyEnvVars() { - if val, ok := r.runConfig.EnvProvider().Get(ctx, name); ok && val != "" { - addEnv(name, val) - } - } - } - - if r.agentConfig != nil { - cfg := *r.agentConfig - cfg.Agents = slices.Clone(cfg.Agents) - cfg.Providers = maps.Clone(cfg.Providers) - config.MergeGlobalProviders(&cfg, r.runConfig.Providers) - config.MergeAgentHooks(&cfg, config.MergeHooks(r.runConfig.GlobalHooks, r.runConfig.CLIHooks())) - evaluatorOpts := append([]options.Opt{options.WithGateway(r.runConfig.ModelsGateway)}, r.runConfig.EvaluatorOptions...) - for _, name := range config.RequiredEvaluatorEnvVars(&cfg, evaluatorOpts...) { - if val, ok := r.runConfig.EnvProvider().Get(ctx, name); ok && val != "" { - addEnv(name, val) - } - } - } - - // Pass additional environment variables specified via -e flag + // Only forward environment variables explicitly requested via -e. // Format: KEY or KEY=VALUE for _, entry := range r.EnvVars { if key, val, hasValue := strings.Cut(entry, "="); hasValue && key != "" { @@ -528,7 +497,7 @@ func (r *Runner) runDockerAgentInContainer(ctx context.Context, imageID string, containerRuntime := r.containerRuntimeOrDefault() cmd := exec.CommandContext(ctx, containerRuntime, args...) - cmd.Env = append(env, os.Environ()...) + cmd.Env = append(os.Environ(), env...) // On cancellation send SIGINT instead of the default SIGKILL: the // Docker-compatible CLI proxies SIGINT to the container (SIGKILL is never // proxied and would leave the container running daemon-side). The diff --git a/pkg/model/provider/aliases.go b/pkg/model/provider/aliases.go deleted file mode 100644 index 4d7dfb530..000000000 --- a/pkg/model/provider/aliases.go +++ /dev/null @@ -1,275 +0,0 @@ -package provider - -import ( - "iter" - "maps" - "slices" - "strings" - - "github.com/docker/docker-agent/pkg/chatgpt" - "github.com/docker/docker-agent/pkg/modelsdev" -) - -// Alias defines the configuration for a provider alias. -type Alias struct { - APIType string // The actual API type to use (openai, anthropic, etc.) - // BaseURL is the default base URL for the provider. It may contain - // ${VAR}/${env.VAR} references (e.g. ${CLOUDFLARE_ACCOUNT_ID}) that are - // resolved from the runtime environment when the provider is built. - BaseURL string - TokenEnvVar string // Environment variable name for the API token -} - -// CoreProviders lists all natively implemented provider types. -// These are the provider types that have direct implementations (not aliases). -var CoreProviders = []string{ - "openai", - "anthropic", - "google", - "dmr", - "amazon-bedrock", -} - -// Aliases maps provider names to their corresponding configurations. -// -// Most consumers should call [LookupAlias] for a single lookup or [EachAlias] -// to iterate, both of which keep the rest of the codebase decoupled from this -// concrete map. Direct mutation of Aliases is not supported. -var Aliases = map[string]Alias{ - "requesty": { - APIType: "openai", - BaseURL: "https://router.requesty.ai/v1", - TokenEnvVar: "REQUESTY_API_KEY", - }, - "azure": { - APIType: "openai", - TokenEnvVar: "AZURE_API_KEY", - }, - "xai": { - APIType: "openai", - BaseURL: "https://api.x.ai/v1", - TokenEnvVar: "XAI_API_KEY", - }, - "nebius": { - APIType: "openai", - BaseURL: "https://api.studio.nebius.com/v1", - TokenEnvVar: "NEBIUS_API_KEY", - }, - "nvidia": { - APIType: "openai", - BaseURL: "https://integrate.api.nvidia.com/v1", - TokenEnvVar: "NVIDIA_API_KEY", - }, - "openrouter": { - APIType: "openai", - BaseURL: "https://openrouter.ai/api/v1", - TokenEnvVar: "OPENROUTER_API_KEY", - }, - "api-route": { - APIType: "openai", - BaseURL: "https://global.api-route.com/v1", - TokenEnvVar: "API_ROUTE_API_KEY", - }, - "runcrate": { - APIType: "openai", - BaseURL: "https://api.runcrate.ai/v1", - TokenEnvVar: "RUNCRATE_API_KEY", - }, - "lunaroute": { - APIType: "openai", - BaseURL: "https://gw.lunaroute.com/v1", - TokenEnvVar: "LUNAROUTE_API_KEY", - }, - "orcarouter": { - APIType: "openai", - BaseURL: "https://api.orcarouter.ai/v1", - TokenEnvVar: "ORCAROUTER_API_KEY", - }, - "atlascloud": { - APIType: "openai", - BaseURL: "https://api.atlascloud.ai/v1", - TokenEnvVar: "ATLASCLOUD_API_KEY", - }, - "opper": { - APIType: "openai", - BaseURL: "https://api.opper.ai/v3/compat", - TokenEnvVar: "OPPER_API_KEY", - }, - "mistral": { - APIType: "openai", - BaseURL: "https://api.mistral.ai/v1", - TokenEnvVar: "MISTRAL_API_KEY", - }, - "ollama": { - APIType: "openai", - BaseURL: "http://localhost:11434/v1", - }, - "minimax": { - APIType: "openai", - BaseURL: "https://api.minimax.io/v1", - TokenEnvVar: "MINIMAX_API_KEY", - }, - "baseten": { - APIType: "openai", - BaseURL: "https://inference.baseten.co/v1", - TokenEnvVar: "BASETEN_API_KEY", - }, - "ovhcloud": { - APIType: "openai", - BaseURL: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", - TokenEnvVar: "OVH_AI_ENDPOINTS_ACCESS_TOKEN", - }, - "daoxe": { - APIType: "openai", - BaseURL: "https://api.daoxe.com/v1", - TokenEnvVar: "DAOXE_API_KEY", - }, - "groq": { - APIType: "openai", - BaseURL: "https://api.groq.com/openai/v1", - TokenEnvVar: "GROQ_API_KEY", - }, - "fireworks-ai": { - APIType: "openai", - BaseURL: "https://api.fireworks.ai/inference/v1", - TokenEnvVar: "FIREWORKS_API_KEY", - }, - "deepseek": { - APIType: "openai", - BaseURL: "https://api.deepseek.com/v1", - TokenEnvVar: "DEEPSEEK_API_KEY", - }, - "cerebras": { - APIType: "openai", - BaseURL: "https://api.cerebras.ai/v1", - TokenEnvVar: "CEREBRAS_API_KEY", - }, - "togetherai": { - APIType: "openai", - BaseURL: "https://api.together.xyz/v1", - TokenEnvVar: "TOGETHER_API_KEY", - }, - "huggingface": { - APIType: "openai", - BaseURL: "https://router.huggingface.co/v1", - TokenEnvVar: "HF_TOKEN", - }, - "moonshotai": { - APIType: "openai", - BaseURL: "https://api.moonshot.ai/v1", - TokenEnvVar: "MOONSHOT_API_KEY", - }, - "vercel": { - APIType: "openai", - BaseURL: "https://ai-gateway.vercel.sh/v1", - TokenEnvVar: "AI_GATEWAY_API_KEY", - }, - // Cloudflare endpoints are account-scoped, so their base URLs are templated - // with ${CLOUDFLARE_ACCOUNT_ID} (and ${CLOUDFLARE_GATEWAY_ID} for the - // gateway), resolved from the environment at provider-build time. - "cloudflare-workers-ai": { - APIType: "openai", - BaseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - TokenEnvVar: "CLOUDFLARE_API_TOKEN", - }, - "cloudflare-ai-gateway": { - APIType: "openai", - BaseURL: "https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat", - TokenEnvVar: "CLOUDFLARE_API_TOKEN", - }, - "github-copilot": { - APIType: "openai", - BaseURL: "https://api.githubcopilot.com", - TokenEnvVar: "GITHUB_TOKEN", - }, - // The chatgpt provider uses a ChatGPT subscription (Plus/Pro/Business) - // instead of an API key: the `docker agent setup` sign-in stores an - // OAuth login that the "chatgpt-login" environment source exposes as the - // virtual CHATGPT_OAUTH_TOKEN variable. The Codex backend only serves - // the Responses API; the OpenAI client pins it for this provider. - "chatgpt": { - APIType: "openai", - BaseURL: chatgpt.BaseURL, - TokenEnvVar: chatgpt.TokenEnvVar, - }, - "opencode-go": { - APIType: "openai", - BaseURL: "https://opencode.ai/zen/go/v1", - TokenEnvVar: "OPENCODE_API_KEY", - }, - "opencode": { - APIType: "openai", - BaseURL: "https://opencode.ai/zen/v1", - TokenEnvVar: "OPENCODE_API_KEY", - }, -} - -// LookupAlias returns the Alias registered for the given name (if any). -// Lookup is case-sensitive; callers that need case-insensitive matching -// should normalise the name first (e.g. [strings.ToLower]). -func LookupAlias(name string) (Alias, bool) { - alias, ok := Aliases[modelsdev.CanonicalProviderID(name)] - return alias, ok -} - -// EachAlias returns an iterator over every registered (name, Alias) pair. -// Iteration order is not guaranteed; callers that need a deterministic order -// should sort by name. -func EachAlias() iter.Seq2[string, Alias] { - return func(yield func(string, Alias) bool) { - for name, alias := range Aliases { - if !yield(name, alias) { - return - } - } - } -} - -// AllProviders returns all known provider names (core providers + aliases), -// sorted for deterministic output. -func AllProviders() []string { - providers := slices.Concat(CoreProviders, slices.Collect(maps.Keys(Aliases))) - slices.Sort(providers) - return providers -} - -// IsKnownProvider returns true if the provider name is a core provider or an alias. -func IsKnownProvider(name string) bool { - if slices.Contains(CoreProviders, strings.ToLower(name)) { - return true - } - _, exists := LookupAlias(strings.ToLower(name)) - return exists -} - -// CatalogProviders returns the list of provider names that should be shown in the model catalog. -// This includes core providers and aliases that have a defined BaseURL (self-contained endpoints). -// Aliases without a BaseURL (like azure) require user configuration and are excluded. -func CatalogProviders() []string { - providers := make([]string, 0, len(CoreProviders)+len(Aliases)) - - // Add all core providers - providers = append(providers, CoreProviders...) - - // Add aliases that have a defined BaseURL (they work out of the box) - for name, alias := range EachAlias() { - if alias.BaseURL != "" { - providers = append(providers, name) - } - } - - return providers -} - -// IsCatalogProvider returns true if the provider name is valid for the model catalog. -func IsCatalogProvider(name string) bool { - // Check core providers - if slices.Contains(CoreProviders, name) { - return true - } - // Check aliases with BaseURL - if alias, exists := LookupAlias(name); exists && alias.BaseURL != "" { - return true - } - return false -} diff --git a/pkg/model/provider/aliases_test.go b/pkg/model/provider/aliases_test.go deleted file mode 100644 index 8414a35cc..000000000 --- a/pkg/model/provider/aliases_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package provider - -import ( - "maps" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/docker/docker-agent/pkg/modelsdev" -) - -func TestLookupAlias(t *testing.T) { - t.Parallel() - - // Every entry in the table is reachable. - for name, expected := range Aliases { - got, ok := LookupAlias(name) - assert.True(t, ok, "alias %q should be found", name) - assert.Equal(t, expected, got) - } - - // Unknown name yields the zero Alias and false. - got, ok := LookupAlias("does-not-exist") - assert.False(t, ok) - assert.Equal(t, Alias{}, got) - - // Lookup is case-sensitive (callers normalise themselves). - if _, ok := LookupAlias("MISTRAL"); ok { - t.Errorf("LookupAlias should be case-sensitive") - } -} - -// TestCatalogAliases asserts each self-contained OpenAI-compatible alias -// resolves to its expected configuration and is reported as both a known and a -// catalog provider. New aliases of the same shape only need a row here. -func TestCatalogAliases(t *testing.T) { - t.Parallel() - - expected := map[string]Alias{ - "openrouter": {APIType: "openai", BaseURL: "https://openrouter.ai/api/v1", TokenEnvVar: "OPENROUTER_API_KEY"}, - "api-route": {APIType: "openai", BaseURL: "https://global.api-route.com/v1", TokenEnvVar: "API_ROUTE_API_KEY"}, - "lunaroute": {APIType: "openai", BaseURL: "https://gw.lunaroute.com/v1", TokenEnvVar: "LUNAROUTE_API_KEY"}, - "orcarouter": {APIType: "openai", BaseURL: "https://api.orcarouter.ai/v1", TokenEnvVar: "ORCAROUTER_API_KEY"}, - "atlascloud": {APIType: "openai", BaseURL: "https://api.atlascloud.ai/v1", TokenEnvVar: "ATLASCLOUD_API_KEY"}, - "runcrate": {APIType: "openai", BaseURL: "https://api.runcrate.ai/v1", TokenEnvVar: "RUNCRATE_API_KEY"}, - "opper": {APIType: "openai", BaseURL: "https://api.opper.ai/v3/compat", TokenEnvVar: "OPPER_API_KEY"}, - "baseten": {APIType: "openai", BaseURL: "https://inference.baseten.co/v1", TokenEnvVar: "BASETEN_API_KEY"}, - "daoxe": {APIType: "openai", BaseURL: "https://api.daoxe.com/v1", TokenEnvVar: "DAOXE_API_KEY"}, - "ovhcloud": {APIType: "openai", BaseURL: "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", TokenEnvVar: "OVH_AI_ENDPOINTS_ACCESS_TOKEN"}, - "groq": {APIType: "openai", BaseURL: "https://api.groq.com/openai/v1", TokenEnvVar: "GROQ_API_KEY"}, - "deepseek": {APIType: "openai", BaseURL: "https://api.deepseek.com/v1", TokenEnvVar: "DEEPSEEK_API_KEY"}, - "cerebras": {APIType: "openai", BaseURL: "https://api.cerebras.ai/v1", TokenEnvVar: "CEREBRAS_API_KEY"}, - "fireworks-ai": {APIType: "openai", BaseURL: "https://api.fireworks.ai/inference/v1", TokenEnvVar: "FIREWORKS_API_KEY"}, - "togetherai": {APIType: "openai", BaseURL: "https://api.together.xyz/v1", TokenEnvVar: "TOGETHER_API_KEY"}, - "huggingface": {APIType: "openai", BaseURL: "https://router.huggingface.co/v1", TokenEnvVar: "HF_TOKEN"}, - "moonshotai": {APIType: "openai", BaseURL: "https://api.moonshot.ai/v1", TokenEnvVar: "MOONSHOT_API_KEY"}, - "nvidia": {APIType: "openai", BaseURL: "https://integrate.api.nvidia.com/v1", TokenEnvVar: "NVIDIA_API_KEY"}, - "vercel": {APIType: "openai", BaseURL: "https://ai-gateway.vercel.sh/v1", TokenEnvVar: "AI_GATEWAY_API_KEY"}, - } - - for name, want := range expected { - t.Run(name, func(t *testing.T) { - t.Parallel() - - alias, ok := LookupAlias(name) - require.True(t, ok) - assert.Equal(t, want, alias) - assert.True(t, IsKnownProvider(name)) - assert.True(t, IsCatalogProvider(name)) - }) - } -} - -// TestCloudflareAliases asserts the two Cloudflare aliases resolve to their -// account/gateway-scoped, env-templated base URLs. Unlike the static aliases -// above, their BaseURL carries ${...} references that are expanded at -// provider-build time (see cloudflare_alias_test.go for the resolution path). -func TestCloudflareAliases(t *testing.T) { - t.Parallel() - - expected := map[string]Alias{ - "cloudflare-workers-ai": { - APIType: "openai", - BaseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", - TokenEnvVar: "CLOUDFLARE_API_TOKEN", - }, - "cloudflare-ai-gateway": { - APIType: "openai", - BaseURL: "https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat", - TokenEnvVar: "CLOUDFLARE_API_TOKEN", - }, - } - - for name, want := range expected { - t.Run(name, func(t *testing.T) { - t.Parallel() - - alias, ok := LookupAlias(name) - require.True(t, ok) - assert.Equal(t, want, alias) - assert.True(t, IsKnownProvider(name)) - }) - } -} - -func TestEachAlias(t *testing.T) { - t.Parallel() - - // Iterator yields every entry exactly once. - collected := maps.Collect(EachAlias()) - assert.Equal(t, Aliases, collected) -} - -func TestEachAlias_EarlyTermination(t *testing.T) { - t.Parallel() - - // Iterator must respect a false return from the yield function. - require.NotEmpty(t, Aliases, "test requires the alias table to be non-empty") - - count := 0 - for range EachAlias() { - count++ - if count == 1 { - break - } - } - assert.Equal(t, 1, count, "iteration should stop when consumer breaks out") -} - -func TestProviderIDsMatchEmbeddedCatalog(t *testing.T) { - t.Parallel() - db := modelsdev.EmbeddedSnapshot() - absent := map[string]string{ - "api-route": "model IDs come from API Route's /v1/models endpoint, not models.dev", - "atlascloud": "model IDs come from Atlas Cloud's /v1/models endpoint, not models.dev", - "chatgpt": "subscription backend, distinct from OpenAI", - "lunaroute": "model IDs come from Lunaroute’s /v1/models endpoint, not models.dev", - "runcrate": "model IDs come from Runcrate's /v1/models endpoint, not models.dev", - "dmr": "local Docker Model Runner", - "ollama": "local Ollama server", - } - for _, name := range AllProviders() { - t.Run(name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, name, modelsdev.CanonicalProviderID(name)) - _, exists := db.Providers[name] - if reason, exempt := absent[name]; exempt { - assert.False(t, exists, "remove stale exemption: %s", reason) - } else { - assert.True(t, exists, "built-in provider must use its models.dev ID") - } - }) - } - for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} { - canonical := modelsdev.CanonicalProviderID(legacy) - assert.NotEqual(t, legacy, canonical) - assert.NotContains(t, Aliases, legacy) - assert.NotContains(t, AllProviders(), legacy) - assert.NotContains(t, db.Providers, legacy, "legacy ID now collides with an upstream service") - require.Contains(t, Aliases, canonical) - got, ok := LookupAlias(legacy) - require.True(t, ok) - assert.Equal(t, Aliases[canonical], got) - assert.True(t, IsKnownProvider(legacy)) - } -} diff --git a/pkg/model/provider/bedrock/client.go b/pkg/model/provider/bedrock/client.go index 44b1d6355..45523a715 100644 --- a/pkg/model/provider/bedrock/client.go +++ b/pkg/model/provider/bedrock/client.go @@ -1,6 +1,7 @@ package bedrock import ( + "cmp" "context" "errors" "fmt" @@ -18,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" @@ -96,8 +98,12 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // Create Bedrock Runtime client with appropriate auth var clientOpts []func(*bedrockruntime.Options) - // Support custom endpoint for VPC endpoints or testing - if endpoint := getProviderOpt[string](cfg.ProviderOpts, "endpoint_url"); endpoint != "" { + // The catalog template leaves regional and partition-specific routing to AWS. + endpoint := getProviderOpt[string](cfg.ProviderOpts, "endpoint_url") + if !catalog.IsDefaultEndpoint("amazon-bedrock", cfg.BaseURL) { + endpoint = cmp.Or(endpoint, cfg.BaseURL) + } + if endpoint != "" { clientOpts = append(clientOpts, func(o *bedrockruntime.Options) { o.BaseEndpoint = new(endpoint) }) diff --git a/pkg/model/provider/bedrock/client_test.go b/pkg/model/provider/bedrock/client_test.go index 59f21514d..0c4690b35 100644 --- a/pkg/model/provider/bedrock/client_test.go +++ b/pkg/model/provider/bedrock/client_test.go @@ -19,6 +19,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/tools" @@ -275,6 +276,43 @@ func TestNewClient_BearerTokenRequest(t *testing.T) { assert.Empty(t, got.Header.Get("X-Amz-Date"), "no SigV4 signing") } +func TestNewClient_EndpointResolution(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name, baseURL, endpointURL, wantHost string + }{ + {"catalog default", catalog.Lookup("amazon-bedrock").BaseURL, "", "bedrock-runtime.eu-west-1.amazonaws.com"}, + {"custom base URL", "https://custom.example", "", "custom.example"}, + {"endpoint option wins", "https://custom.example", "https://vpc.example", "vpc.example"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var got *http.Request + cfg := &latest.ModelConfig{ + Provider: "amazon-bedrock", Model: "anthropic.claude-v2", BaseURL: tc.baseURL, + ProviderOpts: map[string]any{"region": "eu-west-1", "endpoint_url": tc.endpointURL}, + } + client, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"AWS_BEARER_TOKEN_BEDROCK": "test-token"}), + options.WithHTTPTransportWrapper(func(http.RoundTripper) http.RoundTripper { + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + got = req + return &http.Response{ + StatusCode: http.StatusForbidden, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(`{"message":"denied"}`)), Request: req, + }, nil + }) + })) + require.NoError(t, err) + _, err = client.CreateChatCompletionStream(t.Context(), []chat.Message{{Role: chat.MessageRoleUser, Content: "hi"}}, nil) + require.Error(t, err) + require.NotNil(t, got) + assert.Equal(t, tc.wantHost, got.URL.Host) + }) + } +} + type roundTripperFunc func(*http.Request) (*http.Response, error) func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } diff --git a/pkg/model/provider/catalog/catalog.go b/pkg/model/provider/catalog/catalog.go new file mode 100644 index 000000000..97afc0b18 --- /dev/null +++ b/pkg/model/provider/catalog/catalog.go @@ -0,0 +1,152 @@ +// Package catalog provides embedded metadata for built-in model providers, +// without linking their SDK implementations. +package catalog + +import ( + _ "embed" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "github.com/docker/docker-agent/pkg/modelsdev" +) + +// Provider describes a provider's API and transport defaults. +// Callers must treat maps and slices returned by this package as read-only. +type Provider struct { + // Name is the canonical provider ID used in model references and catalog lookups. + Name string `json:"name"` + // APIType selects the registered API implementation that serves this provider. + APIType string `json:"api_type"` + // BaseURL is the default endpoint; API implementations resolve dynamic endpoints themselves. + // Environment references such as ${CLOUDFLARE_ACCOUNT_ID} expand at construction time. + BaseURL string `json:"base_url,omitempty"` + // TokenEnvVars lists token sources in priority order; explicit token_key overrides the list. + TokenEnvVars []string `json:"token_env_vars,omitempty"` + // DefaultModel is used when selecting this provider without an explicit model. + // Providers without one are excluded from automatic selection. + DefaultModel string `json:"default_model,omitempty"` + // RequiresConfiguration excludes providers needing a user-supplied endpoint from catalog listings and automatic selection. + RequiresConfiguration bool `json:"requires_configuration,omitempty"` + // MergeConsecutiveMessages coalesces adjacent same-role Chat Completions messages + // for servers whose chat templates reject multiple system messages. + MergeConsecutiveMessages bool `json:"merge_consecutive_messages,omitempty"` + // AutoResponses selects the Responses API for supported models unless api_type is explicit. + AutoResponses bool `json:"auto_responses,omitempty"` + // OpenAIVendor identifies genuine OpenAI model hosts, gating OpenAI-only wire fields. + // Speaking the OpenAI-compatible protocol alone does not qualify. + OpenAIVendor bool `json:"openai_vendor,omitempty"` + // Headers supplies default HTTP headers; user http_headers override them case-insensitively. + Headers map[string]string `json:"headers,omitempty"` + // QueryParams maps string-valued provider_opts keys to URL query parameter names. + QueryParams map[string]string `json:"query_params,omitempty"` + // Hint describes credential setup in automatic-selection errors; defaults to the token variable names. + Hint string `json:"hint,omitempty"` +} + +//go:embed providers.json +var providersJSON []byte + +var orderedProviders = loadProviders() + +func loadProviders() []Provider { + var providers []Provider + if err := json.Unmarshal(providersJSON, &providers); err != nil { + panic(fmt.Errorf("decode embedded providers: %w", err)) + } + return providers +} + +// Providers indexes built-in providers by name. Callers must not modify it. +var Providers = func() map[string]Provider { + providers := make(map[string]Provider, len(orderedProviders)) + for _, p := range orderedProviders { + providers[p.Name] = p + } + return providers +}() + +// Ordered returns providers in JSON order, which determines selection priority. +func Ordered() []Provider { + return orderedProviders +} + +// DefaultModels returns the configured default models, keyed by provider name. +func DefaultModels() map[string]string { + models := make(map[string]string) + for name, p := range Providers { + if p.DefaultModel != "" { + models[name] = p.DefaultModel + } + } + return models +} + +// CredentialProviders returns providers with a default model and credential +// detection, in catalog order. Local providers are handled by discovery. +func CredentialProviders() []Provider { + var providers []Provider + for _, p := range Ordered() { + if p.DefaultModel == "" || p.RequiresConfiguration { + continue + } + if len(p.TokenEnvVars) == 0 { + continue + } + if p.Hint == "" { + p.Hint = p.TokenEnvVars[0] + if len(p.TokenEnvVars) > 1 { + p.Hint += " (or " + strings.Join(p.TokenEnvVars[1:], ", ") + ")" + } + } + providers = append(providers, p) + } + return providers +} + +// Lookup resolves canonical and legacy provider names. Unknown names return zero metadata. +func Lookup(name string) Provider { + p, _ := Find(name) + return p +} + +// Find returns metadata and whether the provider is registered. +func Find(name string) (Provider, bool) { + p, ok := Providers[modelsdev.CanonicalProviderID(name)] + return p, ok +} + +// IsDefaultEndpoint treats an omitted endpoint and the catalog endpoint equally. +func IsDefaultEndpoint(name, endpoint string) bool { + return endpoint == "" || strings.TrimRight(endpoint, "/") == strings.TrimRight(Lookup(name).BaseURL, "/") +} + +// TokenKeys returns credential sources in priority order. Alternatives only apply +// to the built-in token key, never to an explicitly overridden credential. +func TokenKeys(name, tokenKey string) []string { + keys := []string{tokenKey} + p := Lookup(name) + if tokenKey != "" && len(p.TokenEnvVars) > 0 && tokenKey == p.TokenEnvVars[0] { + keys = p.TokenEnvVars + } + return keys +} + +// AllProviders returns all built-in provider names, sorted. +func AllProviders() []string { + return slices.Sorted(maps.Keys(Providers)) +} + +// IsKnownProvider reports whether the name identifies a built-in provider. +func IsKnownProvider(name string) bool { + _, exists := Find(strings.ToLower(name)) + return exists +} + +// IsCatalogProvider reports whether a provider is usable without explicit endpoint configuration. +func IsCatalogProvider(name string) bool { + p, exists := Find(name) + return exists && !p.RequiresConfiguration +} diff --git a/pkg/model/provider/catalog/catalog_test.go b/pkg/model/provider/catalog/catalog_test.go new file mode 100644 index 000000000..b5d526212 --- /dev/null +++ b/pkg/model/provider/catalog/catalog_test.go @@ -0,0 +1,91 @@ +package catalog + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEmbeddedProviders(t *testing.T) { + t.Parallel() + + var providers []Provider + decoder := json.NewDecoder(bytes.NewReader(providersJSON)) + decoder.DisallowUnknownFields() + require.NoError(t, decoder.Decode(&providers)) + require.ErrorIs(t, decoder.Decode(new(any)), io.EOF) + require.NotEmpty(t, providers) + assert.Equal(t, Ordered(), providers) + assert.Len(t, Providers, len(providers)) + + for name, alias := range Providers { + assert.NotEmpty(t, name) + assert.Equal(t, strings.ToLower(name), name) + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, alias, Lookup(name)) + for header, value := range alias.Headers { + assert.NotEmpty(t, header) + assert.Equal(t, http.CanonicalHeaderKey(header), header) + assert.NotContains(t, value, "\r") + assert.NotContains(t, value, "\n") + } + assert.Len(t, slices.Compact(slices.Clone(alias.TokenEnvVars)), len(alias.TokenEnvVars)) + for _, key := range alias.TokenEnvVars { + assert.NotEmpty(t, key) + } + for key, param := range alias.QueryParams { + assert.NotEmpty(t, key) + assert.NotEmpty(t, param) + } + }) + } +} + +func TestCredentialProviders(t *testing.T) { + t.Parallel() + + providers := CredentialProviders() + require.NotEmpty(t, providers) + seen := make(map[string]bool) + lastIndex := -1 + for _, p := range providers { + index := slices.IndexFunc(Ordered(), func(entry Provider) bool { return entry.Name == p.Name }) + assert.Greater(t, index, lastIndex, "selection must follow JSON order") + lastIndex = index + assert.False(t, seen[p.Name], "duplicate auto provider %q", p.Name) + seen[p.Name] = true + assert.NotEmpty(t, Providers[p.Name].APIType, "unknown provider %q", p.Name) + assert.NotEmpty(t, DefaultModels()[p.Name], "auto provider %q needs a default model", p.Name) + assert.NotEmpty(t, p.TokenEnvVars) + assert.NotEmpty(t, p.Hint) + } + for name, model := range DefaultModels() { + assert.NotEmpty(t, model) + assert.NotEmpty(t, Providers[name].APIType, "unknown default model provider %q", name) + } +} + +func TestTokenKeys(t *testing.T) { + t.Parallel() + + for name, alias := range Providers { + t.Run(name, func(t *testing.T) { + t.Parallel() + if len(alias.TokenEnvVars) > 0 { + assert.Equal(t, alias.TokenEnvVars, TokenKeys(name, alias.TokenEnvVars[0])) + } + assert.Equal(t, []string{"CUSTOM_TOKEN"}, TokenKeys(name, "CUSTOM_TOKEN")) + assert.Equal(t, []string{""}, TokenKeys(name, "")) + }) + } + assert.Equal(t, Provider{}, Lookup("unknown")) + assert.Equal(t, []string{"CUSTOM_TOKEN"}, TokenKeys("unknown", "CUSTOM_TOKEN")) +} diff --git a/pkg/model/provider/catalog/providers.json b/pkg/model/provider/catalog/providers.json new file mode 100644 index 000000000..27c2ba2b9 --- /dev/null +++ b/pkg/model/provider/catalog/providers.json @@ -0,0 +1,356 @@ +[ + { + "name": "anthropic", + "api_type": "anthropic", + "default_model": "claude-sonnet-5", + "base_url": "https://api.anthropic.com", + "token_env_vars": [ + "ANTHROPIC_API_KEY" + ] + }, + { + "name": "openai", + "api_type": "openai", + "auto_responses": true, + "openai_vendor": true, + "default_model": "gpt-5.6", + "base_url": "https://api.openai.com/v1", + "token_env_vars": [ + "OPENAI_API_KEY" + ] + }, + { + "name": "chatgpt", + "auto_responses": true, + "openai_vendor": true, + "api_type": "openai", + "base_url": "https://chatgpt.com/backend-api/codex", + "default_model": "gpt-5.6", + "hint": "sign in with your ChatGPT account: `docker agent setup`", + "token_env_vars": [ + "CHATGPT_OAUTH_TOKEN" + ] + }, + { + "name": "github-copilot", + "auto_responses": true, + "headers": { + "Copilot-Integration-Id": "copilot-developer-cli" + }, + "api_type": "openai", + "base_url": "https://api.githubcopilot.com", + "default_model": "gpt-5.6-sol", + "token_env_vars": [ + "GITHUB_TOKEN", + "GH_TOKEN" + ] + }, + { + "name": "google", + "api_type": "google", + "default_model": "gemini-3.8-flash", + "base_url": "https://generativelanguage.googleapis.com", + "token_env_vars": [ + "GOOGLE_API_KEY", + "GEMINI_API_KEY" + ] + }, + { + "name": "mistral", + "api_type": "openai", + "base_url": "https://api.mistral.ai/v1", + "default_model": "mistral-small-latest", + "token_env_vars": [ + "MISTRAL_API_KEY" + ] + }, + { + "name": "openrouter", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://openrouter.ai/api/v1", + "default_model": "meta-llama/llama-4-maverick", + "token_env_vars": [ + "OPENROUTER_API_KEY" + ] + }, + { + "name": "api-route", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://global.api-route.com/v1", + "default_model": "gpt-6.1-sol", + "token_env_vars": [ + "API_ROUTE_API_KEY" + ] + }, + { + "name": "lunaroute", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://gw.lunaroute.com/v1", + "default_model": "glm-5.3", + "token_env_vars": [ + "LUNAROUTE_API_KEY" + ] + }, + { + "name": "orcarouter", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.orcarouter.ai/v1", + "default_model": "orcarouter/auto", + "token_env_vars": [ + "ORCAROUTER_API_KEY" + ] + }, + { + "name": "atlascloud", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.atlascloud.ai/v1", + "default_model": "qwen/qwen3.8-max", + "token_env_vars": [ + "ATLASCLOUD_API_KEY" + ] + }, + { + "name": "opper", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.opper.ai/v3/compat", + "default_model": "claude-sonnet-4-6", + "token_env_vars": [ + "OPPER_API_KEY" + ] + }, + { + "name": "runcrate", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.runcrate.ai/v1", + "default_model": "deepseek-ai/DeepSeek-V4.1-Flash", + "token_env_vars": [ + "RUNCRATE_API_KEY" + ] + }, + { + "name": "baseten", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://inference.baseten.co/v1", + "default_model": "deepseek-ai/DeepSeek-V4-Pro-0813", + "token_env_vars": [ + "BASETEN_API_KEY" + ] + }, + { + "name": "ovhcloud", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1", + "default_model": "Qwen3.5-397B-A17B", + "token_env_vars": [ + "OVH_AI_ENDPOINTS_ACCESS_TOKEN" + ] + }, + { + "name": "daoxe", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.daoxe.com/v1", + "default_model": "claude-sonnet-4-6", + "token_env_vars": [ + "DAOXE_API_KEY" + ] + }, + { + "name": "groq", + "api_type": "openai", + "base_url": "https://api.groq.com/openai/v1", + "default_model": "llama-3.3-70b-versatile", + "token_env_vars": [ + "GROQ_API_KEY" + ] + }, + { + "name": "fireworks-ai", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.fireworks.ai/inference/v1", + "default_model": "accounts/fireworks/models/kimi-k3", + "token_env_vars": [ + "FIREWORKS_API_KEY" + ] + }, + { + "name": "deepseek", + "api_type": "openai", + "base_url": "https://api.deepseek.com/v1", + "default_model": "deepseek-v4-pro", + "token_env_vars": [ + "DEEPSEEK_API_KEY" + ] + }, + { + "name": "cerebras", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.cerebras.ai/v1", + "default_model": "gpt-oss-120b", + "token_env_vars": [ + "CEREBRAS_API_KEY" + ] + }, + { + "name": "togetherai", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.together.xyz/v1", + "default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo", + "token_env_vars": [ + "TOGETHER_API_KEY" + ] + }, + { + "name": "huggingface", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://router.huggingface.co/v1", + "default_model": "meta-llama/Llama-3.3-70B-Instruct", + "token_env_vars": [ + "HF_TOKEN" + ] + }, + { + "name": "moonshotai", + "api_type": "openai", + "base_url": "https://api.moonshot.ai/v1", + "default_model": "kimi-k3", + "token_env_vars": [ + "MOONSHOT_API_KEY" + ] + }, + { + "name": "vercel", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://ai-gateway.vercel.sh/v1", + "default_model": "openai/gpt-5.6-sol", + "token_env_vars": [ + "AI_GATEWAY_API_KEY" + ] + }, + { + "name": "amazon-bedrock", + "api_type": "amazon-bedrock", + "default_model": "global.anthropic.claude-sonnet-5", + "hint": "AWS_ACCESS_KEY_ID (or AWS_PROFILE, AWS_ROLE_ARN, AWS_BEARER_TOKEN_BEDROCK)", + "base_url": "https://bedrock-runtime.{region}.amazonaws.com", + "token_env_vars": [ + "AWS_BEARER_TOKEN_BEDROCK" + ] + }, + { + "name": "opencode", + "auto_responses": true, + "api_type": "openai", + "base_url": "https://opencode.ai/zen/v1", + "default_model": "deepseek-v4-flash-free", + "token_env_vars": [ + "OPENCODE_API_KEY" + ] + }, + { + "name": "opencode-go", + "api_type": "openai", + "base_url": "https://opencode.ai/zen/go/v1", + "default_model": "deepseek-v4.1-flash", + "token_env_vars": [ + "OPENCODE_API_KEY" + ] + }, + { + "name": "azure", + "requires_configuration": true, + "openai_vendor": true, + "query_params": { + "api_version": "api-version" + }, + "api_type": "openai", + "token_env_vars": [ + "AZURE_API_KEY" + ] + }, + { + "name": "cloudflare-ai-gateway", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://gateway.ai.cloudflare.com/v1/${CLOUDFLARE_ACCOUNT_ID}/${CLOUDFLARE_GATEWAY_ID}/compat", + "token_env_vars": [ + "CLOUDFLARE_API_TOKEN" + ] + }, + { + "name": "cloudflare-workers-ai", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1", + "token_env_vars": [ + "CLOUDFLARE_API_TOKEN" + ] + }, + { + "name": "minimax", + "api_type": "openai", + "base_url": "https://api.minimax.io/v1", + "token_env_vars": [ + "MINIMAX_API_KEY" + ] + }, + { + "name": "nebius", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://api.studio.nebius.com/v1", + "token_env_vars": [ + "NEBIUS_API_KEY" + ] + }, + { + "name": "nvidia", + "merge_consecutive_messages": true, + "api_type": "openai", + "base_url": "https://integrate.api.nvidia.com/v1", + "token_env_vars": [ + "NVIDIA_API_KEY" + ] + }, + { + "name": "ollama", + "api_type": "openai", + "base_url": "http://localhost:11434/v1" + }, + { + "name": "requesty", + "api_type": "openai", + "base_url": "https://router.requesty.ai/v1", + "token_env_vars": [ + "REQUESTY_API_KEY" + ] + }, + { + "name": "xai", + "api_type": "openai", + "base_url": "https://api.x.ai/v1", + "token_env_vars": [ + "XAI_API_KEY" + ] + }, + { + "name": "dmr", + "api_type": "dmr", + "default_model": "ai/qwen3:latest", + "base_url": "http://model-runner.docker.internal/engines/v1/" + } +] diff --git a/pkg/model/provider/catalog/providers_test.go b/pkg/model/provider/catalog/providers_test.go new file mode 100644 index 000000000..d07a0cd98 --- /dev/null +++ b/pkg/model/provider/catalog/providers_test.go @@ -0,0 +1,36 @@ +package catalog + +import ( + "maps" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsCatalogProvider(t *testing.T) { + t.Parallel() + + for name, p := range Providers { + assert.Equal(t, !p.RequiresConfiguration, IsCatalogProvider(name), name) + } + assert.False(t, IsCatalogProvider("unknown")) +} + +func TestAllProviders(t *testing.T) { + t.Parallel() + + assert.Equal(t, slices.Sorted(maps.Keys(Providers)), AllProviders()) +} + +func TestIsKnownProvider(t *testing.T) { + t.Parallel() + + for name := range Providers { + assert.True(t, IsKnownProvider(name), name) + assert.True(t, IsKnownProvider(strings.ToUpper(name)), name) + } + assert.False(t, IsKnownProvider("unknown")) + assert.False(t, IsKnownProvider("")) +} diff --git a/pkg/model/provider/catalog_compat_test.go b/pkg/model/provider/catalog_compat_test.go new file mode 100644 index 000000000..29887a757 --- /dev/null +++ b/pkg/model/provider/catalog_compat_test.go @@ -0,0 +1,116 @@ +//go:build !js && !docker_agent_no_openai + +package provider + +import ( + "net/http" + "net/http/httptest" + "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" + "github.com/docker/docker-agent/pkg/model/provider/catalog" + "github.com/docker/docker-agent/pkg/model/provider/options" +) + +func TestRegistryUnauthenticatedOpenAIEndpoint(t *testing.T) { + for _, key := range []string{"", "host-key"} { + t.Run("host-key="+key, func(t *testing.T) { + auth := make(chan string, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + auth <- r.Header.Get("Authorization") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer server.Close() + cfg := &latest.ModelConfig{ + Provider: "openai", Model: "local-model", BaseURL: server.URL, + ProviderOpts: map[string]any{"api_type": "openai_chatcompletions"}, + } + p, err := fullTestRegistry().New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": key})) + require.NoError(t, err) + for range 2 { + stream, err := p.CreateChatCompletionStream(t.Context(), []chat.Message{{Role: chat.MessageRoleUser, Content: "hi"}}, nil) + require.NoError(t, err) + for { + if _, err := stream.Recv(); err != nil { + break + } + } + stream.Close() + assert.Empty(t, <-auth) + p, err = Clone(t.Context(), p) + require.NoError(t, err) + } + }) + } +} + +func TestRegistryEndpointEnvironmentPrecedence(t *testing.T) { + for _, name := range []string{"openai", "anthropic"} { + t.Run(name, func(t *testing.T) { + received := make(chan string, 4) + newServer := func(label string) *httptest.Server { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + received <- label + w.Header().Set("Content-Type", "text/event-stream") + if name == "anthropic" { + _, _ = w.Write([]byte("event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")) + } else { + _, _ = w.Write([]byte("data: [DONE]\n\n")) + } + })) + t.Cleanup(server.Close) + return server + } + envServer, explicitServer := newServer("environment"), newServer("explicit") + if name == "openai" { + t.Setenv("OPENAI_BASE_URL", envServer.URL) + } else { + t.Setenv("ANTHROPIC_BASE_URL", envServer.URL) + } + for _, explicit := range []bool{false, true} { + cfg := &latest.ModelConfig{Provider: name, Model: "test-model"} + expected := "environment" + if explicit { + cfg.BaseURL = explicitServer.URL + expected = "explicit" + } + p, err := fullTestRegistry().New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"OPENAI_API_KEY": "test", "ANTHROPIC_API_KEY": "test"})) + require.NoError(t, err) + for range 2 { + stream, err := p.CreateChatCompletionStream(t.Context(), []chat.Message{{Role: chat.MessageRoleUser, Content: "hi"}}, nil) + require.NoError(t, err) + for { + if _, err := stream.Recv(); err != nil { + break + } + } + stream.Close() + assert.Equal(t, expected, <-received) + p, err = Clone(t.Context(), p) + require.NoError(t, err) + } + } + }) + } +} + +func TestRegistryExplicitCanonicalEndpointBypassesGateway(t *testing.T) { + t.Parallel() + cfg := &latest.ModelConfig{Provider: "openai", Model: "gpt-4o", BaseURL: catalog.Lookup("openai").BaseURL, TokenKey: "CUSTOM_KEY"} + p, err := fullTestRegistry().New(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"CUSTOM_KEY": "direct-key"}), options.WithGateway("https://gateway.example")) + require.NoError(t, err) + initial := p.BaseConfig() + assert.Empty(t, initial.ModelOptions.Gateway()) + assert.Equal(t, "CUSTOM_KEY", p.BaseConfig().ModelConfig.TokenKey) + cloned, err := Clone(t.Context(), p) + require.NoError(t, err) + rebuilt := cloned.BaseConfig() + assert.Empty(t, rebuilt.ModelOptions.Gateway()) + assert.Equal(t, cfg.BaseURL, cloned.BaseConfig().ModelConfig.BaseURL) +} diff --git a/pkg/model/provider/catalog_defaults_test.go b/pkg/model/provider/catalog_defaults_test.go new file mode 100644 index 000000000..9ef6c6660 --- /dev/null +++ b/pkg/model/provider/catalog_defaults_test.go @@ -0,0 +1,37 @@ +package provider + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" + "github.com/docker/docker-agent/pkg/model/provider/openai" + "github.com/docker/docker-agent/pkg/model/provider/options" +) + +func TestCatalogDefaultsPreserveGatewayOnRebuild(t *testing.T) { + t.Parallel() + for name, metadata := range catalog.Providers { + t.Run(name, func(t *testing.T) { + t.Parallel() + factory := func(_ context.Context, cfg *latest.ModelConfig, _ environment.Provider, opts ...options.Opt) (Provider, error) { + applied := options.Apply(opts...) + assert.Equal(t, "https://gateway.example", applied.Gateway()) + return &openai.Client{Config: base.Config{ModelConfig: *cfg, ModelOptions: applied}}, nil + } + registry := NewRegistry(map[string]Factory{metadata.APIType: factory}) + cfg := &latest.ModelConfig{Provider: name, Model: "test-model"} + env := environment.NewMapEnvProvider(map[string]string{"CLOUDFLARE_ACCOUNT_ID": "account", "CLOUDFLARE_GATEWAY_ID": "gateway"}) + p, err := registry.New(t.Context(), cfg, env, options.WithGateway("https://gateway.example")) + require.NoError(t, err) + _, err = Clone(t.Context(), p, options.WithMaxTokens(100)) + require.NoError(t, err) + }) + } +} diff --git a/pkg/model/provider/catalog_test.go b/pkg/model/provider/catalog_test.go new file mode 100644 index 000000000..79cfcc400 --- /dev/null +++ b/pkg/model/provider/catalog_test.go @@ -0,0 +1,107 @@ +package provider + +import ( + "net/url" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/chatgpt" + "github.com/docker/docker-agent/pkg/model/provider/catalog" + "github.com/docker/docker-agent/pkg/modelsdev" +) + +func TestFindProvider(t *testing.T) { + t.Parallel() + + // Every entry in the table is reachable. + for name, expected := range catalog.Providers { + got, ok := catalog.Find(name) + assert.True(t, ok, "alias %q should be found", name) + assert.Equal(t, expected, got) + } + + // Unknown name yields the zero catalog.Provider and false. + got, ok := catalog.Find("does-not-exist") + assert.False(t, ok) + assert.Equal(t, catalog.Provider{}, got) + + // Lookup is case-sensitive (callers normalise themselves). + if _, ok := catalog.Find("MISTRAL"); ok { + t.Errorf("catalog.Find should be case-sensitive") + } +} + +func TestProviderConfiguration(t *testing.T) { + t.Parallel() + + for name, alias := range catalog.Providers { + t.Run(name, func(t *testing.T) { + t.Parallel() + + assert.NotEmpty(t, name) + assert.Equal(t, strings.ToLower(name), name) + assert.Contains(t, []string{"openai", "anthropic", "google", "dmr", "amazon-bedrock"}, alias.APIType) + for _, key := range alias.TokenEnvVars { + assert.Equal(t, strings.TrimSpace(key), key) + assert.NotContains(t, key, " ") + } + if alias.BaseURL != "" { + endpoint, err := url.Parse(strings.ReplaceAll(alias.BaseURL, "{region}", "us-east-1")) + require.NoError(t, err) + assert.NotEmpty(t, endpoint.Host) + assert.True(t, slices.Contains([]string{"http", "https"}, endpoint.Scheme)) + } + }) + } +} + +func TestChatGPTAliasMatchesLogin(t *testing.T) { + t.Parallel() + + alias, ok := catalog.Find(chatgpt.ProviderName) + require.True(t, ok) + assert.Equal(t, chatgpt.BaseURL, alias.BaseURL) + assert.Equal(t, chatgpt.TokenEnvVar, alias.TokenEnvVars[0]) +} + +func TestProviderIDsMatchEmbeddedCatalog(t *testing.T) { + t.Parallel() + db := modelsdev.EmbeddedSnapshot() + absent := map[string]string{ + "api-route": "model IDs come from API Route's /v1/models endpoint, not models.dev", + "atlascloud": "model IDs come from Atlas Cloud's /v1/models endpoint, not models.dev", + "chatgpt": "subscription backend, distinct from OpenAI", + "lunaroute": "model IDs come from Lunaroute’s /v1/models endpoint, not models.dev", + "runcrate": "model IDs come from Runcrate's /v1/models endpoint, not models.dev", + "dmr": "local Docker Model Runner", + "ollama": "local Ollama server", + } + for _, name := range catalog.AllProviders() { + t.Run(name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, name, modelsdev.CanonicalProviderID(name)) + _, exists := db.Providers[name] + if reason, exempt := absent[name]; exempt { + assert.False(t, exists, "remove stale exemption: %s", reason) + } else { + assert.True(t, exists, "built-in provider must use its models.dev ID") + } + }) + } + for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} { + canonical := modelsdev.CanonicalProviderID(legacy) + assert.NotEqual(t, legacy, canonical) + assert.NotContains(t, catalog.Providers, legacy) + assert.NotContains(t, catalog.AllProviders(), legacy) + assert.NotContains(t, db.Providers, legacy, "legacy ID now collides with an upstream service") + require.Contains(t, catalog.Providers, canonical) + got, ok := catalog.Find(legacy) + require.True(t, ok) + assert.Equal(t, catalog.Providers[canonical], got) + assert.True(t, catalog.IsKnownProvider(legacy)) + } +} diff --git a/pkg/model/provider/contracts/contracts.go b/pkg/model/provider/contracts/contracts.go index 0cb4a8d36..e76a6823d 100644 --- a/pkg/model/provider/contracts/contracts.go +++ b/pkg/model/provider/contracts/contracts.go @@ -9,6 +9,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" @@ -103,7 +104,7 @@ func (c *Config) ImageOutputEnabled(ctx context.Context) bool { // the SDK at a local server. An explicit api_type of openai_chatcompletions // routes off the Responses API, which alone serves tool_search. func (c *Config) NativeToolSearchEnabled() bool { - if c.ModelConfig.BaseURL != "" || c.ModelConfig.ProviderOpts["api_type"] == "openai_chatcompletions" { + if !catalog.IsDefaultEndpoint(c.ModelConfig.Provider, c.ModelConfig.BaseURL) || c.ModelConfig.ProviderOpts["api_type"] == "openai_chatcompletions" { return false } enabled, _ := providerutil.GetProviderOptBool(c.ModelConfig.ProviderOpts, "native_tool_search") diff --git a/pkg/model/provider/defaults.go b/pkg/model/provider/defaults.go index e0666796e..dca6cd88d 100644 --- a/pkg/model/provider/defaults.go +++ b/pkg/model/provider/defaults.go @@ -10,6 +10,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/modelinfo" "github.com/docker/docker-agent/pkg/modelsdev" @@ -37,7 +38,7 @@ func resolveProviderType(cfg *latest.ModelConfig) string { if apiType, ok := cfg.ProviderOpts["api_type"].(string); ok && apiType != "" { return apiType } - if alias, exists := LookupAlias(cfg.Provider); exists && alias.APIType != "" { + if alias, exists := catalog.Find(cfg.Provider); exists && alias.APIType != "" { return alias.APIType } return cfg.Provider @@ -132,7 +133,7 @@ func isOpenAICompatibleProvider(providerType string) bool { return true } // Otherwise, the type is OpenAI-compatible iff it's an alias that maps to OpenAI. - alias, exists := LookupAlias(providerType) + alias, exists := catalog.Find(providerType) return exists && alias.APIType == "openai" } @@ -166,8 +167,8 @@ func applyProviderDefaults(cfg *latest.ModelConfig, customProviders map[string]l } enhancedCfg.Provider = modelsdev.CanonicalProviderID(enhancedCfg.Provider) - if alias, exists := LookupAlias(enhancedCfg.Provider); exists { - applyAliasFallbacks(enhancedCfg, alias) + if alias, exists := catalog.Find(enhancedCfg.Provider); exists { + applyCatalogDefaults(enhancedCfg, alias) } // Apply model-specific defaults (e.g., thinking budget for Claude/GPT models) @@ -230,14 +231,19 @@ func mergeFromProviderConfig(dst *latest.ModelConfig, src latest.ProviderConfig) } } -// applyAliasFallbacks applies BaseURL and TokenKey defaults from a built-in +// applyCatalogDefaults applies BaseURL and TokenKey defaults from a built-in // alias when the model config does not already specify them. -func applyAliasFallbacks(dst *latest.ModelConfig, alias Alias) { - if dst.BaseURL == "" { +func applyCatalogDefaults(dst *latest.ModelConfig, alias catalog.Provider) { + apiType, _ := dst.ProviderOpts["api_type"].(string) + unauthenticatedEndpoint := dst.BaseURL != "" && apiType != "" && alias.APIType == "openai" + // Native SDKs resolve endpoint environment overrides before their own defaults. + sdkEndpoint := alias.Name == alias.APIType && (alias.APIType == "openai" || alias.APIType == "anthropic") + if dst.BaseURL == "" && !sdkEndpoint { dst.BaseURL = alias.BaseURL } - if dst.TokenKey == "" { - dst.TokenKey = alias.TokenEnvVar + // Google chooses between API keys and Vertex OAuth/ADC at client construction. + if dst.TokenKey == "" && !unauthenticatedEndpoint && alias.APIType != "google" && len(alias.TokenEnvVars) > 0 { + dst.TokenKey = alias.TokenEnvVars[0] } } @@ -388,7 +394,7 @@ func isOpenAIVendor(cfg *latest.ModelConfig) bool { // exclusion [modelinfo.IsOpenAIVendor] cannot express on its own, since the // built-in alias registry lives in this package (see [isOpenAIVendor]'s doc). func isUnrecognizedOpenAIProtocolProvider(cfg *latest.ModelConfig) bool { - if _, isBuiltinAlias := LookupAlias(cfg.Provider); isBuiltinAlias { + if _, isBuiltinAlias := catalog.Find(cfg.Provider); isBuiltinAlias { return false } switch resolveProviderType(cfg) { diff --git a/pkg/model/provider/dmr/client.go b/pkg/model/provider/dmr/client.go index f294056d7..da5d5547c 100644 --- a/pkg/model/provider/dmr/client.go +++ b/pkg/model/provider/dmr/client.go @@ -22,6 +22,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/dmr/dmrmodels" "github.com/docker/docker-agent/pkg/model/provider/oaistream" "github.com/docker/docker-agent/pkg/model/provider/options" @@ -90,7 +91,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, opts ...options.Opt } else { // Skip docker model status query when BaseURL is explicitly provided. // This avoids unnecessary exec calls and speeds up tests/CI scenarios. - if cfg.BaseURL == "" && os.Getenv("MODEL_RUNNER_HOST") == "" { + if catalog.IsDefaultEndpoint(cfg.Provider, cfg.BaseURL) && os.Getenv("MODEL_RUNNER_HOST") == "" { var err error endpoint, engine, err = dmrmodels.DockerModelEndpointAndEngine(ctx) switch { diff --git a/pkg/model/provider/dmr/dmrmodels/resolve.go b/pkg/model/provider/dmr/dmrmodels/resolve.go index 019582fc9..033638ebb 100644 --- a/pkg/model/provider/dmr/dmrmodels/resolve.go +++ b/pkg/model/provider/dmr/dmrmodels/resolve.go @@ -21,6 +21,7 @@ import ( "time" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) const ( @@ -128,7 +129,7 @@ func getDMRFallbackURLs(containerized bool) []string { // possible. // // High‑level rules: -// - If the user explicitly configured a BaseURL or MODEL_RUNNER_HOST, use that (no fallbacks). +// - A non-default BaseURL or MODEL_RUNNER_HOST takes precedence (no fallbacks). // - For Desktop endpoints (model-runner.docker.internal) on the host, route // through the selected Docker Engine's experimental endpoints prefix. // - For standalone / offload endpoints like http://172.17.0.1:12435/engines/v1/, @@ -139,7 +140,7 @@ func getDMRFallbackURLs(containerized bool) []string { // It also returns an *http.Client when a custom transport (e.g., Docker Unix socket) is needed. func ResolveBaseURL(ctx context.Context, cfg *latest.ModelConfig, endpoint string) (string, *http.Client) { // Explicit configuration — return immediately without fallback testing. - if cfg != nil && cfg.BaseURL != "" { + if cfg != nil && !catalog.IsDefaultEndpoint("dmr", cfg.BaseURL) { slog.DebugContext(ctx, "DMR using explicitly configured BaseURL", "url", cfg.BaseURL) return cfg.BaseURL, nil } diff --git a/pkg/model/provider/dmr/dmrmodels/resolve_test.go b/pkg/model/provider/dmr/dmrmodels/resolve_test.go index 48a5ea607..c8fcac4e2 100644 --- a/pkg/model/provider/dmr/dmrmodels/resolve_test.go +++ b/pkg/model/provider/dmr/dmrmodels/resolve_test.go @@ -14,8 +14,26 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) +func TestResolveBaseURLCatalogDefault(t *testing.T) { + t.Setenv("MODEL_RUNNER_HOST", "http://runner.example:12434") + + cfg := &latest.ModelConfig{Provider: "dmr", BaseURL: catalog.Lookup("dmr").BaseURL} + endpoint, client := ResolveBaseURL(t.Context(), cfg, "") + assert.Equal(t, "http://runner.example:12434/engines/v1/", endpoint) + assert.Nil(t, client) + assert.Equal(t, catalog.Lookup("dmr").BaseURL, cfg.BaseURL) + + cfg.BaseURL = "http://custom.example/v1" + endpoint, client = ResolveBaseURL(t.Context(), cfg, "") + assert.Equal(t, cfg.BaseURL, endpoint) + assert.Nil(t, client) +} + func TestGetDMRFallbackURLs(t *testing.T) { t.Parallel() diff --git a/pkg/model/provider/factory.go b/pkg/model/provider/factory.go index 777db9bf7..8a4806ca6 100644 --- a/pkg/model/provider/factory.go +++ b/pkg/model/provider/factory.go @@ -168,6 +168,13 @@ func (r *Registry) createDirectProvider(ctx context.Context, cfg *latest.ModelCo if err != nil { return nil, err } + if setter, ok := p.(interface{ SetDispatchConfig(cfg *latest.ModelConfig) }); ok { + source, err := copyModelConfig(*constructionCfg) + if err != nil { + return nil, err + } + setter.SetDispatchConfig(&source) + } r.attachRebuilder(p, nil, env) // Wrap leaf providers with the GenAI semconv tracer so every chat // completion emits a `chat {model}` CLIENT span and the standard diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index 8f775008a..4710b7818 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" @@ -143,6 +144,10 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro httpClient.Transport = &bearerTokenTransport{source: tokenSource, base: httpClient.Transport} } + baseURL := cfg.BaseURL + if backend == genai.BackendVertexAI && catalog.IsDefaultEndpoint(cfg.Provider, baseURL) { + baseURL = "" + } client, err := genai.NewClient(ctx, &genai.ClientConfig{ APIKey: apiKey, Project: project, @@ -150,7 +155,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro Backend: backend, HTTPClient: httpClient, HTTPOptions: genai.HTTPOptions{ - BaseURL: cfg.BaseURL, + BaseURL: baseURL, }, }) if err != nil { @@ -227,11 +232,12 @@ func (t *bearerTokenTransport) RoundTrip(req *http.Request) (*http.Response, err // token_key when set, otherwise GOOGLE_API_KEY or GEMINI_API_KEY. func directAPIKey(ctx context.Context, cfg *latest.ModelConfig, env environment.Provider) (string, error) { if cfg.TokenKey != "" { - apiKey, _ := env.Get(ctx, cfg.TokenKey) - if apiKey == "" { - return "", fmt.Errorf("%s environment variable is required", cfg.TokenKey) + for _, key := range catalog.TokenKeys(cfg.Provider, cfg.TokenKey) { + if apiKey, _ := env.Get(ctx, key); apiKey != "" { + return apiKey, nil + } } - return apiKey, nil + return "", fmt.Errorf("%s environment variable is required", strings.Join(catalog.TokenKeys(cfg.Provider, cfg.TokenKey), " or ")) } var apiKey string if value, exist := env.Get(ctx, "GEMINI_API_KEY"); exist { diff --git a/pkg/model/provider/gemini/token_key_test.go b/pkg/model/provider/gemini/token_key_test.go index 2b0fc1f42..a3260f7b2 100644 --- a/pkg/model/provider/gemini/token_key_test.go +++ b/pkg/model/provider/gemini/token_key_test.go @@ -17,6 +17,29 @@ import ( "github.com/docker/docker-agent/pkg/testutil/geminiintegration" ) +func TestDirectAPIKeyCatalogAlternatives(t *testing.T) { + t.Parallel() + + cfg := &latest.ModelConfig{Provider: "google", TokenKey: "GOOGLE_API_KEY"} + env := environment.NewMapEnvProvider(map[string]string{ + "GEMINI_API_KEY": "alternative-key", + "GOOGLE_GENAI_USE_VERTEXAI": "true", + }) + key, err := directAPIKey(t.Context(), cfg, env) + require.NoError(t, err) + assert.Equal(t, "alternative-key", key) + + cfg.TokenKey = "CUSTOM_KEY" + _, err = directAPIKey(t.Context(), cfg, env) + require.ErrorContains(t, err, "CUSTOM_KEY") + + cfg.TokenKey = "GOOGLE_API_KEY" + _, err = directAPIKey(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{ + "GOOGLE_GENAI_USE_VERTEXAI": "true", + })) + require.Error(t, err) +} + func TestNewClient_TokenKey(t *testing.T) { t.Parallel() type testCase struct { diff --git a/pkg/model/provider/openai/canonical_provider_test.go b/pkg/model/provider/openai/canonical_provider_test.go index aff6c095c..1c9950f37 100644 --- a/pkg/model/provider/openai/canonical_provider_test.go +++ b/pkg/model/provider/openai/canonical_provider_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -18,7 +19,7 @@ func TestCanonicalProviderBehavior(t *testing.T) { shouldMergeConsecutiveMessages(&latest.ModelConfig{Provider: legacy}), shouldMergeConsecutiveMessages(&latest.ModelConfig{Provider: canonical}), ) - assert.NotContains(t, openModelHostProviders, legacy) + assert.NotContains(t, catalog.Providers, legacy) } assert.True(t, autoSelectsResponsesAPI("opencode")) assert.True(t, autoSelectsResponsesAPI("opencode-zen")) diff --git a/pkg/model/provider/openai/catalog_test.go b/pkg/model/provider/openai/catalog_test.go new file mode 100644 index 000000000..2dd497b8d --- /dev/null +++ b/pkg/model/provider/openai/catalog_test.go @@ -0,0 +1,93 @@ +package openai + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/catalog" +) + +func TestAliasBehaviorMetadata(t *testing.T) { + t.Parallel() + + for name, alias := range catalog.Providers { + t.Run(name, func(t *testing.T) { + t.Parallel() + cfg := &latest.ModelConfig{Provider: name} + assert.Equal(t, alias.MergeConsecutiveMessages, shouldMergeConsecutiveMessages(cfg)) + assert.Equal(t, alias.AutoResponses, autoSelectsResponsesAPI(name)) + for header, value := range alias.Headers { + assert.Equal(t, value, buildHeaderMap(cfg)[http.CanonicalHeaderKey(header)]) + } + }) + } +} + +func TestAliasQueryParams(t *testing.T) { + t.Parallel() + + for name, alias := range catalog.Providers { + for key, param := range alias.QueryParams { + t.Run(name+"/"+key, func(t *testing.T) { + t.Parallel() + for _, value := range []any{"2026-01-01", 42, nil} { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if expected, ok := value.(string); ok { + assert.Equal(t, expected, r.URL.Query().Get(param)) + } else { + assert.False(t, r.URL.Query().Has(param)) + } + writeSSEResponse(w) + })) + cfg := &latest.ModelConfig{ + Provider: name, + BaseURL: server.URL, + TokenKey: "TEST_KEY", + ProviderOpts: map[string]any{key: value}, + } + client, err := NewClient(t.Context(), cfg, environment.NewMapEnvProvider(map[string]string{"TEST_KEY": "test-token"})) + require.NoError(t, err) + sdk, err := client.clientFn(t.Context()) + require.NoError(t, err) + err = sdk.Get(t.Context(), "/models", nil, nil) + server.Close() + require.NoError(t, err) + } + }) + } + } +} + +func TestAliasTokenFallbacks(t *testing.T) { + t.Parallel() + + for name, alias := range catalog.Providers { + if len(alias.TokenEnvVars) < 2 { + continue + } + t.Run(name, func(t *testing.T) { + t.Parallel() + cfg := &latest.ModelConfig{Provider: name, TokenKey: alias.TokenEnvVars[0]} + env := environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVars[0]: "primary", alias.TokenEnvVars[1]: "fallback"}) + token, key := authTokenForTokenKey(t.Context(), cfg, env) + assert.Equal(t, "primary", token) + assert.Equal(t, alias.TokenEnvVars[0], key) + + env = environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVars[1]: "fallback"}) + token, key = authTokenForTokenKey(t.Context(), cfg, env) + assert.Equal(t, "fallback", token) + assert.Equal(t, alias.TokenEnvVars[1], key) + + cfg.TokenKey = "CUSTOM_KEY" + token, key = authTokenForTokenKey(t.Context(), cfg, env) + assert.Empty(t, token) + assert.Equal(t, "CUSTOM_KEY", key) + }) + } +} diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index b66f9628a..cc4f78093 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -21,17 +21,16 @@ import ( "github.com/openai/openai-go/v3/shared" "github.com/docker/docker-agent/pkg/chat" - "github.com/docker/docker-agent/pkg/chatgpt" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/effort" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/httpclient" "github.com/docker/docker-agent/pkg/model/provider/base" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/oaistream" "github.com/docker/docker-agent/pkg/model/provider/options" "github.com/docker/docker-agent/pkg/model/provider/providerutil" "github.com/docker/docker-agent/pkg/modelinfo" - "github.com/docker/docker-agent/pkg/modelsdev" "github.com/docker/docker-agent/pkg/rag/prompts" "github.com/docker/docker-agent/pkg/rag/types" "github.com/docker/docker-agent/pkg/tools" @@ -100,10 +99,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // Explicit token_key configured - use that env var authToken, tokenKey := authTokenForTokenKey(ctx, cfg, env) if authToken == "" { - if cfg.Provider == "github-copilot" && cfg.TokenKey == "GITHUB_TOKEN" { - return nil, errors.New("GITHUB_TOKEN or GH_TOKEN environment variable is required") - } - return nil, fmt.Errorf("%s environment variable is required", tokenKey) + return nil, fmt.Errorf("%s environment variable is required", strings.Join(catalog.TokenKeys(cfg.Provider, tokenKey), " or ")) } clientOptions = append(clientOptions, option.WithAPIKey(authToken)) case isCustomProvider(cfg): @@ -125,24 +121,14 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro // (OPENAI_API_KEY from the OS env). } - if cfg.Provider == "azure" { - // Azure configuration - if cfg.BaseURL != "" { - clientOptions = append(clientOptions, option.WithBaseURL(cfg.BaseURL)) - } - - // Azure API version from provider opts - if cfg.ProviderOpts != nil { - if apiVersion, exists := cfg.ProviderOpts["api_version"]; exists { - slog.DebugContext(ctx, "Setting API version", "api_version", apiVersion) - if apiVersionStr, ok := apiVersion.(string); ok { - clientOptions = append(clientOptions, option.WithQueryAdd("api-version", apiVersionStr)) - } - } - } - } else if cfg.BaseURL != "" { + if cfg.BaseURL != "" { clientOptions = append(clientOptions, option.WithBaseURL(cfg.BaseURL)) } + for key, name := range catalog.Lookup(cfg.Provider).QueryParams { + if value, ok := cfg.ProviderOpts[key].(string); ok { + clientOptions = append(clientOptions, option.WithQueryAdd(name, value)) + } + } // Apply custom HTTP headers from provider_opts (e.g. github-copilot's // required Copilot-Integration-Id) and any provider-specific defaults. @@ -268,37 +254,6 @@ func (c *Client) convertMessages(ctx context.Context, messages []chat.Message) [ return oaistream.ConvertMessages(ctx, messages, c.ID(), c.ModelOptions.ModelsDevStore(), c.CapsOverride()) } -// openModelHostProviders are built-in OpenAI-compatible aliases that front -// arbitrary open-weight models (Qwen, GLM, Llama, DeepSeek, ...) whose -// server-side chat templates may reject a request with more than one system -// message. First-party APIs with a fixed, vendor-controlled model lineup -// (openai, mistral, xai, minimax, github-copilot, opencode) tolerate multiple -// system messages and are deliberately absent so their behavior is unchanged. -var openModelHostProviders = map[string]bool{ - "api-route": true, - "atlascloud": true, - "baseten": true, - "daoxe": true, - "ovhcloud": true, - "openrouter": true, - "opper": true, - "runcrate": true, - "orcarouter": true, - "lunaroute": true, - "nebius": true, - "nvidia": true, - "cerebras": true, - "fireworks-ai": true, - "togetherai": true, - "huggingface": true, - "vercel": true, - // Cloudflare Workers AI serves open-weight models directly; the AI Gateway - // fronts them (and other providers) through one endpoint. Both plausibly - // reach models with strict single-system-message chat templates. - "cloudflare-workers-ai": true, - "cloudflare-ai-gateway": true, -} - // shouldMergeConsecutiveMessages reports whether the chat-completions request // should coalesce consecutive same-role messages before sending. // @@ -312,7 +267,7 @@ var openModelHostProviders = map[string]bool{ // It is scoped to endpoints that plausibly front such models: explicit // chat-completions custom providers, a self-hosted server reached by pointing // the openai provider at a custom base_url (vLLM/SGLang), and the open-model -// host aliases above. The official OpenAI API and other first-party APIs are +// host aliases declared in the catalog. The official OpenAI API and other first-party APIs are // left untouched. func shouldMergeConsecutiveMessages(cfg *latest.ModelConfig) bool { if cfg == nil { @@ -324,10 +279,10 @@ func shouldMergeConsecutiveMessages(cfg *latest.ModelConfig) bool { } // A custom base_url on the openai provider means a self-hosted or // third-party OpenAI-compatible server (e.g. vLLM), not api.openai.com. - if cfg.Provider == "openai" && cfg.BaseURL != "" { + if cfg.Provider == "openai" && !catalog.IsDefaultEndpoint(cfg.Provider, cfg.BaseURL) { return true } - return openModelHostProviders[modelsdev.CanonicalProviderID(cfg.Provider)] + return catalog.Lookup(cfg.Provider).MergeConsecutiveMessages } // contextLimit returns this model's context window in tokens, preferring an @@ -920,13 +875,12 @@ func (c *Client) buildWSHeaderFn() func(ctx context.Context) (http.Header, error } func authTokenForTokenKey(ctx context.Context, cfg *latest.ModelConfig, env environment.Provider) (string, string) { - token, _ := env.Get(ctx, cfg.TokenKey) - if token == "" && cfg.Provider == "github-copilot" && cfg.TokenKey == "GITHUB_TOKEN" { - if fallback, _ := env.Get(ctx, "GH_TOKEN"); fallback != "" { - return fallback, "GH_TOKEN" + for _, key := range catalog.TokenKeys(cfg.Provider, cfg.TokenKey) { + if token, _ := env.Get(ctx, key); token != "" { + return token, key } } - return token, cfg.TokenKey + return "", cfg.TokenKey } // webSocketEnabled reports whether transport=websocket can be honoured. WebSocket @@ -1423,11 +1377,7 @@ func isCustomProvider(cfg *latest.ModelConfig) bool { // driven by modelinfo.SupportsResponsesAPI so new models are picked up by // naming convention rather than a hardcoded allow-list. func autoSelectsResponsesAPI(provider string) bool { - switch modelsdev.CanonicalProviderID(provider) { - case "openai", "github-copilot", "opencode", chatgpt.ProviderName: - return true - } - return false + return catalog.Lookup(provider).AutoResponses } // noThinkingMinOutputTokens is the minimum output-token budget we enforce for diff --git a/pkg/model/provider/openai/headers.go b/pkg/model/provider/openai/headers.go index ad3c62bd4..87657a0ff 100644 --- a/pkg/model/provider/openai/headers.go +++ b/pkg/model/provider/openai/headers.go @@ -8,26 +8,7 @@ import ( "github.com/openai/openai-go/v3/option" "github.com/docker/docker-agent/pkg/config/latest" -) - -// GitHub Copilot's API requires a Copilot-Integration-Id header to -// identify the client integration when authenticating with a GitHub -// token. Without it, requests to https://api.githubcopilot.com are -// rejected with "Bad Request". -// -// We default to "copilot-developer-cli" because, unlike "vscode-chat", -// it is accepted by the Copilot API for both OAuth tokens and Personal -// Access Tokens (PATs). Most docker-agent users authenticate with a -// PAT exported as GITHUB_TOKEN, so this default makes the provider -// usable out of the box. -// -// Users can still override this via provider_opts.http_headers (see -// buildHeaderOptions below). -// -// See https://github.com/docker/docker-agent/issues/2471 -const ( - copilotIntegrationIDHeader = "Copilot-Integration-Id" - copilotIntegrationIDDefault = "copilot-developer-cli" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // buildHeaderOptions returns OpenAI client options for every custom @@ -65,8 +46,10 @@ func buildHeaderMap(cfg *latest.ModelConfig) map[string]string { // Canonicalizing keys de-duplicates headers case-insensitively: // defaults are applied first, then user config clobbers conflicts. headers := map[string]string{} - if cfg != nil && cfg.Provider == "github-copilot" { - headers[copilotIntegrationIDHeader] = copilotIntegrationIDDefault + if cfg != nil { + for name, value := range catalog.Lookup(cfg.Provider).Headers { + headers[http.CanonicalHeaderKey(name)] = sanitizeHeaderValue(value) + } } for name, value := range userHeaders(cfg) { headers[http.CanonicalHeaderKey(name)] = sanitizeHeaderValue(value) diff --git a/pkg/model/provider/openai/headers_test.go b/pkg/model/provider/openai/headers_test.go index a2bb42607..b133c298e 100644 --- a/pkg/model/provider/openai/headers_test.go +++ b/pkg/model/provider/openai/headers_test.go @@ -14,6 +14,11 @@ import ( "github.com/docker/docker-agent/pkg/environment" ) +const ( + copilotIntegrationIDHeader = "Copilot-Integration-Id" + copilotIntegrationIDDefault = "copilot-developer-cli" +) + func TestUserHeaders(t *testing.T) { t.Parallel() tests := []struct { diff --git a/pkg/model/provider/openai/prompt_cache.go b/pkg/model/provider/openai/prompt_cache.go index 20e43f628..1b5317f0f 100644 --- a/pkg/model/provider/openai/prompt_cache.go +++ b/pkg/model/provider/openai/prompt_cache.go @@ -23,6 +23,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/oaistream" "github.com/docker/docker-agent/pkg/modelinfo" ) @@ -53,7 +54,7 @@ func sendsExplicitCacheBreakpoints(cfg *latest.ModelConfig, openAIVendor bool) b if !modelinfo.OpenAISupportsExplicitPromptCache(cfg.Model) { return false } - if cfg.Provider == "openai" && cfg.BaseURL != "" { + if cfg.Provider == "openai" && !catalog.IsDefaultEndpoint(cfg.Provider, cfg.BaseURL) { return false } return modelinfo.IsOpenAIVendor(cfg.Provider, cfg.Model) || openAIVendor diff --git a/pkg/model/provider/openai/response_state.go b/pkg/model/provider/openai/response_state.go index 3d76ef38f..5541312c3 100644 --- a/pkg/model/provider/openai/response_state.go +++ b/pkg/model/provider/openai/response_state.go @@ -13,6 +13,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/chatgpt" "github.com/docker/docker-agent/pkg/httpclient" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelinfo" "github.com/docker/docker-agent/pkg/tools" ) @@ -22,7 +23,7 @@ func (c *Client) firstPartyResponses() bool { if c.ModelConfig.Provider == chatgpt.ProviderName { return c.ModelConfig.BaseURL == "" || strings.TrimRight(c.ModelConfig.BaseURL, "/") == chatgpt.BaseURL } - return c.ModelConfig.Provider == "openai" && c.ModelConfig.BaseURL == "" + return c.ModelConfig.Provider == "openai" && catalog.IsDefaultEndpoint(c.ModelConfig.Provider, c.ModelConfig.BaseURL) } // Native search needs ordered output replay to retain its loaded tools. diff --git a/pkg/model/provider/openai/system_message_merge_test.go b/pkg/model/provider/openai/system_message_merge_test.go index d769c0c49..bfeca7e7d 100644 --- a/pkg/model/provider/openai/system_message_merge_test.go +++ b/pkg/model/provider/openai/system_message_merge_test.go @@ -14,6 +14,7 @@ import ( "github.com/docker/docker-agent/pkg/chat" "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // TestChatCompletions_MergesConsecutiveSystemMessages is a regression test for @@ -35,64 +36,22 @@ func TestChatCompletions_MergesConsecutiveSystemMessages(t *testing.T) { }) } -func TestOrcaRouter_MergesConsecutiveSystemMessages(t *testing.T) { +func TestAliases_MergeConsecutiveSystemMessages(t *testing.T) { t.Parallel() - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "orcarouter", - Model: "orcarouter/auto", - TokenKey: "MY_TOKEN", - }) -} - -func TestDaoxe_MergesConsecutiveSystemMessages(t *testing.T) { - t.Parallel() - - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "daoxe", - Model: "gpt-4o", - TokenKey: "MY_TOKEN", - }) -} - -func TestOpper_MergesConsecutiveSystemMessages(t *testing.T) { - t.Parallel() - - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "opper", - Model: "claude-sonnet-4-6", - TokenKey: "MY_TOKEN", - }) -} - -func TestRuncrate_MergesConsecutiveSystemMessages(t *testing.T) { - t.Parallel() - - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "runcrate", - Model: "deepseek-ai/DeepSeek-V4.1-Flash", - TokenKey: "MY_TOKEN", - }) -} - -func TestBaseten_MergesConsecutiveSystemMessages(t *testing.T) { - t.Parallel() - - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "baseten", - Model: "zai-org/GLM-5.2", - TokenKey: "MY_TOKEN", - }) -} - -func TestOVHcloud_MergesConsecutiveSystemMessages(t *testing.T) { - t.Parallel() - - assertMergesConsecutiveMessages(t, &latest.ModelConfig{ - Provider: "ovhcloud", - Model: "Qwen3.5-397B-A17B", - TokenKey: "MY_TOKEN", - }) + for name, alias := range catalog.Providers { + if !alias.MergeConsecutiveMessages { + continue + } + t.Run(name, func(t *testing.T) { + t.Parallel() + assertMergesConsecutiveMessages(t, &latest.ModelConfig{ + Provider: name, + Model: "test-model", + TokenKey: "MY_TOKEN", + }) + }) + } } // TestOpenAIWithBaseURL_MergesConsecutiveSystemMessages is a regression test for diff --git a/pkg/model/provider/openai_alias_providers_test.go b/pkg/model/provider/openai_alias_providers_test.go index 0eca90446..cb01693f7 100644 --- a/pkg/model/provider/openai_alias_providers_test.go +++ b/pkg/model/provider/openai_alias_providers_test.go @@ -38,9 +38,8 @@ type openAIAliasProvider struct { model string // default model to send greeting string // words the mock server streams back, joined into one reply - // mergesSystemMessages is true for open-model hosts (openModelHostProviders - // in the openai client) whose strict chat templates require the per-source - // system messages to be coalesced into a single leading one (issue #3344). + // mergesSystemMessages is true for open-model hosts whose strict chat + // templates require a single leading system message (issue #3344). // First-party APIs with a fixed model lineup leave them untouched. mergesSystemMessages bool @@ -347,28 +346,18 @@ func TestOpenAIAliasProvider_LiveAPI(t *testing.T) { } } -func TestAPIRouteLiveAPIRequiresExplicitOptIn(t *testing.T) { - assertLiveAPIRequiresExplicitOptIn(t, "api-route") -} - -func TestLunarouteLiveAPIRequiresExplicitOptIn(t *testing.T) { - assertLiveAPIRequiresExplicitOptIn(t, "lunaroute") -} - -func TestOrcaRouterLiveAPIRequiresExplicitOptIn(t *testing.T) { - assertLiveAPIRequiresExplicitOptIn(t, "orcarouter") -} - -func assertLiveAPIRequiresExplicitOptIn(t *testing.T, providerName string) { - t.Helper() - var p openAIAliasProvider - for _, candidate := range openAIAliasProviders { - if candidate.provider == providerName { - p = candidate - break +func TestOpenAIAliasProvider_LiveAPIRequiresExplicitOptIn(t *testing.T) { + for _, p := range openAIAliasProviders { + if p.liveOptInEnvVar != "" { + t.Run(p.provider, func(t *testing.T) { + assertLiveAPIRequiresExplicitOptIn(t, p) + }) } } - require.NotEmpty(t, p.liveOptInEnvVar) +} + +func assertLiveAPIRequiresExplicitOptIn(t *testing.T, p openAIAliasProvider) { + t.Helper() var requests atomic.Int32 var mu sync.Mutex var hosts []string diff --git a/pkg/model/provider/provider_defaults_test.go b/pkg/model/provider/provider_defaults_test.go index b8ec30979..a0c5ee2c8 100644 --- a/pkg/model/provider/provider_defaults_test.go +++ b/pkg/model/provider/provider_defaults_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) // TestMergeFromProviderConfig_Fields exercises every individual field merge @@ -279,48 +280,53 @@ func TestMergeFromProviderConfig_Fields(t *testing.T) { } } -func TestApplyAliasFallbacks(t *testing.T) { +func TestApplyCatalogDefaults(t *testing.T) { t.Parallel() tests := []struct { name string dst *latest.ModelConfig - alias Alias + alias catalog.Provider wantBaseURL string wantTokenKey string }{ { name: "fills empty BaseURL and TokenKey", dst: &latest.ModelConfig{}, - alias: Alias{BaseURL: "https://api.example.com", TokenEnvVar: "X_KEY"}, + alias: catalog.Provider{BaseURL: "https://api.example.com", TokenEnvVars: []string{"X_KEY"}}, wantBaseURL: "https://api.example.com", wantTokenKey: "X_KEY", }, + { + name: "google defers authentication to its API implementation", + dst: &latest.ModelConfig{}, + alias: catalog.Provider{APIType: "google", TokenEnvVars: []string{"GOOGLE_API_KEY", "GEMINI_API_KEY"}}, + }, { name: "preserves explicit BaseURL", dst: &latest.ModelConfig{BaseURL: "https://override"}, - alias: Alias{BaseURL: "https://default", TokenEnvVar: "X_KEY"}, + alias: catalog.Provider{BaseURL: "https://default", TokenEnvVars: []string{"X_KEY"}}, wantBaseURL: "https://override", wantTokenKey: "X_KEY", }, { name: "preserves explicit TokenKey", dst: &latest.ModelConfig{TokenKey: "OVERRIDE"}, - alias: Alias{BaseURL: "https://default", TokenEnvVar: "DEFAULT"}, + alias: catalog.Provider{BaseURL: "https://default", TokenEnvVars: []string{"DEFAULT"}}, wantBaseURL: "https://default", wantTokenKey: "OVERRIDE", }, { name: "alias with only BaseURL (e.g. ollama)", dst: &latest.ModelConfig{}, - alias: Alias{BaseURL: "http://localhost:11434/v1"}, + alias: catalog.Provider{BaseURL: "http://localhost:11434/v1"}, wantBaseURL: "http://localhost:11434/v1", wantTokenKey: "", }, { name: "alias with only TokenEnvVar (e.g. azure)", dst: &latest.ModelConfig{}, - alias: Alias{TokenEnvVar: "AZURE_API_KEY"}, + alias: catalog.Provider{TokenEnvVars: []string{"AZURE_API_KEY"}}, wantBaseURL: "", wantTokenKey: "AZURE_API_KEY", }, @@ -329,7 +335,7 @@ func TestApplyAliasFallbacks(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - applyAliasFallbacks(tt.dst, tt.alias) + applyCatalogDefaults(tt.dst, tt.alias) assert.Equal(t, tt.wantBaseURL, tt.dst.BaseURL) assert.Equal(t, tt.wantTokenKey, tt.dst.TokenKey) }) @@ -401,7 +407,7 @@ func TestSetProviderOptIfAbsent(t *testing.T) { } // TestApplyProviderDefaults_AliasFallback covers the alias-only branch -// (no custom-provider entry) which exercises applyAliasFallbacks indirectly. +// (no custom-provider entry) which exercises applyCatalogDefaults indirectly. func TestApplyProviderDefaults_AliasFallback(t *testing.T) { t.Parallel() diff --git a/pkg/model/provider/provider_test.go b/pkg/model/provider/provider_test.go deleted file mode 100644 index 1e39d50f9..000000000 --- a/pkg/model/provider/provider_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package provider - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestCatalogProviders(t *testing.T) { - t.Parallel() - - providers := CatalogProviders() - - // Should include all core providers - for _, core := range CoreProviders { - assert.Contains(t, providers, core, "should include core provider %s", core) - } - - // Should include aliases with BaseURL - for name, alias := range EachAlias() { - if alias.BaseURL != "" { - assert.Contains(t, providers, name, "should include alias %s with BaseURL", name) - } else { - assert.NotContains(t, providers, name, "should NOT include alias %s without BaseURL", name) - } - } -} - -func TestIsCatalogProvider(t *testing.T) { - t.Parallel() - - // All core providers should be catalog providers - for _, core := range CoreProviders { - assert.True(t, IsCatalogProvider(core), "core provider %s should be a catalog provider", core) - } - - // Aliases: catalog if and only if they have a BaseURL - for name, alias := range EachAlias() { - if alias.BaseURL != "" { - assert.True(t, IsCatalogProvider(name), "alias %s with BaseURL should be a catalog provider", name) - } else { - assert.False(t, IsCatalogProvider(name), "alias %s without BaseURL should NOT be a catalog provider", name) - } - } - - // Unknown providers - assert.False(t, IsCatalogProvider("unknown")) - assert.False(t, IsCatalogProvider("cohere")) -} - -func TestAllProviders(t *testing.T) { - t.Parallel() - - all := AllProviders() - - // Should include all core providers - for _, core := range CoreProviders { - assert.Contains(t, all, core, "should include core provider %s", core) - } - - // Should include all aliases - for name := range EachAlias() { - assert.Contains(t, all, name, "should include alias %s", name) - } - - // Total count should be core + aliases - assert.Len(t, all, len(CoreProviders)+len(Aliases)) -} - -func TestIsKnownProvider(t *testing.T) { - t.Parallel() - - // All core providers should be known - for _, core := range CoreProviders { - assert.True(t, IsKnownProvider(core), "core provider %s should be known", core) - } - - // All aliases should be known - for name := range EachAlias() { - assert.True(t, IsKnownProvider(name), "alias %s should be known", name) - } - - // Case-insensitive - assert.True(t, IsKnownProvider("OpenAI")) - assert.True(t, IsKnownProvider("ANTHROPIC")) - - // Unknown providers - assert.False(t, IsKnownProvider("unknown")) - assert.False(t, IsKnownProvider("")) -} diff --git a/pkg/model/provider/resolve_test.go b/pkg/model/provider/resolve_test.go index 146ba4d09..03842be8e 100644 --- a/pkg/model/provider/resolve_test.go +++ b/pkg/model/provider/resolve_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/docker/docker-agent/pkg/config/latest" + "github.com/docker/docker-agent/pkg/model/provider/catalog" ) func TestResolveEffectiveProvider(t *testing.T) { @@ -54,7 +55,7 @@ func TestIsOpenAICompatibleProvider(t *testing.T) { } // Aliases that point to the openai api — the previously-uncovered tail. - for name, alias := range EachAlias() { + for name, alias := range catalog.Providers { if alias.APIType == "openai" { t.Run("alias/"+name, func(t *testing.T) { t.Parallel() diff --git a/pkg/modelinfo/modelinfo.go b/pkg/modelinfo/modelinfo.go index 94567009a..092e9368a 100644 --- a/pkg/modelinfo/modelinfo.go +++ b/pkg/modelinfo/modelinfo.go @@ -39,6 +39,7 @@ import ( "sync" "time" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -512,16 +513,6 @@ func HasOpenAIQualifier(modelID string) bool { return strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelID)), openAIQualifierPrefix) } -// openAIVendorProviders are the provider names whose models are genuinely -// OpenAI's own, as opposed to a third-party vendor that merely speaks the -// OpenAI-compatible wire protocol through a built-in alias. See -// [IsOpenAIVendor]. -var openAIVendorProviders = map[string]bool{ - "openai": true, - "chatgpt": true, - "azure": true, -} - // IsOpenAIVendor reports whether a (provider, modelID) pair genuinely names an // OpenAI vendor endpoint, as opposed to a third-party provider that merely // speaks the OpenAI-compatible wire protocol (xai, mistral, and other @@ -545,7 +536,8 @@ var openAIVendorProviders = map[string]bool{ // alias registry — that registry lives above modelinfo in the import graph, // so it cannot be folded in here. func IsOpenAIVendor(provider, modelID string) bool { - if openAIVendorProviders[strings.ToLower(strings.TrimSpace(provider))] { + provider = strings.ToLower(strings.TrimSpace(provider)) + if catalog.Lookup(provider).OpenAIVendor { return true } return HasOpenAIQualifier(modelID) diff --git a/pkg/runtime/lazy_model_store.go b/pkg/runtime/lazy_model_store.go index e0cf1d35f..6342dbd7c 100644 --- a/pkg/runtime/lazy_model_store.go +++ b/pkg/runtime/lazy_model_store.go @@ -4,7 +4,7 @@ import ( "context" "sync" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -31,7 +31,7 @@ type lazyModelStore struct { func (l *lazyModelStore) load() (*modelsdev.Store, error) { l.once.Do(func() { - l.st, l.err = modelsdev.NewStore(modelsdev.WithKnownProvider(provider.IsKnownProvider)) + l.st, l.err = modelsdev.NewStore(modelsdev.WithKnownProvider(catalog.IsKnownProvider)) }) return l.st, l.err } diff --git a/pkg/runtime/modelcatalog/catalog.go b/pkg/runtime/modelcatalog/catalog.go index 8b840c53b..43d415b1e 100644 --- a/pkg/runtime/modelcatalog/catalog.go +++ b/pkg/runtime/modelcatalog/catalog.go @@ -12,6 +12,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/model/provider/dmr/dmrmodels" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -408,10 +409,9 @@ func (r *Catalog) buildCatalogChoices(ctx context.Context) []ModelChoice { // mapModelsDevProvider maps a models.dev provider ID to a docker agent provider name. // Returns the docker agent provider name and whether it's supported. -// Uses provider.IsCatalogProvider to dynamically include all core providers -// and aliases with defined base URLs. +// Uses catalog metadata to include providers that need no endpoint configuration. func mapModelsDevProvider(providerID string) (string, bool) { - if provider.IsCatalogProvider(providerID) { + if catalog.IsCatalogProvider(providerID) { return providerID, true } return "", false diff --git a/pkg/runtime/modelcatalog/catalog_test.go b/pkg/runtime/modelcatalog/catalog_test.go index d0e30a3a1..7246e937e 100644 --- a/pkg/runtime/modelcatalog/catalog_test.go +++ b/pkg/runtime/modelcatalog/catalog_test.go @@ -10,7 +10,7 @@ import ( "github.com/docker/docker-agent/pkg/config/latest" "github.com/docker/docker-agent/pkg/environment" - "github.com/docker/docker-agent/pkg/model/provider" + "github.com/docker/docker-agent/pkg/model/provider/catalog" "github.com/docker/docker-agent/pkg/modelsdev" ) @@ -472,7 +472,7 @@ func TestBuildCatalogChoices_CanonicalProviders(t *testing.T) { t.Run(fmt.Sprintf("%s/custom=%t", legacy, custom), func(t *testing.T) { t.Parallel() canonical := modelsdev.CanonicalProviderID(legacy) - alias, ok := provider.LookupAlias(canonical) + alias, ok := catalog.Find(canonical) require.True(t, ok) r := &Catalog{ Store: &mockCatalogStore{db: &modelsdev.Database{Providers: map[string]modelsdev.Provider{ @@ -482,7 +482,7 @@ func TestBuildCatalogChoices_CanonicalProviders(t *testing.T) { }}, }}}, Config: &Config{ - EnvProvider: environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVar: "test-key"}), + EnvProvider: environment.NewMapEnvProvider(map[string]string{alias.TokenEnvVars[0]: "test-key"}), Models: map[string]latest.ModelConfig{"mine": {Provider: legacy, Model: "configured"}}, }, }