Skip to content

Commit 6ed3f01

Browse files
committed
handle providers better and show an appropriate error message when using the "auto" model and no provider is available or when dmr is not available
Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent d241b03 commit 6ed3f01

File tree

7 files changed

+159
-25
lines changed

7 files changed

+159
-25
lines changed

pkg/config/auto.go

Lines changed: 51 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,54 @@ package config
22

33
import (
44
"context"
5+
"fmt"
6+
"strings"
57

68
"github.com/docker/cagent/pkg/config/latest"
79
"github.com/docker/cagent/pkg/environment"
810
)
911

12+
// providerConfig defines a cloud provider and how to detect/describe its API keys.
13+
type providerConfig struct {
14+
name string // provider name (e.g., "anthropic")
15+
envVars []string // env vars to check - provider is available if ANY is set
16+
hint string // description for error messages
17+
}
18+
19+
// cloudProviders defines the available cloud providers in priority order.
20+
// The first provider with a configured API key will be selected by AutoModelConfig.
21+
// DMR is always appended as the final fallback (not listed here).
22+
var cloudProviders = []providerConfig{
23+
{"anthropic", []string{"ANTHROPIC_API_KEY"}, "ANTHROPIC_API_KEY"},
24+
{"openai", []string{"OPENAI_API_KEY"}, "OPENAI_API_KEY"},
25+
{"google", []string{"GOOGLE_API_KEY"}, "GOOGLE_API_KEY"},
26+
{"mistral", []string{"MISTRAL_API_KEY"}, "MISTRAL_API_KEY"},
27+
{"amazon-bedrock", []string{
28+
"AWS_BEARER_TOKEN_BEDROCK",
29+
"AWS_ACCESS_KEY_ID",
30+
"AWS_PROFILE",
31+
"AWS_ROLE_ARN",
32+
}, "AWS_ACCESS_KEY_ID (or AWS_PROFILE, AWS_ROLE_ARN, AWS_BEARER_TOKEN_BEDROCK)"},
33+
}
34+
35+
// ErrAutoModelFallback is returned when auto model selection fails because
36+
// no providers are available (no API keys configured and DMR not installed).
37+
type ErrAutoModelFallback struct{}
38+
39+
func (e *ErrAutoModelFallback) Error() string {
40+
var hints []string
41+
for _, p := range cloudProviders {
42+
hints = append(hints, fmt.Sprintf(" - %s: %s", p.name, p.hint))
43+
}
44+
45+
return fmt.Sprintf(`No model providers available.
46+
47+
To fix this, you can:
48+
- Install Docker Model Runner: https://docs.docker.com/ai/model-runner/get-started/
49+
- Configure an API key for a cloud provider:
50+
%s`, strings.Join(hints, "\n"))
51+
}
52+
1053
var DefaultModels = map[string]string{
1154
"openai": "gpt-5-mini",
1255
"anthropic": "claude-sonnet-4-0",
@@ -24,29 +67,16 @@ func AvailableProviders(ctx context.Context, modelsGateway string, env environme
2467

2568
var providers []string
2669

27-
if key, _ := env.Get(ctx, "ANTHROPIC_API_KEY"); key != "" {
28-
providers = append(providers, "anthropic")
29-
}
30-
if key, _ := env.Get(ctx, "OPENAI_API_KEY"); key != "" {
31-
providers = append(providers, "openai")
32-
}
33-
if key, _ := env.Get(ctx, "GOOGLE_API_KEY"); key != "" {
34-
providers = append(providers, "google")
35-
}
36-
if key, _ := env.Get(ctx, "MISTRAL_API_KEY"); key != "" {
37-
providers = append(providers, "mistral")
38-
}
39-
// AWS Bedrock supports multiple authentication methods (API key, IAM credentials, profile, role)
40-
if key, _ := env.Get(ctx, "AWS_BEARER_TOKEN_BEDROCK"); key != "" {
41-
providers = append(providers, "amazon-bedrock")
42-
} else if key, _ := env.Get(ctx, "AWS_ACCESS_KEY_ID"); key != "" {
43-
providers = append(providers, "amazon-bedrock")
44-
} else if key, _ := env.Get(ctx, "AWS_PROFILE"); key != "" {
45-
providers = append(providers, "amazon-bedrock")
46-
} else if key, _ := env.Get(ctx, "AWS_ROLE_ARN"); key != "" {
47-
providers = append(providers, "amazon-bedrock")
70+
for _, p := range cloudProviders {
71+
for _, envVar := range p.envVars {
72+
if key, _ := env.Get(ctx, envVar); key != "" {
73+
providers = append(providers, p.name)
74+
break // found one, no need to check other env vars for this provider
75+
}
76+
}
4877
}
4978

79+
// DMR is always the final fallback
5080
providers = append(providers, "dmr")
5181

5282
return providers

pkg/creator/agent_test.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/docker/cagent/pkg/config"
1111
"github.com/docker/cagent/pkg/config/latest"
12+
"github.com/docker/cagent/pkg/environment"
1213
)
1314

1415
func TestAgentConfigYAML(t *testing.T) {
@@ -102,14 +103,17 @@ func TestAgent(t *testing.T) {
102103

103104
ctx := t.Context()
104105

105-
// Create a minimal runtime config
106+
// Create a runtime config with a mock env provider that has a dummy API key
107+
// so the auto model can resolve to a provider without needing real credentials
106108
runConfig := &config.RuntimeConfig{
107109
Config: config.Config{
108110
WorkingDir: t.TempDir(),
109111
},
112+
EnvProviderForTests: environment.NewEnvListProvider([]string{
113+
"OPENAI_API_KEY=dummy-key-for-testing",
114+
}),
110115
}
111116

112-
// Test with a mock model override to avoid needing real API keys
113117
// The auto model will be resolved based on available providers
114118
team, err := Agent(ctx, runConfig, "")
115119
require.NoError(t, err)

pkg/model/provider/dmr/client.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ const (
4444
connectivityTimeout = 2 * time.Second
4545
)
4646

47+
// ErrNotInstalled is returned when Docker Model Runner is not installed.
48+
var ErrNotInstalled = errors.New("docker model runner is not available\nplease install it and try again (https://docs.docker.com/ai/model-runner/get-started/)")
49+
4750
const (
4851
// dmrInferencePrefix mirrors github.com/docker/model-runner/pkg/inference.InferencePrefix.
4952
dmrInferencePrefix = "/engines"
@@ -87,7 +90,11 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, opts ...options.Opt
8790
var err error
8891
endpoint, engine, err = getDockerModelEndpointAndEngine(ctx)
8992
if err != nil {
90-
slog.Debug("docker model status query failed", "error", err)
93+
if err.Error() == "unknown flag: --json\n\nUsage: docker [OPTIONS] COMMAND [ARG...]\n\nRun 'docker --help' for more information" {
94+
slog.Debug("docker model status query failed", "error", err)
95+
return nil, ErrNotInstalled
96+
}
97+
slog.Error("docker model status query failed", "error", err)
9198
} else {
9299
// Auto-pull the model if needed
93100
if err := pullDockerModelIfNeeded(ctx, cfg.Model); err != nil {

pkg/model/provider/dmr/client_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import (
55
"io"
66
"net/http"
77
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"runtime"
811
"testing"
912

1013
"github.com/stretchr/testify/assert"
@@ -27,6 +30,31 @@ func TestNewClientWithExplicitBaseURL(t *testing.T) {
2730
assert.Equal(t, "https://custom.example.com:8080/api/v1", client.baseURL)
2831
}
2932

33+
func TestNewClientReturnsErrNotInstalledWhenDockerModelUnsupported(t *testing.T) {
34+
if runtime.GOOS == "windows" {
35+
t.Skip("Skipping docker CLI shim test on Windows")
36+
}
37+
38+
tempDir := t.TempDir()
39+
dockerPath := filepath.Join(tempDir, "docker")
40+
script := "#!/bin/sh\n" +
41+
"printf 'unknown flag: --json\\n\\nUsage: docker [OPTIONS] COMMAND [ARG...]\\n\\nRun '\\''docker --help'\\'' for more information\\n' >&2\n" +
42+
"exit 1\n"
43+
require.NoError(t, os.WriteFile(dockerPath, []byte(script), 0o755))
44+
45+
t.Setenv("PATH", tempDir)
46+
t.Setenv("MODEL_RUNNER_HOST", "")
47+
48+
cfg := &latest.ModelConfig{
49+
Provider: "dmr",
50+
Model: "ai/qwen3",
51+
}
52+
53+
_, err := NewClient(t.Context(), cfg)
54+
require.Error(t, err)
55+
require.ErrorIs(t, err, ErrNotInstalled)
56+
}
57+
3058
func TestGetDMRFallbackURLs(t *testing.T) {
3159
t.Parallel()
3260

pkg/teamloader/teamloader.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package teamloader
33
import (
44
"cmp"
55
"context"
6+
"errors"
67
"fmt"
78
"log/slog"
89
"strings"
@@ -13,6 +14,7 @@ import (
1314
"github.com/docker/cagent/pkg/config/latest"
1415
"github.com/docker/cagent/pkg/js"
1516
"github.com/docker/cagent/pkg/model/provider"
17+
"github.com/docker/cagent/pkg/model/provider/dmr"
1618
"github.com/docker/cagent/pkg/model/provider/options"
1719
"github.com/docker/cagent/pkg/modelsdev"
1820
"github.com/docker/cagent/pkg/permissions"
@@ -157,6 +159,12 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c
157159

158160
models, thinkingConfigured, err := getModelsForAgent(ctx, cfg, &agentConfig, autoModel, runConfig)
159161
if err != nil {
162+
// Return auto model fallback errors and DMR not installed errors directly
163+
// without wrapping to provide cleaner messages
164+
var autoErr *config.ErrAutoModelFallback
165+
if errors.As(err, &autoErr) || errors.Is(err, dmr.ErrNotInstalled) {
166+
return nil, err
167+
}
160168
return nil, fmt.Errorf("failed to get models: %w", err)
161169
}
162170
for _, model := range models {
@@ -238,9 +246,11 @@ func getModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentC
238246

239247
for name := range strings.SplitSeq(a.Model, ",") {
240248
modelCfg, exists := cfg.Models[name]
249+
isAutoModel := false
241250
if !exists {
242251
if name == "auto" {
243252
modelCfg = autoModelFn()
253+
isAutoModel = true
244254
} else {
245255
return nil, false, fmt.Errorf("model '%s' not found in configuration", name)
246256
}
@@ -286,6 +296,10 @@ func getModelsForAgent(ctx context.Context, cfg *latest.Config, a *latest.AgentC
286296
opts...,
287297
)
288298
if err != nil {
299+
// Return a cleaner error message for auto model selection failures
300+
if isAutoModel {
301+
return nil, false, &config.ErrAutoModelFallback{}
302+
}
289303
return nil, false, err
290304
}
291305
models = append(models, model)

pkg/teamloader/teamloader_test.go

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package teamloader
22

33
import (
44
"context"
5+
"errors"
56
"io/fs"
7+
"os"
68
"path/filepath"
9+
"runtime"
710
"testing"
811

912
"github.com/goccy/go-yaml"
@@ -12,6 +15,8 @@ import (
1215

1316
"github.com/docker/cagent/pkg/config"
1417
"github.com/docker/cagent/pkg/config/latest"
18+
"github.com/docker/cagent/pkg/environment"
19+
"github.com/docker/cagent/pkg/model/provider/dmr"
1520
)
1621

1722
// skipExamples contains example files that require cloud-specific configurations
@@ -119,6 +124,11 @@ func TestLoadExamples(t *testing.T) {
119124

120125
// Then make sure the config loads successfully
121126
teams, err := Load(t.Context(), agentSource, runConfig)
127+
if err != nil {
128+
if errors.Is(err, dmr.ErrNotInstalled) && filepath.Base(agentFilename) == "dmr.yaml" {
129+
t.Skip("Skipping DMR example: Docker Model Runner not installed")
130+
}
131+
}
122132
require.NoError(t, err)
123133
assert.NotEmpty(t, teams)
124134
})
@@ -131,7 +141,13 @@ func TestLoadDefaultAgent(t *testing.T) {
131141
agentSource, err := config.Resolve("../../pkg/config/default-agent.yaml")
132142
require.NoError(t, err)
133143

134-
teams, err := Load(t.Context(), agentSource, &config.RuntimeConfig{})
144+
runConfig := &config.RuntimeConfig{
145+
EnvProviderForTests: environment.NewEnvListProvider([]string{
146+
"OPENAI_API_KEY=dummy",
147+
}),
148+
}
149+
150+
teams, err := Load(t.Context(), agentSource, runConfig)
135151
require.NoError(t, err)
136152
require.NotEmpty(t, teams)
137153
}
@@ -199,6 +215,37 @@ func TestToolsetInstructions(t *testing.T) {
199215
require.Equal(t, expected, instructions)
200216
}
201217

218+
func TestAutoModelFallbackError(t *testing.T) {
219+
if runtime.GOOS == "windows" {
220+
t.Skip("Skipping docker CLI shim test on Windows")
221+
}
222+
223+
tempDir := t.TempDir()
224+
dockerPath := filepath.Join(tempDir, "docker")
225+
script := "#!/bin/sh\n" +
226+
"printf 'unknown flag: --json\\n\\nUsage: docker [OPTIONS] COMMAND [ARG...]\\n\\nRun '\\''docker --help'\\'' for more information\\n' >&2\n" +
227+
"exit 1\n"
228+
require.NoError(t, os.WriteFile(dockerPath, []byte(script), 0o755))
229+
230+
t.Setenv("PATH", tempDir+string(os.PathListSeparator)+os.Getenv("PATH"))
231+
t.Setenv("MODEL_RUNNER_HOST", "")
232+
233+
agentSource, err := config.Resolve("testdata/auto-model.yaml")
234+
require.NoError(t, err)
235+
236+
// Use noEnvProvider to ensure no API keys are available,
237+
// so DMR is the only fallback option.
238+
runConfig := &config.RuntimeConfig{
239+
EnvProviderForTests: &noEnvProvider{},
240+
}
241+
242+
_, err = Load(t.Context(), agentSource, runConfig)
243+
require.Error(t, err)
244+
245+
var autoErr *config.ErrAutoModelFallback
246+
require.ErrorAs(t, err, &autoErr, "expected ErrAutoModelFallback when auto model selection fails")
247+
}
248+
202249
func TestIsThinkingBudgetDisabled(t *testing.T) {
203250
t.Parallel()
204251

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
agents:
2+
root:
3+
model: auto
4+
instruction: Test agent with auto model selection

0 commit comments

Comments
 (0)