Skip to content

Commit 96491c4

Browse files
authored
Merge pull request #1552 from krissetto/better-no-provider-err
Show better errors if `dmr` not found or no providers for `model: "auto"`
2 parents 3d9f7b3 + 6ed3f01 commit 96491c4

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)