refactor: centralize providers in an embedded catalog

Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
This commit is contained in:
Djordje Lukic committed 2026-10-28 22:03:27 +01:00
1 parent b8fce99da4
commit 306a0b8cb0
53 files changed
+1421 -1043

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -22,6 +22,7 @@ tasks:
aliases: [b]
sources:
- "**/*.go"
- pkg/model/provider/catalog/providers.json
- "**/*.template"
- "**/*.txt"
- "**/*.yml"
+1 -1
View File
@@ -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 (<output>/<run>.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)")
+8 -6
View File
@@ -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)
+6 -6
View File
@@ -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
+2 -2
View File
@@ -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
+2 -1
View File
@@ -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...)
+19 -24
View File
@@ -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
+16 -128
View File
@@ -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
}
+3 -50
View File
@@ -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)
}
}
+10
View File
@@ -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()
+30 -16
View File
@@ -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
}
+20
View File
@@ -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"},
+2 -2
View File
@@ -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 {
+17 -21
View File
@@ -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
+2 -1
View File
@@ -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
+76
View File
@@ -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)
}
})
}
}
}
+2 -33
View File
@@ -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
-275
View File
@@ -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
}
-167
View File
@@ -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))
}
}
+8 -2
View File
@@ -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)
})
+38
View File
@@ -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) }
+152
View File
@@ -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
}
@@ -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"))
}
+356
View File
@@ -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/"
}
]
@@ -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(""))
}
+116
View File
@@ -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)
}
@@ -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)
})
}
}
+107
View File
@@ -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))
}
}
+2 -1
View File
@@ -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")
+16 -10
View File
@@ -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) {
+2 -1
View File
@@ -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 {
+3 -2
View File
@@ -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
}
@@ -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()
+7
View File
@@ -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
+11 -5
View File
@@ -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 {
@@ -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 {
@@ -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"))
+93
View File
@@ -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)
})
}
}
+16 -66
View File
@@ -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
+5 -22
View File
@@ -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)
@@ -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 {
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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.
@@ -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
@@ -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
+15 -9
View File
@@ -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()
-90
View File
@@ -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(""))
}
+2 -1
View File
@@ -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()
+3 -11
View File
@@ -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)
+2 -2
View File
@@ -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
}
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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"}},
},
}