mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-04 10:02:15 +00:00
refactor: centralize providers in an embedded catalog
Signed-off-by: Djordje Lukic <djordje.lukic@docker.com>
This commit is contained in:
53 files changed
+1421
-1043
No files matched your search
@@ -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
|
||||
@@ -22,6 +22,7 @@ tasks:
|
||||
aliases: [b]
|
||||
sources:
|
||||
- "**/*.go"
|
||||
- pkg/model/provider/catalog/providers.json
|
||||
- "**/*.template"
|
||||
- "**/*.txt"
|
||||
- "**/*.yml"
|
||||
|
||||
+1
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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...)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user