Skip to content

Commit 06c69e7

Browse files
masegrayeclaude
andcommitted
feat: add docker agent models command to list available models
Adds a CLI command to discover models available for --model flag, so users don't have to guess valid provider/model combinations. Shows all catalog models for providers with configured credentials by default. --all includes providers without credentials. Supports --provider filter and --format json output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ae34273 commit 06c69e7

3 files changed

Lines changed: 399 additions & 0 deletions

File tree

cmd/root/models.go

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package root
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"slices"
8+
"strings"
9+
"text/tabwriter"
10+
11+
"github.com/spf13/cobra"
12+
13+
"github.com/docker/docker-agent/pkg/cli"
14+
"github.com/docker/docker-agent/pkg/config"
15+
"github.com/docker/docker-agent/pkg/config/latest"
16+
"github.com/docker/docker-agent/pkg/environment"
17+
"github.com/docker/docker-agent/pkg/model/provider"
18+
"github.com/docker/docker-agent/pkg/modelsdev"
19+
"github.com/docker/docker-agent/pkg/telemetry"
20+
)
21+
22+
type modelsListFlags struct {
23+
providerFilter string
24+
format string
25+
all bool
26+
runConfig config.RuntimeConfig
27+
}
28+
29+
// modelRow represents a single model entry for display or serialization.
30+
type modelRow struct {
31+
Provider string `json:"provider"`
32+
Model string `json:"model"`
33+
Default bool `json:"default,omitempty"`
34+
}
35+
36+
func newModelsCmd() *cobra.Command {
37+
cmd := &cobra.Command{
38+
Use: "models",
39+
Short: "List available models",
40+
Long: `List models available for use with --model flag.
41+
42+
Shows models that can be passed to 'docker agent run --model' or
43+
'docker agent new --model'. By default shows models from providers
44+
you have credentials for. Use --all to include all providers.`,
45+
GroupID: "core",
46+
}
47+
48+
listCmd := newModelsListCmd()
49+
cmd.AddCommand(listCmd)
50+
51+
// Default to "list" when no subcommand given.
52+
cmd.RunE = listCmd.RunE
53+
54+
// Copy the flags from the list command so they work on the bare
55+
// "docker agent models --provider openai" form as well.
56+
cmd.Flags().AddFlagSet(listCmd.Flags())
57+
58+
return cmd
59+
}
60+
61+
func newModelsListCmd() *cobra.Command {
62+
var flags modelsListFlags
63+
64+
cmd := &cobra.Command{
65+
Use: "list",
66+
Aliases: []string{"ls"},
67+
Short: "List available models",
68+
Example: ` docker agent models
69+
docker agent models list --provider openai
70+
docker agent models ls --all
71+
docker agent models --format json`,
72+
Args: cobra.NoArgs,
73+
RunE: flags.runModelsListCommand,
74+
}
75+
76+
cmd.Flags().StringVarP(&flags.providerFilter, "provider", "p", "", "Filter by provider name")
77+
cmd.Flags().StringVar(&flags.format, "format", "table", "Output format: table, json")
78+
cmd.Flags().BoolVarP(&flags.all, "all", "a", false, "Include models from all providers, not just those with credentials")
79+
addGatewayFlags(cmd, &flags.runConfig)
80+
81+
return cmd
82+
}
83+
84+
func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string) (commandErr error) {
85+
ctx := cmd.Context()
86+
telemetry.TrackCommand(ctx, "models", append([]string{"list"}, args...))
87+
defer func() {
88+
telemetry.TrackCommandError(ctx, "models", append([]string{"list"}, args...), commandErr)
89+
}()
90+
91+
out := cli.NewPrinter(cmd.OutOrStdout())
92+
env := f.runConfig.EnvProvider()
93+
94+
// Determine which providers the user has credentials for.
95+
availableProviders := make(map[string]bool)
96+
for _, p := range config.AvailableProviders(ctx, f.runConfig.ModelsGateway, env) {
97+
availableProviders[p] = true
98+
}
99+
100+
// Determine which model auto-selection would pick.
101+
autoModel := config.AutoModelConfig(ctx, f.runConfig.ModelsGateway, env, f.runConfig.DefaultModel)
102+
103+
rows := f.collectModels(ctx, env, availableProviders, autoModel)
104+
105+
// Apply provider filter
106+
if f.providerFilter != "" {
107+
filter := strings.ToLower(f.providerFilter)
108+
rows = slices.DeleteFunc(rows, func(r modelRow) bool {
109+
return strings.ToLower(r.Provider) != filter
110+
})
111+
}
112+
113+
// Sort: default first, then by provider, then by model
114+
slices.SortFunc(rows, func(a, b modelRow) int {
115+
if a.Default != b.Default {
116+
if a.Default {
117+
return -1
118+
}
119+
return 1
120+
}
121+
if c := strings.Compare(a.Provider, b.Provider); c != 0 {
122+
return c
123+
}
124+
return strings.Compare(a.Model, b.Model)
125+
})
126+
127+
if len(rows) == 0 {
128+
out.Println("No models available.")
129+
out.Println("\nConfigure a provider API key or install Docker Model Runner.")
130+
return nil
131+
}
132+
133+
switch f.format {
134+
case "json":
135+
return f.renderJSON(cmd, rows)
136+
default:
137+
f.renderTable(cmd, rows)
138+
}
139+
140+
return nil
141+
}
142+
143+
// collectModels returns all models from the catalog, filtered by credential
144+
// availability unless --all is set. Default models for each available provider
145+
// are always included even if the catalog fetch fails.
146+
func (f *modelsListFlags) collectModels(ctx context.Context, env environment.Provider, availableProviders map[string]bool, autoModel latest.ModelConfig) []modelRow {
147+
seen := make(map[string]bool)
148+
var rows []modelRow
149+
150+
// Always include the per-provider defaults so we have something even
151+
// if the catalog is unreachable.
152+
for prov, model := range config.DefaultModels {
153+
if !f.all && !availableProviders[prov] {
154+
continue
155+
}
156+
ref := prov + "/" + model
157+
seen[ref] = true
158+
rows = append(rows, modelRow{
159+
Provider: prov,
160+
Model: model,
161+
Default: prov == autoModel.Provider && model == autoModel.Model,
162+
})
163+
}
164+
165+
// Fetch catalog and add all text-capable models.
166+
store, err := modelsdev.NewStore()
167+
if err != nil {
168+
return rows
169+
}
170+
db, err := store.GetDatabase(ctx)
171+
if err != nil {
172+
return rows
173+
}
174+
175+
for providerID, prov := range db.Providers {
176+
if !provider.IsCatalogProvider(providerID) {
177+
continue
178+
}
179+
if !f.all && !availableProviders[providerID] {
180+
continue
181+
}
182+
for modelID, model := range prov.Models {
183+
if !slices.Contains(model.Modalities.Output, "text") {
184+
continue
185+
}
186+
if isEmbeddingModel(model.Family, model.Name) {
187+
continue
188+
}
189+
190+
ref := providerID + "/" + modelID
191+
if seen[ref] {
192+
continue
193+
}
194+
seen[ref] = true
195+
196+
rows = append(rows, modelRow{
197+
Provider: providerID,
198+
Model: modelID,
199+
})
200+
}
201+
}
202+
203+
return rows
204+
}
205+
206+
func isEmbeddingModel(family, name string) bool {
207+
fl := strings.ToLower(family)
208+
nl := strings.ToLower(name)
209+
return strings.Contains(fl, "embed") || strings.Contains(nl, "embed")
210+
}
211+
212+
func (f *modelsListFlags) renderTable(cmd *cobra.Command, rows []modelRow) {
213+
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 3, ' ', 0)
214+
fmt.Fprintln(w, "PROVIDER\tMODEL\tDEFAULT")
215+
for _, r := range rows {
216+
def := ""
217+
if r.Default {
218+
def = "*"
219+
}
220+
fmt.Fprintf(w, "%s\t%s\t%s\n", r.Provider, r.Model, def)
221+
}
222+
w.Flush()
223+
}
224+
225+
func (f *modelsListFlags) renderJSON(cmd *cobra.Command, rows []modelRow) error {
226+
enc := json.NewEncoder(cmd.OutOrStdout())
227+
enc.SetIndent("", " ")
228+
return enc.Encode(rows)
229+
}

cmd/root/models_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package root
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/docker/docker-agent/pkg/config"
13+
"github.com/docker/docker-agent/pkg/userconfig"
14+
)
15+
16+
func TestModelsListCommand_DefaultOutput(t *testing.T) {
17+
// With ANTHROPIC_API_KEY set, the default output should include
18+
// at least the anthropic default model.
19+
t.Setenv("ANTHROPIC_API_KEY", "test-key")
20+
t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "")
21+
t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "")
22+
23+
original := loadUserConfig
24+
loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
25+
t.Cleanup(func() { loadUserConfig = original })
26+
27+
var buf bytes.Buffer
28+
cmd := newModelsCmd()
29+
cmd.SetOut(&buf)
30+
cmd.SetErr(&buf)
31+
cmd.SetArgs(nil)
32+
33+
err := cmd.Execute()
34+
require.NoError(t, err)
35+
36+
output := buf.String()
37+
assert.Contains(t, output, "PROVIDER")
38+
assert.Contains(t, output, "MODEL")
39+
assert.Contains(t, output, "anthropic")
40+
}
41+
42+
func TestModelsListCommand_ProviderFilter(t *testing.T) {
43+
t.Setenv("ANTHROPIC_API_KEY", "test-key")
44+
t.Setenv("OPENAI_API_KEY", "test-key")
45+
t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "")
46+
t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "")
47+
48+
original := loadUserConfig
49+
loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
50+
t.Cleanup(func() { loadUserConfig = original })
51+
52+
var buf bytes.Buffer
53+
cmd := newModelsCmd()
54+
cmd.SetOut(&buf)
55+
cmd.SetErr(&buf)
56+
cmd.SetArgs([]string{"--provider", "anthropic"})
57+
58+
err := cmd.Execute()
59+
require.NoError(t, err)
60+
61+
output := buf.String()
62+
// Every non-header line should be anthropic
63+
for _, line := range strings.Split(output, "\n") {
64+
line = strings.TrimSpace(line)
65+
if line == "" || strings.HasPrefix(line, "PROVIDER") {
66+
continue
67+
}
68+
assert.True(t, strings.HasPrefix(line, "anthropic"),
69+
"expected anthropic provider, got: %s", line)
70+
}
71+
}
72+
73+
func TestModelsListCommand_JSONFormat(t *testing.T) {
74+
t.Setenv("ANTHROPIC_API_KEY", "test-key")
75+
t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "")
76+
t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "")
77+
78+
original := loadUserConfig
79+
loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
80+
t.Cleanup(func() { loadUserConfig = original })
81+
82+
var buf bytes.Buffer
83+
cmd := newModelsCmd()
84+
cmd.SetOut(&buf)
85+
cmd.SetErr(&buf)
86+
cmd.SetArgs([]string{"--format", "json"})
87+
88+
err := cmd.Execute()
89+
require.NoError(t, err)
90+
91+
var rows []modelRow
92+
err = json.Unmarshal(buf.Bytes(), &rows)
93+
require.NoError(t, err)
94+
assert.NotEmpty(t, rows)
95+
96+
// At least one should be the default
97+
hasDefault := false
98+
for _, r := range rows {
99+
if r.Default {
100+
hasDefault = true
101+
break
102+
}
103+
}
104+
assert.True(t, hasDefault, "expected at least one default model")
105+
}
106+
107+
func TestModelsListCommand_DefaultMarker(t *testing.T) {
108+
// When a default model is configured via env, it should be marked.
109+
t.Setenv("ANTHROPIC_API_KEY", "test-key")
110+
t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "")
111+
t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "")
112+
113+
original := loadUserConfig
114+
loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
115+
t.Cleanup(func() { loadUserConfig = original })
116+
117+
var buf bytes.Buffer
118+
cmd := newModelsCmd()
119+
cmd.SetOut(&buf)
120+
cmd.SetErr(&buf)
121+
cmd.SetArgs([]string{"--format", "json"})
122+
123+
err := cmd.Execute()
124+
require.NoError(t, err)
125+
126+
var rows []modelRow
127+
require.NoError(t, json.Unmarshal(buf.Bytes(), &rows))
128+
129+
// The auto-selected model should be marked as default
130+
rc := config.RuntimeConfig{}
131+
autoModel := config.AutoModelConfig(t.Context(), "", rc.EnvProvider(), nil)
132+
for _, r := range rows {
133+
if r.Provider == autoModel.Provider && r.Model == autoModel.Model {
134+
assert.True(t, r.Default, "auto-selected model %s/%s should be marked as default", r.Provider, r.Model)
135+
}
136+
}
137+
}
138+
139+
func TestModelsListCommand_NoCredentials(t *testing.T) {
140+
// Clear all provider keys — only DMR should remain as fallback.
141+
t.Setenv("ANTHROPIC_API_KEY", "")
142+
t.Setenv("OPENAI_API_KEY", "")
143+
t.Setenv("GOOGLE_API_KEY", "")
144+
t.Setenv("GEMINI_API_KEY", "")
145+
t.Setenv("MISTRAL_API_KEY", "")
146+
t.Setenv("AWS_ACCESS_KEY_ID", "")
147+
t.Setenv("AWS_PROFILE", "")
148+
t.Setenv("AWS_ROLE_ARN", "")
149+
t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "")
150+
t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "")
151+
152+
original := loadUserConfig
153+
loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil }
154+
t.Cleanup(func() { loadUserConfig = original })
155+
156+
var buf bytes.Buffer
157+
cmd := newModelsCmd()
158+
cmd.SetOut(&buf)
159+
cmd.SetErr(&buf)
160+
cmd.SetArgs(nil)
161+
162+
err := cmd.Execute()
163+
require.NoError(t, err)
164+
165+
output := buf.String()
166+
// DMR is always available as fallback
167+
assert.Contains(t, output, "dmr")
168+
}
169+

0 commit comments

Comments
 (0)