From 06c69e7782af390e6d1421f980a58dd0e635e19f Mon Sep 17 00:00:00 2001 From: Mase Graye Date: Wed, 1 Apr 2026 07:17:23 -0500 Subject: [PATCH 1/2] 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) --- cmd/root/models.go | 229 ++++++++++++++++++++++++++++++++++++++++ cmd/root/models_test.go | 169 +++++++++++++++++++++++++++++ cmd/root/root.go | 1 + 3 files changed, 399 insertions(+) create mode 100644 cmd/root/models.go create mode 100644 cmd/root/models_test.go diff --git a/cmd/root/models.go b/cmd/root/models.go new file mode 100644 index 000000000..f99d8ff78 --- /dev/null +++ b/cmd/root/models.go @@ -0,0 +1,229 @@ +package root + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "github.com/docker/docker-agent/pkg/cli" + "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" + "github.com/docker/docker-agent/pkg/telemetry" +) + +type modelsListFlags struct { + providerFilter string + format string + all bool + runConfig config.RuntimeConfig +} + +// modelRow represents a single model entry for display or serialization. +type modelRow struct { + Provider string `json:"provider"` + Model string `json:"model"` + Default bool `json:"default,omitempty"` +} + +func newModelsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "models", + Short: "List available models", + Long: `List models available for use with --model flag. + +Shows models that can be passed to 'docker agent run --model' or +'docker agent new --model'. By default shows models from providers +you have credentials for. Use --all to include all providers.`, + GroupID: "core", + } + + listCmd := newModelsListCmd() + cmd.AddCommand(listCmd) + + // Default to "list" when no subcommand given. + cmd.RunE = listCmd.RunE + + // Copy the flags from the list command so they work on the bare + // "docker agent models --provider openai" form as well. + cmd.Flags().AddFlagSet(listCmd.Flags()) + + return cmd +} + +func newModelsListCmd() *cobra.Command { + var flags modelsListFlags + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List available models", + Example: ` docker agent models + docker agent models list --provider openai + docker agent models ls --all + docker agent models --format json`, + Args: cobra.NoArgs, + RunE: flags.runModelsListCommand, + } + + cmd.Flags().StringVarP(&flags.providerFilter, "provider", "p", "", "Filter by provider name") + cmd.Flags().StringVar(&flags.format, "format", "table", "Output format: table, json") + cmd.Flags().BoolVarP(&flags.all, "all", "a", false, "Include models from all providers, not just those with credentials") + addGatewayFlags(cmd, &flags.runConfig) + + return cmd +} + +func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string) (commandErr error) { + ctx := cmd.Context() + telemetry.TrackCommand(ctx, "models", append([]string{"list"}, args...)) + defer func() { + telemetry.TrackCommandError(ctx, "models", append([]string{"list"}, args...), commandErr) + }() + + out := cli.NewPrinter(cmd.OutOrStdout()) + env := f.runConfig.EnvProvider() + + // Determine which providers the user has credentials for. + availableProviders := make(map[string]bool) + for _, p := range config.AvailableProviders(ctx, f.runConfig.ModelsGateway, env) { + availableProviders[p] = true + } + + // Determine which model auto-selection would pick. + autoModel := config.AutoModelConfig(ctx, f.runConfig.ModelsGateway, env, f.runConfig.DefaultModel) + + rows := f.collectModels(ctx, env, availableProviders, autoModel) + + // Apply provider filter + if f.providerFilter != "" { + filter := strings.ToLower(f.providerFilter) + rows = slices.DeleteFunc(rows, func(r modelRow) bool { + return strings.ToLower(r.Provider) != filter + }) + } + + // Sort: default first, then by provider, then by model + slices.SortFunc(rows, func(a, b modelRow) int { + if a.Default != b.Default { + if a.Default { + return -1 + } + return 1 + } + if c := strings.Compare(a.Provider, b.Provider); c != 0 { + return c + } + return strings.Compare(a.Model, b.Model) + }) + + if len(rows) == 0 { + out.Println("No models available.") + out.Println("\nConfigure a provider API key or install Docker Model Runner.") + return nil + } + + switch f.format { + case "json": + return f.renderJSON(cmd, rows) + default: + f.renderTable(cmd, rows) + } + + return nil +} + +// collectModels returns all models from the catalog, filtered by credential +// availability unless --all is set. Default models for each available provider +// are always included even if the catalog fetch fails. +func (f *modelsListFlags) collectModels(ctx context.Context, env environment.Provider, availableProviders map[string]bool, autoModel latest.ModelConfig) []modelRow { + seen := make(map[string]bool) + var rows []modelRow + + // Always include the per-provider defaults so we have something even + // if the catalog is unreachable. + for prov, model := range config.DefaultModels { + if !f.all && !availableProviders[prov] { + continue + } + ref := prov + "/" + model + seen[ref] = true + rows = append(rows, modelRow{ + Provider: prov, + Model: model, + Default: prov == autoModel.Provider && model == autoModel.Model, + }) + } + + // Fetch catalog and add all text-capable models. + store, err := modelsdev.NewStore() + if err != nil { + return rows + } + db, err := store.GetDatabase(ctx) + if err != nil { + return rows + } + + for providerID, prov := range db.Providers { + if !provider.IsCatalogProvider(providerID) { + continue + } + if !f.all && !availableProviders[providerID] { + continue + } + for modelID, model := range prov.Models { + if !slices.Contains(model.Modalities.Output, "text") { + continue + } + if isEmbeddingModel(model.Family, model.Name) { + continue + } + + ref := providerID + "/" + modelID + if seen[ref] { + continue + } + seen[ref] = true + + rows = append(rows, modelRow{ + Provider: providerID, + Model: modelID, + }) + } + } + + return rows +} + +func isEmbeddingModel(family, name string) bool { + fl := strings.ToLower(family) + nl := strings.ToLower(name) + return strings.Contains(fl, "embed") || strings.Contains(nl, "embed") +} + +func (f *modelsListFlags) renderTable(cmd *cobra.Command, rows []modelRow) { + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 3, ' ', 0) + fmt.Fprintln(w, "PROVIDER\tMODEL\tDEFAULT") + for _, r := range rows { + def := "" + if r.Default { + def = "*" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", r.Provider, r.Model, def) + } + w.Flush() +} + +func (f *modelsListFlags) renderJSON(cmd *cobra.Command, rows []modelRow) error { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(rows) +} diff --git a/cmd/root/models_test.go b/cmd/root/models_test.go new file mode 100644 index 000000000..cc6bdaf09 --- /dev/null +++ b/cmd/root/models_test.go @@ -0,0 +1,169 @@ +package root + +import ( + "bytes" + "encoding/json" + "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/userconfig" +) + +func TestModelsListCommand_DefaultOutput(t *testing.T) { + // With ANTHROPIC_API_KEY set, the default output should include + // at least the anthropic default model. + t.Setenv("ANTHROPIC_API_KEY", "test-key") + t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "") + t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "") + + original := loadUserConfig + loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil } + t.Cleanup(func() { loadUserConfig = original }) + + var buf bytes.Buffer + cmd := newModelsCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(nil) + + err := cmd.Execute() + require.NoError(t, err) + + output := buf.String() + assert.Contains(t, output, "PROVIDER") + assert.Contains(t, output, "MODEL") + assert.Contains(t, output, "anthropic") +} + +func TestModelsListCommand_ProviderFilter(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "test-key") + t.Setenv("OPENAI_API_KEY", "test-key") + t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "") + t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "") + + original := loadUserConfig + loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil } + t.Cleanup(func() { loadUserConfig = original }) + + var buf bytes.Buffer + cmd := newModelsCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--provider", "anthropic"}) + + err := cmd.Execute() + require.NoError(t, err) + + output := buf.String() + // Every non-header line should be anthropic + for _, line := range strings.Split(output, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "PROVIDER") { + continue + } + assert.True(t, strings.HasPrefix(line, "anthropic"), + "expected anthropic provider, got: %s", line) + } +} + +func TestModelsListCommand_JSONFormat(t *testing.T) { + t.Setenv("ANTHROPIC_API_KEY", "test-key") + t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "") + t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "") + + original := loadUserConfig + loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil } + t.Cleanup(func() { loadUserConfig = original }) + + var buf bytes.Buffer + cmd := newModelsCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--format", "json"}) + + err := cmd.Execute() + require.NoError(t, err) + + var rows []modelRow + err = json.Unmarshal(buf.Bytes(), &rows) + require.NoError(t, err) + assert.NotEmpty(t, rows) + + // At least one should be the default + hasDefault := false + for _, r := range rows { + if r.Default { + hasDefault = true + break + } + } + assert.True(t, hasDefault, "expected at least one default model") +} + +func TestModelsListCommand_DefaultMarker(t *testing.T) { + // When a default model is configured via env, it should be marked. + t.Setenv("ANTHROPIC_API_KEY", "test-key") + t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "") + t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "") + + original := loadUserConfig + loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil } + t.Cleanup(func() { loadUserConfig = original }) + + var buf bytes.Buffer + cmd := newModelsCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--format", "json"}) + + err := cmd.Execute() + require.NoError(t, err) + + var rows []modelRow + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + + // The auto-selected model should be marked as default + rc := config.RuntimeConfig{} + autoModel := config.AutoModelConfig(t.Context(), "", rc.EnvProvider(), nil) + for _, r := range rows { + if r.Provider == autoModel.Provider && r.Model == autoModel.Model { + assert.True(t, r.Default, "auto-selected model %s/%s should be marked as default", r.Provider, r.Model) + } + } +} + +func TestModelsListCommand_NoCredentials(t *testing.T) { + // Clear all provider keys — only DMR should remain as fallback. + t.Setenv("ANTHROPIC_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("GOOGLE_API_KEY", "") + t.Setenv("GEMINI_API_KEY", "") + t.Setenv("MISTRAL_API_KEY", "") + t.Setenv("AWS_ACCESS_KEY_ID", "") + t.Setenv("AWS_PROFILE", "") + t.Setenv("AWS_ROLE_ARN", "") + t.Setenv("DOCKER_AGENT_MODELS_GATEWAY", "") + t.Setenv("DOCKER_AGENT_DEFAULT_MODEL", "") + + original := loadUserConfig + loadUserConfig = func() (*userconfig.Config, error) { return &userconfig.Config{}, nil } + t.Cleanup(func() { loadUserConfig = original }) + + var buf bytes.Buffer + cmd := newModelsCmd() + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs(nil) + + err := cmd.Execute() + require.NoError(t, err) + + output := buf.String() + // DMR is always available as fallback + assert.Contains(t, output, "dmr") +} + diff --git a/cmd/root/root.go b/cmd/root/root.go index 841e980b6..c670080de 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -157,6 +157,7 @@ We collect anonymous usage data to help improve docker agent. To disable: newNewCmd(), newEvalCmd(), newShareCmd(), + newModelsCmd(), newDebugCmd(), newAliasCmd(), newServeCmd(), From 54f0e1a7f93e36991a08f08860fc76e7a6157065 Mon Sep 17 00:00:00 2001 From: Mase Graye Date: Wed, 1 Apr 2026 09:34:31 -0500 Subject: [PATCH 2/2] fix: resolve golangci-lint issues in models command - Remove unused env parameter (unparam) - Use strings.EqualFold for case-insensitive comparison (gocritic) - Use strings.SplitSeq for range iteration (modernize) - Fix import ordering: standard > third-party > project (gci) - Remove trailing blank line Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/root/models.go | 8 +++----- cmd/root/models_test.go | 3 +-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/cmd/root/models.go b/cmd/root/models.go index f99d8ff78..050616778 100644 --- a/cmd/root/models.go +++ b/cmd/root/models.go @@ -13,7 +13,6 @@ import ( "github.com/docker/docker-agent/pkg/cli" "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" "github.com/docker/docker-agent/pkg/telemetry" @@ -100,13 +99,12 @@ func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string // Determine which model auto-selection would pick. autoModel := config.AutoModelConfig(ctx, f.runConfig.ModelsGateway, env, f.runConfig.DefaultModel) - rows := f.collectModels(ctx, env, availableProviders, autoModel) + rows := f.collectModels(ctx, availableProviders, autoModel) // Apply provider filter if f.providerFilter != "" { - filter := strings.ToLower(f.providerFilter) rows = slices.DeleteFunc(rows, func(r modelRow) bool { - return strings.ToLower(r.Provider) != filter + return !strings.EqualFold(r.Provider, f.providerFilter) }) } @@ -143,7 +141,7 @@ func (f *modelsListFlags) runModelsListCommand(cmd *cobra.Command, args []string // collectModels returns all models from the catalog, filtered by credential // availability unless --all is set. Default models for each available provider // are always included even if the catalog fetch fails. -func (f *modelsListFlags) collectModels(ctx context.Context, env environment.Provider, availableProviders map[string]bool, autoModel latest.ModelConfig) []modelRow { +func (f *modelsListFlags) collectModels(ctx context.Context, availableProviders map[string]bool, autoModel latest.ModelConfig) []modelRow { seen := make(map[string]bool) var rows []modelRow diff --git a/cmd/root/models_test.go b/cmd/root/models_test.go index cc6bdaf09..4b8cae72a 100644 --- a/cmd/root/models_test.go +++ b/cmd/root/models_test.go @@ -60,7 +60,7 @@ func TestModelsListCommand_ProviderFilter(t *testing.T) { output := buf.String() // Every non-header line should be anthropic - for _, line := range strings.Split(output, "\n") { + for line := range strings.SplitSeq(output, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "PROVIDER") { continue @@ -166,4 +166,3 @@ func TestModelsListCommand_NoCredentials(t *testing.T) { // DMR is always available as fallback assert.Contains(t, output, "dmr") } -