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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@
"properties": {
"provider": {
"type": "string",
"description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, azure, xai, ollama, mistral, baseten, ovhcloud, groq, fireworks, deepseek, cerebras, together, huggingface, moonshot, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, github-copilot, chatgpt, etc.).",
"description": "The underlying provider type. Defaults to \"openai\" when not set. Supported values: openai, anthropic, google, amazon-bedrock, dmr, and any built-in alias (requesty, openrouter, azure, xai, ollama, mistral, baseten, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, nvidia, github-copilot, chatgpt, opencode, opencode-go, etc.).",
"examples": [
"openai",
"anthropic",
Expand Down
30 changes: 23 additions & 7 deletions cmd/root/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ const listTimeout = 5 * time.Second
// from the snapshot) prevents surprising side effects like `docker agent models
// --provider ollama` issuing a real GET against localhost.
var liveFetchProviders = map[string]bool{
"opencode-zen": true,
"opencode-go": true,
"opencode": true,
"opencode-go": true,
}

// modelRow represents a single model entry for display or serialization.
Expand Down Expand Up @@ -129,11 +129,22 @@ func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string
out := cli.NewPrinter(cmd.OutOrStdout())
env := f.runConfig.EnvProvider()

// Normalize the provider filter to lowercase so case-sensitive map lookups
// in db.Providers and IsCatalogProvider all match the same way
// strings.EqualFold does in the outer row filter below.
isCustomProvider := func(name string) bool {
for customName := range f.runConfig.Providers {
if strings.EqualFold(customName, name) {
return true
}
}
return false
}

// Custom names take precedence over built-in aliases, regardless of case.
customFilter := isCustomProvider(f.providerFilter)
if f.providerFilter != "" {
f.providerFilter = strings.ToLower(f.providerFilter)
if !customFilter {
f.providerFilter = modelsdev.CanonicalProviderID(f.providerFilter)
}
}

// Determine which model auto-selection would pick. DMR discovery is left
Expand All @@ -158,10 +169,15 @@ func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string
rows = f.collectModels(ctx, env, availableProviders, autoModel)
}

// Apply provider filter
if f.providerFilter != "" {
rows = slices.DeleteFunc(rows, func(r modelRow) bool {
return !strings.EqualFold(r.Provider, f.providerFilter)
if strings.EqualFold(r.Provider, f.providerFilter) {
return false
}
if customFilter || isCustomProvider(r.Provider) {
return true
}
return modelsdev.CanonicalProviderID(strings.ToLower(r.Provider)) != f.providerFilter
})
}

Expand Down
175 changes: 175 additions & 0 deletions cmd/root/models_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"maps"
"net/http"
"net/http/httptest"
Expand All @@ -18,6 +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/modelsdev"
)

Expand Down Expand Up @@ -805,3 +807,176 @@ func TestModelsListCommand_AliasCredentialsListAliasModels(t *testing.T) {

assert.Contains(t, buf.String(), "grok-4", "an alias credential must surface the alias's catalog models without --all")
}

func TestModelsListCommand_LegacyProviderFilter(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} {
t.Run(legacy, func(t *testing.T) {
t.Parallel()
canonical := modelsdev.CanonicalProviderID(legacy)
alias, ok := provider.LookupAlias(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.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"}}}}},
}})
})
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--provider", legacy, "--format", "json"})
require.NoError(t, cmd.Execute())
var rows []modelRow
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.NotEmpty(t, rows)
var catalogFound bool
for _, row := range rows {
assert.Equal(t, canonical, row.Provider)
catalogFound = catalogFound || row.Model == "catalog-only"
}
assert.True(t, catalogFound)
})
}
}

func TestModelsListCommand_LegacyNamedCustomProviderFilter(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} {
t.Run(legacy, func(t *testing.T) {
t.Parallel()
for _, name := range []string{legacy, strings.ToUpper(legacy[:1]) + legacy[1:]} {
t.Run(name, func(t *testing.T) {
t.Parallel()
server, _ := newCustomProviderServer(t, []string{"custom-model"})
for _, filter := range []string{legacy, strings.ToUpper(legacy[:1]) + legacy[1:], strings.ToUpper(legacy)} {
t.Run(filter, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cmd := newModelsCmd(
withTestConfig(map[string]string{"MYPROVIDER_API_KEY": "custom-key"}),
withProviders(map[string]latest.ProviderConfig{
name: {BaseURL: server.URL, TokenKey: "MYPROVIDER_API_KEY"},
}),
)
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--provider", filter, "--format", "json"})
require.NoError(t, cmd.Execute())
var rows []modelRow
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, name, rows[0].Provider)
assert.Equal(t, "custom-model", rows[0].Model)
})
}
})
}
})
}
}

func TestModelsListCommand_GatewayLegacyProviderFilter(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} {
t.Run(legacy, func(t *testing.T) {
t.Parallel()
canonical := modelsdev.CanonicalProviderID(legacy)
for _, servedProvider := range []string{legacy, canonical} {
t.Run(servedProvider, func(t *testing.T) {
t.Parallel()
servedID := servedProvider + "/served-model"
gw, _ := newGatewayServer(t, fmt.Sprintf(`{"object":"list","data":[{"id":%q},{"id":"openai/other-model"}]}`, servedID))
for _, filter := range []string{legacy, canonical} {
t.Run(filter, func(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
cmd := newModelsCmd(
withTestConfig(gatewayTestEnv(nil)),
withProviders(map[string]latest.ProviderConfig{}),
withCatalog(&modelsdev.Database{}),
)
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--models-gateway", gw.URL, "--provider", filter, "--format", "json"})
require.NoError(t, cmd.Execute())
var rows []modelRow
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 1)
assert.Equal(t, servedProvider, rows[0].Provider)
assert.Equal(t, "served-model", rows[0].Model)
assert.Equal(t, servedID, rows[0].Provider+"/"+rows[0].Model)
})
}
})
}
})
}
}

func TestModelsListCommand_LegacyCustomProviderPrecedence(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"fireworks", "together", "moonshot", "opencode-zen"} {
t.Run(legacy, func(t *testing.T) {
t.Parallel()
canonical := modelsdev.CanonicalProviderID(legacy)
name := strings.ToUpper(legacy[:1]) + legacy[1:]
custom, _ := newCustomProviderServer(t, []string{"custom-model"})
gw, _ := newGatewayServer(t, fmt.Sprintf(`{"object":"list","data":[{"id":%q},{"id":%q}]}`, canonical+"/gateway-model", legacy+"/legacy-gateway-model"))
for _, tt := range []struct {
filter string
want []modelRow
}{
{legacy, []modelRow{{Provider: name, Model: "custom-model"}, {Provider: legacy, Model: "legacy-gateway-model"}}},
{canonical, []modelRow{{Provider: canonical, Model: "gateway-model"}}},
} {
t.Run(tt.filter, func(t *testing.T) {
t.Parallel()
env := gatewayTestEnv(nil)
env["MYPROVIDER_API_KEY"] = "custom-key"
var buf bytes.Buffer
cmd := newModelsCmd(
withTestConfig(env),
withCatalog(&modelsdev.Database{}),
withProviders(map[string]latest.ProviderConfig{
name: {BaseURL: custom.URL, TokenKey: "MYPROVIDER_API_KEY"},
}),
)
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--models-gateway", gw.URL, "--provider", tt.filter, "--format", "json"})
require.NoError(t, cmd.Execute())
var rows []modelRow
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
assert.Equal(t, tt.want, rows)
})
}
})
}
}

func TestModelsListCommand_CaseDuplicateCustomProviderFilter(t *testing.T) {
t.Parallel()
upper, _ := newCustomProviderServer(t, []string{"upper-model"})
lower, _ := newCustomProviderServer(t, []string{"lower-model"})
var buf bytes.Buffer
cmd := newModelsCmd(
withTestConfig(map[string]string{"MYPROVIDER_API_KEY": "custom-key"}),
withProviders(map[string]latest.ProviderConfig{
"Fireworks": {BaseURL: upper.URL, TokenKey: "MYPROVIDER_API_KEY"},
"fireworks": {BaseURL: lower.URL, TokenKey: "MYPROVIDER_API_KEY"},
}),
)
cmd.SetOut(&buf)
cmd.SetErr(&buf)
cmd.SetArgs([]string{"--provider", "FiReWoRkS", "--format", "json"})
require.NoError(t, cmd.Execute())
var rows []modelRow
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
require.Len(t, rows, 2)
assert.Equal(t, "Fireworks", rows[0].Provider)
assert.Equal(t, "upper-model", rows[0].Model)
assert.Equal(t, "fireworks", rows[1].Provider)
assert.Equal(t, "lower-model", rows[1].Model)
}
12 changes: 9 additions & 3 deletions docs/configuration/models/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ models:
first_available: [list] # Optional: candidate model refs, tried in order by available credentials.
# Mutually exclusive with other model settings.
provider: string # Required unless using first_available. One of: openai, anthropic, google, amazon-bedrock,
# dmr, mistral, xai, nebius, nvidia, minimax, baseten, ovhcloud, groq, fireworks, deepseek, cerebras, together, huggingface, moonshot, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, requesty, openrouter,
# dmr, mistral, xai, nebius, nvidia, minimax, baseten, ovhcloud, groq, fireworks-ai, deepseek, cerebras, togetherai, huggingface, moonshotai, vercel, cloudflare-workers-ai, cloudflare-ai-gateway, requesty, openrouter,
# azure, ollama, github-copilot, or a named provider defined
# under the top-level `providers:` section.
model: string # Required: model identifier
Expand Down Expand Up @@ -60,7 +60,7 @@ models:
| Property | Type | Required | Description |
| --------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
| `first_available` | array | ✗ | Candidate model references tried in order; selects the first whose credentials are configured. Mutually exclusive with other model settings. |
| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks`, `deepseek`, `cerebras`, `together`, `huggingface`, `moonshot`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `azure`, `ollama`, `github-copilot`, `chatgpt`, or any [named provider](../../providers/custom/index.md). |
| `provider` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Provider: `openai`, `anthropic`, `google`, `amazon-bedrock`, `dmr`, `mistral`, `xai`, `nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `azure`, `ollama`, `github-copilot`, `chatgpt`, `opencode`, `opencode-go`, or any [named provider](../../providers/custom/index.md). |
| `model` | string | ✓/✗ | Required for regular model definitions; omitted for `first_available` selectors. Model name (e.g., `gpt-4o`, `claude-sonnet-4-5`, `gemini-3.5-flash`) |
| `description` | string | ✗ | Informational, human-readable summary of the model's purpose or strengths (e.g., "fast and cheap, good for summaries"). Not sent to the model. Can be combined with `first_available` (a selector's description is kept when it resolves). |
| `temperature` | float | ✗ | Sampling randomness. Range is provider-dependent — typically `0.0–2.0` (Anthropic caps at `1.0`). `0.0` is deterministic. |
Expand All @@ -84,6 +84,12 @@ models:
| `compaction_threshold` | float | ✗ | Fraction of the context window at which proactive auto-compaction triggers for agents running this model. Must be greater than `0` and at most `1`. Takes precedence over the agent-level `compaction_threshold`. Cannot be combined with `first_available`. Default: `0.9`. See the [Context & Compaction guide](../../guides/compaction/index.md). |
| `bypass_models_gateway` | boolean | ✗ | When `true`, this model connects directly to its provider even when a models gateway (`--models-gateway` / `DOCKER_AGENT_MODELS_GATEWAY`) is configured. Implied by a custom `base_url`. See [Gateway Bypass](#gateway-bypass). |

Built-in provider IDs use models.dev names: `fireworks-ai`, `togetherai`,
`moonshotai`, and `opencode`. Existing configurations using `fireworks`,
`together`, `moonshot`, or `opencode-zen` continue to work without warnings.
New configurations using canonical IDs require a version that supports them.
Explicitly named custom providers still take precedence.

## Attachment Capability Overrides

For custom OpenAI-compatible providers, local models (Ollama, DMR), and any
Expand Down Expand Up @@ -550,7 +556,7 @@ for a complete local-server configuration.
## Custom HTTP Headers

For OpenAI-compatible providers (`openai`, `github-copilot`, `mistral`, `xai`,
`nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks`, `deepseek`, `cerebras`, `together`, `huggingface`, `moonshot`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `ollama`, and any custom provider using the OpenAI API),
`nebius`, `nvidia`, `minimax`, `baseten`, `ovhcloud`, `groq`, `fireworks-ai`, `deepseek`, `cerebras`, `togetherai`, `huggingface`, `moonshotai`, `vercel`, `cloudflare-workers-ai`, `cloudflare-ai-gateway`, `requesty`, `openrouter`, `ollama`, and any custom provider using the OpenAI API),
`provider_opts.http_headers` adds arbitrary HTTP headers to every outgoing
request:

Expand Down
2 changes: 2 additions & 0 deletions docs/features/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ $ docker agent models --provider openai
$ docker agent models --format json | jq
```

Provider filters are case-insensitive. For built-in providers, legacy and canonical names match the same models (`fireworks` / `fireworks-ai`, `together` / `togetherai`, `moonshot` / `moonshotai`, and `opencode-zen` / `opencode`), including gateway listings. Gateway model references retain the prefix returned by the gateway. A configured custom provider name takes precedence over a built-in alias and is matched by its own name without alias expansion.

When a models gateway is configured (`--models-gateway`, `DOCKER_AGENT_MODELS_GATEWAY`, or the user config), the command first queries the gateway's `/v1/models` endpoint. A non-empty response is authoritative for the models routed through the gateway: the listing shows the models the gateway serves (`--provider` filters within it), alongside any custom providers you have configured, which serve their models from their own endpoints rather than through the gateway. If the gateway cannot be queried or serves no usable model (endpoint not implemented, empty list, invalid response, timeout, missing authentication), the command falls back to the providers you have configured directly — provider API keys, provider aliases, and custom providers — plus the model catalog; a failure of one source never prevents the others from being listed. Docker Desktop authentication is required only for HTTPS `docker.com` gateways. An available Docker Desktop token may also be sent to trusted loopback gateways, but is never sent to third-party gateways.

### `docker agent toolsets`
Expand Down
8 changes: 5 additions & 3 deletions docs/providers/fireworks/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ models, serving Kimi, Qwen, DeepSeek, GLM and others through an
OpenAI-compatible API. Docker Agent includes built-in support for Fireworks AI
as an alias provider.

Use the provider ID `fireworks-ai`. The legacy ID `fireworks` is also accepted.

## Setup

1. Create an API key from the [Fireworks dashboard](https://fireworks.ai/account/api-keys).
Expand All @@ -33,7 +35,7 @@ The simplest way to use Fireworks AI:
```yaml
agents:
root:
model: fireworks/accounts/fireworks/models/kimi-k3
model: fireworks-ai/accounts/fireworks/models/kimi-k3
description: Assistant using Fireworks AI
instruction: You are a helpful assistant.
```
Expand All @@ -45,7 +47,7 @@ For more control over parameters:
```yaml
models:
fireworks_model:
provider: fireworks
provider: fireworks-ai
model: accounts/fireworks/models/kimi-k3
temperature: 0.7
max_tokens: 8192
Expand Down Expand Up @@ -93,7 +95,7 @@ messages into a single one for this provider.
```yaml
agents:
coder:
model: fireworks/accounts/fireworks/models/kimi-k2p7-code
model: fireworks-ai/accounts/fireworks/models/kimi-k2p7-code
description: Code assistant using Kimi K2.7 Code on Fireworks AI
instruction: |
You are an expert programmer.
Expand Down
8 changes: 5 additions & 3 deletions docs/providers/moonshot/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ OpenAI-compatible API. The Kimi K2 models have strong momentum for coding and
agentic tasks. Docker Agent includes built-in support for Moonshot AI as an
alias provider.

Use the provider ID `moonshotai`. The legacy ID `moonshot` is also accepted.

## Setup

1. Create an API key from the [Moonshot AI console](https://platform.moonshot.ai/console/api-keys).
Expand All @@ -33,7 +35,7 @@ The simplest way to use Moonshot AI:
```yaml
agents:
root:
model: moonshot/kimi-k3
model: moonshotai/kimi-k3
description: Assistant using Moonshot AI
instruction: You are a helpful assistant.
```
Expand All @@ -45,7 +47,7 @@ For more control over parameters:
```yaml
models:
moonshot_model:
provider: moonshot
provider: moonshotai
model: kimi-k3
temperature: 0.7
max_tokens: 8192
Expand Down Expand Up @@ -85,7 +87,7 @@ Moonshot AI is implemented as a built-in alias in Docker Agent:
```yaml
agents:
coder:
model: moonshot/kimi-k3
model: moonshotai/kimi-k3
description: Code assistant using Kimi K2
instruction: |
You are an expert programmer.
Expand Down
Loading
Loading