1package config
2
3import (
4 "io"
5 "log/slog"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "testing"
10
11 "charm.land/catwalk/pkg/catwalk"
12 "github.com/charmbracelet/crush/internal/csync"
13 "github.com/charmbracelet/crush/internal/env"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16)
17
18func TestMain(m *testing.M) {
19 slog.SetDefault(slog.New(slog.NewTextHandler(io.Discard, nil)))
20
21 exitVal := m.Run()
22 os.Exit(exitVal)
23}
24
25func TestConfig_LoadFromBytes(t *testing.T) {
26 data1 := []byte(`{"providers": {"openai": {"api_key": "key1", "base_url": "https://api.openai.com/v1"}}}`)
27 data2 := []byte(`{"providers": {"openai": {"api_key": "key2", "base_url": "https://api.openai.com/v2"}}}`)
28 data3 := []byte(`{"providers": {"openai": {}}}`)
29
30 loadedConfig, err := loadFromBytes([][]byte{data1, data2, data3})
31
32 require.NoError(t, err)
33 require.NotNil(t, loadedConfig)
34 require.Equal(t, 1, loadedConfig.Providers.Len())
35 pc, _ := loadedConfig.Providers.Get("openai")
36 require.Equal(t, "key2", pc.APIKey)
37 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
38}
39
40func TestLoadFromConfigPaths_InvalidJSON(t *testing.T) {
41 t.Parallel()
42
43 t.Run("identifies the offending file", func(t *testing.T) {
44 t.Parallel()
45 tmpDir := t.TempDir()
46 good := filepath.Join(tmpDir, "good.json")
47 bad := filepath.Join(tmpDir, "bad.json")
48 require.NoError(t, os.WriteFile(good, []byte(`{"providers":{}}`), 0o644))
49 require.NoError(t, os.WriteFile(bad, []byte(`{not valid json}`), 0o644))
50
51 _, _, err := loadFromConfigPaths([]string{good, bad})
52 require.Error(t, err)
53 require.Contains(t, err.Error(), "invalid JSON in config file")
54 require.Contains(t, err.Error(), "bad.json")
55 })
56
57 t.Run("skips missing and empty files", func(t *testing.T) {
58 t.Parallel()
59 tmpDir := t.TempDir()
60 empty := filepath.Join(tmpDir, "empty.json")
61 require.NoError(t, os.WriteFile(empty, []byte(""), 0o644))
62
63 cfg, _, err := loadFromConfigPaths([]string{
64 filepath.Join(tmpDir, "nonexistent.json"),
65 empty,
66 })
67 require.NoError(t, err)
68 require.NotNil(t, cfg)
69 })
70}
71
72// testStore wraps a Config in a minimal ConfigStore for testing.
73func testStore(cfg *Config) *ConfigStore {
74 return &ConfigStore{config: cfg}
75}
76
77func TestConfig_setDefaults(t *testing.T) {
78 t.Run("sets default data directory", func(t *testing.T) {
79 cfg := &Config{}
80 workingDir := t.TempDir()
81
82 cfg.setDefaults(workingDir, "")
83
84 require.NotNil(t, cfg.Options)
85 require.NotNil(t, cfg.Options.TUI)
86 require.NotNil(t, cfg.Options.ContextPaths)
87 require.NotNil(t, cfg.Providers)
88 require.NotNil(t, cfg.Models)
89 require.NotNil(t, cfg.LSP)
90 require.NotNil(t, cfg.MCP)
91 require.Equal(t, filepath.Join(workingDir, ".crush"), cfg.Options.DataDirectory)
92 require.Equal(t, "AGENTS.md", cfg.Options.InitializeAs)
93 for _, path := range defaultContextPaths {
94 require.Contains(t, cfg.Options.ContextPaths, path)
95 }
96 })
97
98 t.Run("resolves relative configured data directory from working directory", func(t *testing.T) {
99 cfg := &Config{Options: &Options{DataDirectory: "."}}
100 workingDir := filepath.Join(t.TempDir(), "worktree")
101
102 cfg.setDefaults(workingDir, "")
103
104 require.Equal(t, workingDir, cfg.Options.DataDirectory)
105 })
106
107 t.Run("resolves relative flag data directory from working directory", func(t *testing.T) {
108 cfg := &Config{}
109 workingDir := filepath.Join(t.TempDir(), "worktree")
110
111 cfg.setDefaults(workingDir, "./state")
112
113 require.Equal(t, filepath.Join(workingDir, "state"), cfg.Options.DataDirectory)
114 })
115
116 t.Run("does not adopt .crush from a parent project", func(t *testing.T) {
117 parent := t.TempDir()
118
119 // .crush in the parent: it should not be reused by the child
120 // because there is no git context joining them.
121 require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
122
123 child := filepath.Join(parent, "child")
124 require.NoError(t, os.Mkdir(child, 0o755))
125
126 cfg := &Config{}
127 cfg.setDefaults(child, "")
128
129 require.Equal(t,
130 filepath.Clean(filepath.Join(child, defaultDataDirectory)),
131 filepath.Clean(cfg.Options.DataDirectory),
132 )
133 })
134
135 t.Run("does not climb out of git worktree to find .crush", func(t *testing.T) {
136 if _, err := exec.LookPath("git"); err != nil {
137 t.Skip("git not available")
138 }
139
140 parent := t.TempDir()
141
142 // Stray .crush above the worktree root.
143 require.NoError(t, os.Mkdir(filepath.Join(parent, defaultDataDirectory), 0o755))
144
145 worktree := filepath.Join(parent, "worktree")
146 require.NoError(t, os.Mkdir(worktree, 0o755))
147
148 sub := filepath.Join(worktree, "pkg")
149 require.NoError(t, os.Mkdir(sub, 0o755))
150
151 // Make worktree a real git repo so the boundary detection
152 // resolves to it, mirroring what happens with linked worktrees
153 // in real usage.
154 gitInit := exec.CommandContext(t.Context(), "git", "init", "-q")
155 gitInit.Dir = worktree
156 require.NoError(t, gitInit.Run())
157
158 cfg := &Config{}
159 cfg.setDefaults(sub, "")
160
161 // Resolve symlinks because TempDir on macOS sits under /var
162 // which is a symlink to /private/var. The data directory has
163 // not been created yet, so resolve its parent and join.
164 gotDir, gotName := filepath.Split(cfg.Options.DataDirectory)
165 gotEvalDir, err := filepath.EvalSymlinks(filepath.Clean(gotDir))
166 require.NoError(t, err)
167 gotEval := filepath.Join(gotEvalDir, gotName)
168
169 strayEval, err := filepath.EvalSymlinks(filepath.Join(parent, defaultDataDirectory))
170 require.NoError(t, err)
171 require.NotEqual(t, strayEval, gotEval, "must not adopt parent .crush")
172
173 subEval, err := filepath.EvalSymlinks(sub)
174 require.NoError(t, err)
175 require.Equal(t, filepath.Join(subEval, defaultDataDirectory), gotEval)
176 })
177}
178
179func TestConfig_configureProviders(t *testing.T) {
180 knownProviders := []catwalk.Provider{
181 {
182 ID: "openai",
183 APIKey: "$OPENAI_API_KEY",
184 APIEndpoint: "https://api.openai.com/v1",
185 Models: []catwalk.Model{{
186 ID: "test-model",
187 }},
188 },
189 }
190
191 cfg := &Config{}
192 cfg.setDefaults("/tmp", "")
193 env := env.NewFromMap(map[string]string{
194 "OPENAI_API_KEY": "test-key",
195 })
196 resolver := NewShellVariableResolver(env)
197 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
198 require.NoError(t, err)
199 require.Equal(t, 1, cfg.Providers.Len())
200
201 // We want to make sure that we keep the configured API key as a placeholder
202 pc, _ := cfg.Providers.Get("openai")
203 require.Equal(t, "$OPENAI_API_KEY", pc.APIKey)
204}
205
206func TestConfig_configureProvidersWithOverride(t *testing.T) {
207 knownProviders := []catwalk.Provider{
208 {
209 ID: "openai",
210 APIKey: "$OPENAI_API_KEY",
211 APIEndpoint: "https://api.openai.com/v1",
212 Models: []catwalk.Model{{
213 ID: "test-model",
214 }},
215 },
216 }
217
218 cfg := &Config{
219 Providers: csync.NewMap[string, ProviderConfig](),
220 }
221 cfg.Providers.Set("openai", ProviderConfig{
222 APIKey: "xyz",
223 BaseURL: "https://api.openai.com/v2",
224 Models: []catwalk.Model{
225 {
226 ID: "test-model",
227 Name: "Updated",
228 },
229 {
230 ID: "another-model",
231 },
232 },
233 })
234 cfg.setDefaults("/tmp", "")
235
236 env := env.NewFromMap(map[string]string{
237 "OPENAI_API_KEY": "test-key",
238 })
239 resolver := NewShellVariableResolver(env)
240 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
241 require.NoError(t, err)
242 require.Equal(t, 1, cfg.Providers.Len())
243
244 // We want to make sure that we keep the configured API key as a placeholder
245 pc, _ := cfg.Providers.Get("openai")
246 require.Equal(t, "xyz", pc.APIKey)
247 require.Equal(t, "https://api.openai.com/v2", pc.BaseURL)
248 require.Len(t, pc.Models, 2)
249 require.Equal(t, "Updated", pc.Models[0].Name)
250}
251
252func TestConfig_configureProvidersWithNewProvider(t *testing.T) {
253 knownProviders := []catwalk.Provider{
254 {
255 ID: "openai",
256 APIKey: "$OPENAI_API_KEY",
257 APIEndpoint: "https://api.openai.com/v1",
258 Models: []catwalk.Model{{
259 ID: "test-model",
260 }},
261 },
262 }
263
264 cfg := &Config{
265 Providers: csync.NewMapFrom(map[string]ProviderConfig{
266 "custom": {
267 APIKey: "xyz",
268 BaseURL: "https://api.someendpoint.com/v2",
269 Models: []catwalk.Model{
270 {
271 ID: "test-model",
272 },
273 },
274 },
275 }),
276 }
277 cfg.setDefaults("/tmp", "")
278 env := env.NewFromMap(map[string]string{
279 "OPENAI_API_KEY": "test-key",
280 })
281 resolver := NewShellVariableResolver(env)
282 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
283 require.NoError(t, err)
284 // Should be to because of the env variable
285 require.Equal(t, cfg.Providers.Len(), 2)
286
287 // We want to make sure that we keep the configured API key as a placeholder
288 pc, _ := cfg.Providers.Get("custom")
289 require.Equal(t, "xyz", pc.APIKey)
290 // Make sure we set the ID correctly
291 require.Equal(t, "custom", pc.ID)
292 require.Equal(t, "https://api.someendpoint.com/v2", pc.BaseURL)
293 require.Len(t, pc.Models, 1)
294
295 _, ok := cfg.Providers.Get("openai")
296 require.True(t, ok, "OpenAI provider should still be present")
297}
298
299func TestConfig_configureProvidersBedrockWithCredentials(t *testing.T) {
300 knownProviders := []catwalk.Provider{
301 {
302 ID: catwalk.InferenceProviderBedrock,
303 APIKey: "",
304 APIEndpoint: "",
305 Models: []catwalk.Model{{
306 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
307 }},
308 },
309 }
310
311 cfg := &Config{}
312 cfg.setDefaults("/tmp", "")
313 env := env.NewFromMap(map[string]string{
314 "AWS_ACCESS_KEY_ID": "test-key-id",
315 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
316 })
317 resolver := NewShellVariableResolver(env)
318 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
319 require.NoError(t, err)
320 require.Equal(t, cfg.Providers.Len(), 1)
321
322 bedrockProvider, ok := cfg.Providers.Get("bedrock")
323 require.True(t, ok, "Bedrock provider should be present")
324 require.Len(t, bedrockProvider.Models, 1)
325 require.Equal(t, "anthropic.claude-sonnet-4-20250514-v1:0", bedrockProvider.Models[0].ID)
326}
327
328func TestConfig_configureProvidersBedrockWithoutCredentials(t *testing.T) {
329 knownProviders := []catwalk.Provider{
330 {
331 ID: catwalk.InferenceProviderBedrock,
332 APIKey: "",
333 APIEndpoint: "",
334 Models: []catwalk.Model{{
335 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
336 }},
337 },
338 }
339
340 cfg := &Config{}
341 cfg.setDefaults("/tmp", "")
342 env := env.NewFromMap(map[string]string{})
343 resolver := NewShellVariableResolver(env)
344 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
345 require.NoError(t, err)
346 // Provider should not be configured without credentials
347 require.Equal(t, cfg.Providers.Len(), 0)
348}
349
350func TestConfig_configureProvidersBedrockWithoutUnsupportedModel(t *testing.T) {
351 knownProviders := []catwalk.Provider{
352 {
353 ID: catwalk.InferenceProviderBedrock,
354 APIKey: "",
355 APIEndpoint: "",
356 Models: []catwalk.Model{{
357 ID: "some-random-model",
358 }},
359 },
360 }
361
362 cfg := &Config{}
363 cfg.setDefaults("/tmp", "")
364 env := env.NewFromMap(map[string]string{
365 "AWS_ACCESS_KEY_ID": "test-key-id",
366 "AWS_SECRET_ACCESS_KEY": "test-secret-key",
367 })
368 resolver := NewShellVariableResolver(env)
369 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
370 require.Error(t, err)
371}
372
373func TestConfig_configureProvidersVertexAIWithCredentials(t *testing.T) {
374 knownProviders := []catwalk.Provider{
375 {
376 ID: catwalk.InferenceProviderVertexAI,
377 APIKey: "",
378 APIEndpoint: "",
379 Models: []catwalk.Model{{
380 ID: "gemini-pro",
381 }},
382 },
383 }
384
385 cfg := &Config{}
386 cfg.setDefaults("/tmp", "")
387 env := env.NewFromMap(map[string]string{
388 "VERTEXAI_PROJECT": "test-project",
389 "VERTEXAI_LOCATION": "us-central1",
390 })
391 resolver := NewShellVariableResolver(env)
392 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
393 require.NoError(t, err)
394 require.Equal(t, cfg.Providers.Len(), 1)
395
396 vertexProvider, ok := cfg.Providers.Get("vertexai")
397 require.True(t, ok, "VertexAI provider should be present")
398 require.Len(t, vertexProvider.Models, 1)
399 require.Equal(t, "gemini-pro", vertexProvider.Models[0].ID)
400 require.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
401 require.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
402}
403
404func TestConfig_configureProvidersVertexAIWithoutCredentials(t *testing.T) {
405 knownProviders := []catwalk.Provider{
406 {
407 ID: catwalk.InferenceProviderVertexAI,
408 APIKey: "",
409 APIEndpoint: "",
410 Models: []catwalk.Model{{
411 ID: "gemini-pro",
412 }},
413 },
414 }
415
416 cfg := &Config{}
417 cfg.setDefaults("/tmp", "")
418 env := env.NewFromMap(map[string]string{
419 "GOOGLE_GENAI_USE_VERTEXAI": "false",
420 "GOOGLE_CLOUD_PROJECT": "test-project",
421 "GOOGLE_CLOUD_LOCATION": "us-central1",
422 })
423 resolver := NewShellVariableResolver(env)
424 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
425 require.NoError(t, err)
426 // Provider should not be configured without proper credentials
427 require.Equal(t, cfg.Providers.Len(), 0)
428}
429
430func TestConfig_configureProvidersVertexAIMissingProject(t *testing.T) {
431 knownProviders := []catwalk.Provider{
432 {
433 ID: catwalk.InferenceProviderVertexAI,
434 APIKey: "",
435 APIEndpoint: "",
436 Models: []catwalk.Model{{
437 ID: "gemini-pro",
438 }},
439 },
440 }
441
442 cfg := &Config{}
443 cfg.setDefaults("/tmp", "")
444 env := env.NewFromMap(map[string]string{
445 "GOOGLE_GENAI_USE_VERTEXAI": "true",
446 "GOOGLE_CLOUD_LOCATION": "us-central1",
447 })
448 resolver := NewShellVariableResolver(env)
449 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
450 require.NoError(t, err)
451 // Provider should not be configured without project
452 require.Equal(t, cfg.Providers.Len(), 0)
453}
454
455func TestConfig_configureProvidersSetProviderID(t *testing.T) {
456 knownProviders := []catwalk.Provider{
457 {
458 ID: "openai",
459 APIKey: "$OPENAI_API_KEY",
460 APIEndpoint: "https://api.openai.com/v1",
461 Models: []catwalk.Model{{
462 ID: "test-model",
463 }},
464 },
465 }
466
467 cfg := &Config{}
468 cfg.setDefaults("/tmp", "")
469 env := env.NewFromMap(map[string]string{
470 "OPENAI_API_KEY": "test-key",
471 })
472 resolver := NewShellVariableResolver(env)
473 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
474 require.NoError(t, err)
475 require.Equal(t, cfg.Providers.Len(), 1)
476
477 // Provider ID should be set
478 pc, _ := cfg.Providers.Get("openai")
479 require.Equal(t, "openai", pc.ID)
480}
481
482func TestConfig_EnabledProviders(t *testing.T) {
483 t.Run("all providers enabled", func(t *testing.T) {
484 cfg := &Config{
485 Providers: csync.NewMapFrom(map[string]ProviderConfig{
486 "openai": {
487 ID: "openai",
488 APIKey: "key1",
489 Disable: false,
490 },
491 "anthropic": {
492 ID: "anthropic",
493 APIKey: "key2",
494 Disable: false,
495 },
496 }),
497 }
498
499 enabled := cfg.EnabledProviders()
500 require.Len(t, enabled, 2)
501 })
502
503 t.Run("some providers disabled", func(t *testing.T) {
504 cfg := &Config{
505 Providers: csync.NewMapFrom(map[string]ProviderConfig{
506 "openai": {
507 ID: "openai",
508 APIKey: "key1",
509 Disable: false,
510 },
511 "anthropic": {
512 ID: "anthropic",
513 APIKey: "key2",
514 Disable: true,
515 },
516 }),
517 }
518
519 enabled := cfg.EnabledProviders()
520 require.Len(t, enabled, 1)
521 require.Equal(t, "openai", enabled[0].ID)
522 })
523
524 t.Run("empty providers map", func(t *testing.T) {
525 cfg := &Config{
526 Providers: csync.NewMap[string, ProviderConfig](),
527 }
528
529 enabled := cfg.EnabledProviders()
530 require.Len(t, enabled, 0)
531 })
532}
533
534func TestConfig_IsConfigured(t *testing.T) {
535 t.Run("returns true when at least one provider is enabled", func(t *testing.T) {
536 cfg := &Config{
537 Providers: csync.NewMapFrom(map[string]ProviderConfig{
538 "openai": {
539 ID: "openai",
540 APIKey: "key1",
541 Disable: false,
542 },
543 }),
544 }
545
546 require.True(t, cfg.IsConfigured())
547 })
548
549 t.Run("returns false when no providers are configured", func(t *testing.T) {
550 cfg := &Config{
551 Providers: csync.NewMap[string, ProviderConfig](),
552 }
553
554 require.False(t, cfg.IsConfigured())
555 })
556
557 t.Run("returns false when all providers are disabled", func(t *testing.T) {
558 cfg := &Config{
559 Providers: csync.NewMapFrom(map[string]ProviderConfig{
560 "openai": {
561 ID: "openai",
562 APIKey: "key1",
563 Disable: true,
564 },
565 "anthropic": {
566 ID: "anthropic",
567 APIKey: "key2",
568 Disable: true,
569 },
570 }),
571 }
572
573 require.False(t, cfg.IsConfigured())
574 })
575}
576
577func TestConfig_setupAgentsWithNoDisabledTools(t *testing.T) {
578 cfg := &Config{
579 Options: &Options{
580 DisabledTools: []string{},
581 },
582 }
583
584 cfg.SetupAgents()
585 coderAgent, ok := cfg.Agents[AgentCoder]
586 require.True(t, ok)
587 assert.Equal(t, allToolNames(), coderAgent.AllowedTools)
588
589 taskAgent, ok := cfg.Agents[AgentTask]
590 require.True(t, ok)
591 assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
592}
593
594func TestConfig_setupAgentsWithDisabledTools(t *testing.T) {
595 cfg := &Config{
596 Options: &Options{
597 DisabledTools: []string{
598 "edit",
599 "download",
600 "grep",
601 },
602 },
603 }
604
605 cfg.SetupAgents()
606 coderAgent, ok := cfg.Agents[AgentCoder]
607 require.True(t, ok)
608
609 assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "glob", "ls", "sourcegraph", "todos", "view", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
610
611 taskAgent, ok := cfg.Agents[AgentTask]
612 require.True(t, ok)
613 assert.Equal(t, []string{"glob", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
614}
615
616func TestConfig_setupAgentsWithEveryReadOnlyToolDisabled(t *testing.T) {
617 cfg := &Config{
618 Options: &Options{
619 DisabledTools: []string{
620 "glob",
621 "grep",
622 "ls",
623 "sourcegraph",
624 "view",
625 },
626 },
627 }
628
629 cfg.SetupAgents()
630 coderAgent, ok := cfg.Agents[AgentCoder]
631 require.True(t, ok)
632 assert.Equal(t, []string{"agent", "bash", "crush_info", "crush_logs", "job_output", "job_kill", "download", "edit", "multiedit", "lsp_diagnostics", "lsp_references", "lsp_restart", "fetch", "agentic_fetch", "todos", "write", "list_mcp_resources", "read_mcp_resource"}, coderAgent.AllowedTools)
633
634 taskAgent, ok := cfg.Agents[AgentTask]
635 require.True(t, ok)
636 assert.Len(t, taskAgent.AllowedTools, 0)
637}
638
639func TestConfig_configureProvidersWithDisabledProvider(t *testing.T) {
640 knownProviders := []catwalk.Provider{
641 {
642 ID: "openai",
643 APIKey: "$OPENAI_API_KEY",
644 APIEndpoint: "https://api.openai.com/v1",
645 Models: []catwalk.Model{{
646 ID: "test-model",
647 }},
648 },
649 }
650
651 cfg := &Config{
652 Providers: csync.NewMapFrom(map[string]ProviderConfig{
653 "openai": {
654 Disable: true,
655 },
656 }),
657 }
658 cfg.setDefaults("/tmp", "")
659
660 env := env.NewFromMap(map[string]string{
661 "OPENAI_API_KEY": "test-key",
662 })
663 resolver := NewShellVariableResolver(env)
664 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
665 require.NoError(t, err)
666
667 require.Equal(t, cfg.Providers.Len(), 1)
668 prov, exists := cfg.Providers.Get("openai")
669 require.True(t, exists)
670 require.True(t, prov.Disable)
671}
672
673func TestConfig_configureProvidersCustomProviderValidation(t *testing.T) {
674 t.Run("custom provider with missing API key is allowed, but not known providers", func(t *testing.T) {
675 cfg := &Config{
676 Providers: csync.NewMapFrom(map[string]ProviderConfig{
677 "custom": {
678 BaseURL: "https://api.custom.com/v1",
679 Models: []catwalk.Model{{
680 ID: "test-model",
681 }},
682 },
683 "openai": {
684 APIKey: "$MISSING",
685 },
686 }),
687 }
688 cfg.setDefaults("/tmp", "")
689
690 env := env.NewFromMap(map[string]string{})
691 resolver := NewShellVariableResolver(env)
692 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
693 require.NoError(t, err)
694
695 require.Equal(t, cfg.Providers.Len(), 1)
696 _, exists := cfg.Providers.Get("custom")
697 require.True(t, exists)
698 })
699
700 t.Run("custom provider with missing BaseURL is removed", func(t *testing.T) {
701 cfg := &Config{
702 Providers: csync.NewMapFrom(map[string]ProviderConfig{
703 "custom": {
704 APIKey: "test-key",
705 Models: []catwalk.Model{{
706 ID: "test-model",
707 }},
708 },
709 }),
710 }
711 cfg.setDefaults("/tmp", "")
712
713 env := env.NewFromMap(map[string]string{})
714 resolver := NewShellVariableResolver(env)
715 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
716 require.NoError(t, err)
717
718 require.Equal(t, cfg.Providers.Len(), 0)
719 _, exists := cfg.Providers.Get("custom")
720 require.False(t, exists)
721 })
722
723 t.Run("custom provider with no models is removed", func(t *testing.T) {
724 cfg := &Config{
725 Providers: csync.NewMapFrom(map[string]ProviderConfig{
726 "custom": {
727 APIKey: "test-key",
728 BaseURL: "https://api.custom.com/v1",
729 Models: []catwalk.Model{},
730 },
731 }),
732 }
733 cfg.setDefaults("/tmp", "")
734
735 env := env.NewFromMap(map[string]string{})
736 resolver := NewShellVariableResolver(env)
737 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
738 require.NoError(t, err)
739
740 require.Equal(t, cfg.Providers.Len(), 0)
741 _, exists := cfg.Providers.Get("custom")
742 require.False(t, exists)
743 })
744
745 t.Run("custom provider with unsupported type is removed", func(t *testing.T) {
746 cfg := &Config{
747 Providers: csync.NewMapFrom(map[string]ProviderConfig{
748 "custom": {
749 APIKey: "test-key",
750 BaseURL: "https://api.custom.com/v1",
751 Type: "unsupported",
752 Models: []catwalk.Model{{
753 ID: "test-model",
754 }},
755 },
756 }),
757 }
758 cfg.setDefaults("/tmp", "")
759
760 env := env.NewFromMap(map[string]string{})
761 resolver := NewShellVariableResolver(env)
762 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
763 require.NoError(t, err)
764
765 require.Equal(t, cfg.Providers.Len(), 0)
766 _, exists := cfg.Providers.Get("custom")
767 require.False(t, exists)
768 })
769
770 t.Run("valid custom provider is kept and ID is set", func(t *testing.T) {
771 cfg := &Config{
772 Providers: csync.NewMapFrom(map[string]ProviderConfig{
773 "custom": {
774 APIKey: "test-key",
775 BaseURL: "https://api.custom.com/v1",
776 Type: catwalk.TypeOpenAI,
777 Models: []catwalk.Model{{
778 ID: "test-model",
779 }},
780 },
781 }),
782 }
783 cfg.setDefaults("/tmp", "")
784
785 env := env.NewFromMap(map[string]string{})
786 resolver := NewShellVariableResolver(env)
787 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
788 require.NoError(t, err)
789
790 require.Equal(t, cfg.Providers.Len(), 1)
791 customProvider, exists := cfg.Providers.Get("custom")
792 require.True(t, exists)
793 require.Equal(t, "custom", customProvider.ID)
794 require.Equal(t, "test-key", customProvider.APIKey)
795 require.Equal(t, "https://api.custom.com/v1", customProvider.BaseURL)
796 })
797
798 t.Run("custom anthropic provider is supported", func(t *testing.T) {
799 cfg := &Config{
800 Providers: csync.NewMapFrom(map[string]ProviderConfig{
801 "custom-anthropic": {
802 APIKey: "test-key",
803 BaseURL: "https://api.anthropic.com/v1",
804 Type: catwalk.TypeAnthropic,
805 Models: []catwalk.Model{{
806 ID: "claude-3-sonnet",
807 }},
808 },
809 }),
810 }
811 cfg.setDefaults("/tmp", "")
812
813 env := env.NewFromMap(map[string]string{})
814 resolver := NewShellVariableResolver(env)
815 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
816 require.NoError(t, err)
817
818 require.Equal(t, cfg.Providers.Len(), 1)
819 customProvider, exists := cfg.Providers.Get("custom-anthropic")
820 require.True(t, exists)
821 require.Equal(t, "custom-anthropic", customProvider.ID)
822 require.Equal(t, "test-key", customProvider.APIKey)
823 require.Equal(t, "https://api.anthropic.com/v1", customProvider.BaseURL)
824 require.Equal(t, catwalk.TypeAnthropic, customProvider.Type)
825 })
826
827 t.Run("disabled custom provider is removed", func(t *testing.T) {
828 cfg := &Config{
829 Providers: csync.NewMapFrom(map[string]ProviderConfig{
830 "custom": {
831 APIKey: "test-key",
832 BaseURL: "https://api.custom.com/v1",
833 Type: catwalk.TypeOpenAI,
834 Disable: true,
835 Models: []catwalk.Model{{
836 ID: "test-model",
837 }},
838 },
839 }),
840 }
841 cfg.setDefaults("/tmp", "")
842
843 env := env.NewFromMap(map[string]string{})
844 resolver := NewShellVariableResolver(env)
845 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
846 require.NoError(t, err)
847
848 require.Equal(t, cfg.Providers.Len(), 0)
849 _, exists := cfg.Providers.Get("custom")
850 require.False(t, exists)
851 })
852}
853
854func TestConfig_configureProvidersEnhancedCredentialValidation(t *testing.T) {
855 t.Run("VertexAI provider removed when credentials missing with existing config", func(t *testing.T) {
856 knownProviders := []catwalk.Provider{
857 {
858 ID: catwalk.InferenceProviderVertexAI,
859 APIKey: "",
860 APIEndpoint: "",
861 Models: []catwalk.Model{{
862 ID: "gemini-pro",
863 }},
864 },
865 }
866
867 cfg := &Config{
868 Providers: csync.NewMapFrom(map[string]ProviderConfig{
869 "vertexai": {
870 BaseURL: "custom-url",
871 },
872 }),
873 }
874 cfg.setDefaults("/tmp", "")
875
876 env := env.NewFromMap(map[string]string{
877 "GOOGLE_GENAI_USE_VERTEXAI": "false",
878 })
879 resolver := NewShellVariableResolver(env)
880 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
881 require.NoError(t, err)
882
883 require.Equal(t, cfg.Providers.Len(), 0)
884 _, exists := cfg.Providers.Get("vertexai")
885 require.False(t, exists)
886 })
887
888 t.Run("Bedrock provider removed when AWS credentials missing with existing config", func(t *testing.T) {
889 knownProviders := []catwalk.Provider{
890 {
891 ID: catwalk.InferenceProviderBedrock,
892 APIKey: "",
893 APIEndpoint: "",
894 Models: []catwalk.Model{{
895 ID: "anthropic.claude-sonnet-4-20250514-v1:0",
896 }},
897 },
898 }
899
900 cfg := &Config{
901 Providers: csync.NewMapFrom(map[string]ProviderConfig{
902 "bedrock": {
903 BaseURL: "custom-url",
904 },
905 }),
906 }
907 cfg.setDefaults("/tmp", "")
908
909 env := env.NewFromMap(map[string]string{})
910 resolver := NewShellVariableResolver(env)
911 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
912 require.NoError(t, err)
913
914 require.Equal(t, cfg.Providers.Len(), 0)
915 _, exists := cfg.Providers.Get("bedrock")
916 require.False(t, exists)
917 })
918
919 t.Run("provider removed when API key missing with existing config", func(t *testing.T) {
920 knownProviders := []catwalk.Provider{
921 {
922 ID: "openai",
923 APIKey: "$MISSING_API_KEY",
924 APIEndpoint: "https://api.openai.com/v1",
925 Models: []catwalk.Model{{
926 ID: "test-model",
927 }},
928 },
929 }
930
931 cfg := &Config{
932 Providers: csync.NewMapFrom(map[string]ProviderConfig{
933 "openai": {
934 BaseURL: "custom-url",
935 },
936 }),
937 }
938 cfg.setDefaults("/tmp", "")
939
940 env := env.NewFromMap(map[string]string{})
941 resolver := NewShellVariableResolver(env)
942 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
943 require.NoError(t, err)
944
945 require.Equal(t, cfg.Providers.Len(), 0)
946 _, exists := cfg.Providers.Get("openai")
947 require.False(t, exists)
948 })
949
950 t.Run("known provider should still be added if the endpoint is missing the client will use default endpoints", func(t *testing.T) {
951 knownProviders := []catwalk.Provider{
952 {
953 ID: "openai",
954 APIKey: "$OPENAI_API_KEY",
955 APIEndpoint: "$MISSING_ENDPOINT",
956 Models: []catwalk.Model{{
957 ID: "test-model",
958 }},
959 },
960 }
961
962 cfg := &Config{
963 Providers: csync.NewMapFrom(map[string]ProviderConfig{
964 "openai": {
965 APIKey: "test-key",
966 },
967 }),
968 }
969 cfg.setDefaults("/tmp", "")
970
971 env := env.NewFromMap(map[string]string{
972 "OPENAI_API_KEY": "test-key",
973 })
974 resolver := NewShellVariableResolver(env)
975 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
976 require.NoError(t, err)
977
978 require.Equal(t, cfg.Providers.Len(), 1)
979 _, exists := cfg.Providers.Get("openai")
980 require.True(t, exists)
981 })
982}
983
984func TestConfig_defaultModelSelection(t *testing.T) {
985 t.Run("default behavior uses the default models for given provider", func(t *testing.T) {
986 knownProviders := []catwalk.Provider{
987 {
988 ID: "openai",
989 APIKey: "abc",
990 DefaultLargeModelID: "large-model",
991 DefaultSmallModelID: "small-model",
992 Models: []catwalk.Model{
993 {
994 ID: "large-model",
995 DefaultMaxTokens: 1000,
996 },
997 {
998 ID: "small-model",
999 DefaultMaxTokens: 500,
1000 },
1001 },
1002 },
1003 }
1004
1005 cfg := &Config{}
1006 cfg.setDefaults("/tmp", "")
1007 env := env.NewFromMap(map[string]string{})
1008 resolver := NewShellVariableResolver(env)
1009 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1010 require.NoError(t, err)
1011
1012 large, small, err := cfg.defaultModelSelection(knownProviders)
1013 require.NoError(t, err)
1014 require.Equal(t, "large-model", large.Model)
1015 require.Equal(t, "openai", large.Provider)
1016 require.Equal(t, int64(1000), large.MaxTokens)
1017 require.Equal(t, "small-model", small.Model)
1018 require.Equal(t, "openai", small.Provider)
1019 require.Equal(t, int64(500), small.MaxTokens)
1020 })
1021 t.Run("should error if no providers configured", func(t *testing.T) {
1022 knownProviders := []catwalk.Provider{
1023 {
1024 ID: "openai",
1025 APIKey: "$MISSING_KEY",
1026 DefaultLargeModelID: "large-model",
1027 DefaultSmallModelID: "small-model",
1028 Models: []catwalk.Model{
1029 {
1030 ID: "large-model",
1031 DefaultMaxTokens: 1000,
1032 },
1033 {
1034 ID: "small-model",
1035 DefaultMaxTokens: 500,
1036 },
1037 },
1038 },
1039 }
1040
1041 cfg := &Config{}
1042 cfg.setDefaults("/tmp", "")
1043 env := env.NewFromMap(map[string]string{})
1044 resolver := NewShellVariableResolver(env)
1045 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1046 require.NoError(t, err)
1047
1048 _, _, err = cfg.defaultModelSelection(knownProviders)
1049 require.Error(t, err)
1050 })
1051 t.Run("should error if model is missing", func(t *testing.T) {
1052 knownProviders := []catwalk.Provider{
1053 {
1054 ID: "openai",
1055 APIKey: "abc",
1056 DefaultLargeModelID: "large-model",
1057 DefaultSmallModelID: "small-model",
1058 Models: []catwalk.Model{
1059 {
1060 ID: "not-large-model",
1061 DefaultMaxTokens: 1000,
1062 },
1063 {
1064 ID: "small-model",
1065 DefaultMaxTokens: 500,
1066 },
1067 },
1068 },
1069 }
1070
1071 cfg := &Config{}
1072 cfg.setDefaults("/tmp", "")
1073 env := env.NewFromMap(map[string]string{})
1074 resolver := NewShellVariableResolver(env)
1075 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1076 require.NoError(t, err)
1077 _, _, err = cfg.defaultModelSelection(knownProviders)
1078 require.Error(t, err)
1079 })
1080
1081 t.Run("should configure the default models with a custom provider", func(t *testing.T) {
1082 knownProviders := []catwalk.Provider{
1083 {
1084 ID: "openai",
1085 APIKey: "$MISSING", // will not be included in the config
1086 DefaultLargeModelID: "large-model",
1087 DefaultSmallModelID: "small-model",
1088 Models: []catwalk.Model{
1089 {
1090 ID: "not-large-model",
1091 DefaultMaxTokens: 1000,
1092 },
1093 {
1094 ID: "small-model",
1095 DefaultMaxTokens: 500,
1096 },
1097 },
1098 },
1099 }
1100
1101 cfg := &Config{
1102 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1103 "custom": {
1104 APIKey: "test-key",
1105 BaseURL: "https://api.custom.com/v1",
1106 Models: []catwalk.Model{
1107 {
1108 ID: "model",
1109 DefaultMaxTokens: 600,
1110 },
1111 },
1112 },
1113 }),
1114 }
1115 cfg.setDefaults("/tmp", "")
1116 env := env.NewFromMap(map[string]string{})
1117 resolver := NewShellVariableResolver(env)
1118 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1119 require.NoError(t, err)
1120 large, small, err := cfg.defaultModelSelection(knownProviders)
1121 require.NoError(t, err)
1122 require.Equal(t, "model", large.Model)
1123 require.Equal(t, "custom", large.Provider)
1124 require.Equal(t, int64(600), large.MaxTokens)
1125 require.Equal(t, "model", small.Model)
1126 require.Equal(t, "custom", small.Provider)
1127 require.Equal(t, int64(600), small.MaxTokens)
1128 })
1129
1130 t.Run("should fail if no model configured", func(t *testing.T) {
1131 knownProviders := []catwalk.Provider{
1132 {
1133 ID: "openai",
1134 APIKey: "$MISSING", // will not be included in the config
1135 DefaultLargeModelID: "large-model",
1136 DefaultSmallModelID: "small-model",
1137 Models: []catwalk.Model{
1138 {
1139 ID: "not-large-model",
1140 DefaultMaxTokens: 1000,
1141 },
1142 {
1143 ID: "small-model",
1144 DefaultMaxTokens: 500,
1145 },
1146 },
1147 },
1148 }
1149
1150 cfg := &Config{
1151 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1152 "custom": {
1153 APIKey: "test-key",
1154 BaseURL: "https://api.custom.com/v1",
1155 Models: []catwalk.Model{},
1156 },
1157 }),
1158 }
1159 cfg.setDefaults("/tmp", "")
1160 env := env.NewFromMap(map[string]string{})
1161 resolver := NewShellVariableResolver(env)
1162 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1163 require.NoError(t, err)
1164 _, _, err = cfg.defaultModelSelection(knownProviders)
1165 require.Error(t, err)
1166 })
1167 t.Run("should use the default provider first", func(t *testing.T) {
1168 knownProviders := []catwalk.Provider{
1169 {
1170 ID: "openai",
1171 APIKey: "set",
1172 DefaultLargeModelID: "large-model",
1173 DefaultSmallModelID: "small-model",
1174 Models: []catwalk.Model{
1175 {
1176 ID: "large-model",
1177 DefaultMaxTokens: 1000,
1178 },
1179 {
1180 ID: "small-model",
1181 DefaultMaxTokens: 500,
1182 },
1183 },
1184 },
1185 }
1186
1187 cfg := &Config{
1188 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1189 "custom": {
1190 APIKey: "test-key",
1191 BaseURL: "https://api.custom.com/v1",
1192 Models: []catwalk.Model{
1193 {
1194 ID: "large-model",
1195 DefaultMaxTokens: 1000,
1196 },
1197 },
1198 },
1199 }),
1200 }
1201 cfg.setDefaults("/tmp", "")
1202 env := env.NewFromMap(map[string]string{})
1203 resolver := NewShellVariableResolver(env)
1204 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1205 require.NoError(t, err)
1206 large, small, err := cfg.defaultModelSelection(knownProviders)
1207 require.NoError(t, err)
1208 require.Equal(t, "large-model", large.Model)
1209 require.Equal(t, "openai", large.Provider)
1210 require.Equal(t, int64(1000), large.MaxTokens)
1211 require.Equal(t, "small-model", small.Model)
1212 require.Equal(t, "openai", small.Provider)
1213 require.Equal(t, int64(500), small.MaxTokens)
1214 })
1215}
1216
1217func TestConfig_configureProvidersDisableDefaultProviders(t *testing.T) {
1218 t.Run("when enabled, ignores all default providers and requires full specification", func(t *testing.T) {
1219 knownProviders := []catwalk.Provider{
1220 {
1221 ID: "openai",
1222 APIKey: "$OPENAI_API_KEY",
1223 APIEndpoint: "https://api.openai.com/v1",
1224 Models: []catwalk.Model{{
1225 ID: "gpt-4",
1226 }},
1227 },
1228 }
1229
1230 // User references openai but doesn't fully specify it (no base_url, no
1231 // models). This should be rejected because disable_default_providers
1232 // treats all providers as custom.
1233 cfg := &Config{
1234 Options: &Options{
1235 DisableDefaultProviders: true,
1236 },
1237 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1238 "openai": {
1239 APIKey: "$OPENAI_API_KEY",
1240 },
1241 }),
1242 }
1243 cfg.setDefaults("/tmp", "")
1244
1245 env := env.NewFromMap(map[string]string{
1246 "OPENAI_API_KEY": "test-key",
1247 })
1248 resolver := NewShellVariableResolver(env)
1249 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1250 require.ErrorContains(t, err, "no custom providers")
1251
1252 // openai should NOT be present because it lacks base_url and models.
1253 require.Equal(t, 0, cfg.Providers.Len())
1254 _, exists := cfg.Providers.Get("openai")
1255 require.False(t, exists, "openai should not be present without full specification")
1256 })
1257
1258 t.Run("when enabled, fully specified providers work", func(t *testing.T) {
1259 knownProviders := []catwalk.Provider{
1260 {
1261 ID: "openai",
1262 APIKey: "$OPENAI_API_KEY",
1263 APIEndpoint: "https://api.openai.com/v1",
1264 Models: []catwalk.Model{{
1265 ID: "gpt-4",
1266 }},
1267 },
1268 }
1269
1270 // User fully specifies their provider.
1271 cfg := &Config{
1272 Options: &Options{
1273 DisableDefaultProviders: true,
1274 },
1275 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1276 "my-llm": {
1277 APIKey: "$MY_API_KEY",
1278 BaseURL: "https://my-llm.example.com/v1",
1279 Models: []catwalk.Model{{
1280 ID: "my-model",
1281 }},
1282 },
1283 }),
1284 }
1285 cfg.setDefaults("/tmp", "")
1286
1287 env := env.NewFromMap(map[string]string{
1288 "MY_API_KEY": "test-key",
1289 "OPENAI_API_KEY": "test-key",
1290 })
1291 resolver := NewShellVariableResolver(env)
1292 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1293 require.NoError(t, err)
1294
1295 // Only fully specified provider should be present.
1296 require.Equal(t, 1, cfg.Providers.Len())
1297 provider, exists := cfg.Providers.Get("my-llm")
1298 require.True(t, exists, "my-llm should be present")
1299 require.Equal(t, "https://my-llm.example.com/v1", provider.BaseURL)
1300 require.Len(t, provider.Models, 1)
1301
1302 // Default openai should NOT be present.
1303 _, exists = cfg.Providers.Get("openai")
1304 require.False(t, exists, "openai should not be present")
1305 })
1306
1307 t.Run("when disabled, includes all known providers with valid credentials", func(t *testing.T) {
1308 knownProviders := []catwalk.Provider{
1309 {
1310 ID: "openai",
1311 APIKey: "$OPENAI_API_KEY",
1312 APIEndpoint: "https://api.openai.com/v1",
1313 Models: []catwalk.Model{{
1314 ID: "gpt-4",
1315 }},
1316 },
1317 {
1318 ID: "anthropic",
1319 APIKey: "$ANTHROPIC_API_KEY",
1320 APIEndpoint: "https://api.anthropic.com/v1",
1321 Models: []catwalk.Model{{
1322 ID: "claude-3",
1323 }},
1324 },
1325 }
1326
1327 // User only configures openai, both API keys are available, but option
1328 // is disabled.
1329 cfg := &Config{
1330 Options: &Options{
1331 DisableDefaultProviders: false,
1332 },
1333 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1334 "openai": {
1335 APIKey: "$OPENAI_API_KEY",
1336 },
1337 }),
1338 }
1339 cfg.setDefaults("/tmp", "")
1340
1341 env := env.NewFromMap(map[string]string{
1342 "OPENAI_API_KEY": "test-key",
1343 "ANTHROPIC_API_KEY": "test-key",
1344 })
1345 resolver := NewShellVariableResolver(env)
1346 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1347 require.NoError(t, err)
1348
1349 // Both providers should be present.
1350 require.Equal(t, 2, cfg.Providers.Len())
1351 _, exists := cfg.Providers.Get("openai")
1352 require.True(t, exists, "openai should be present")
1353 _, exists = cfg.Providers.Get("anthropic")
1354 require.True(t, exists, "anthropic should be present")
1355 })
1356
1357 t.Run("when enabled, provider missing models is rejected", func(t *testing.T) {
1358 cfg := &Config{
1359 Options: &Options{
1360 DisableDefaultProviders: true,
1361 },
1362 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1363 "my-llm": {
1364 APIKey: "test-key",
1365 BaseURL: "https://my-llm.example.com/v1",
1366 Models: []catwalk.Model{}, // No models.
1367 },
1368 }),
1369 }
1370 cfg.setDefaults("/tmp", "")
1371
1372 env := env.NewFromMap(map[string]string{})
1373 resolver := NewShellVariableResolver(env)
1374 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1375 require.ErrorContains(t, err, "no custom providers")
1376
1377 // Provider should be rejected for missing models.
1378 require.Equal(t, 0, cfg.Providers.Len())
1379 })
1380
1381 t.Run("when enabled, provider missing base_url is rejected", func(t *testing.T) {
1382 cfg := &Config{
1383 Options: &Options{
1384 DisableDefaultProviders: true,
1385 },
1386 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1387 "my-llm": {
1388 APIKey: "test-key",
1389 Models: []catwalk.Model{{ID: "model"}},
1390 // No BaseURL.
1391 },
1392 }),
1393 }
1394 cfg.setDefaults("/tmp", "")
1395
1396 env := env.NewFromMap(map[string]string{})
1397 resolver := NewShellVariableResolver(env)
1398 err := cfg.configureProviders(testStore(cfg), env, resolver, []catwalk.Provider{})
1399 require.ErrorContains(t, err, "no custom providers")
1400
1401 // Provider should be rejected for missing base_url.
1402 require.Equal(t, 0, cfg.Providers.Len())
1403 })
1404}
1405
1406func TestConfig_setDefaultsDisableDefaultProvidersEnvVar(t *testing.T) {
1407 t.Run("sets option from environment variable", func(t *testing.T) {
1408 t.Setenv("CRUSH_DISABLE_DEFAULT_PROVIDERS", "true")
1409
1410 cfg := &Config{}
1411 cfg.setDefaults("/tmp", "")
1412
1413 require.True(t, cfg.Options.DisableDefaultProviders)
1414 })
1415
1416 t.Run("does not override when env var is not set", func(t *testing.T) {
1417 cfg := &Config{
1418 Options: &Options{
1419 DisableDefaultProviders: true,
1420 },
1421 }
1422 cfg.setDefaults("/tmp", "")
1423
1424 require.True(t, cfg.Options.DisableDefaultProviders)
1425 })
1426}
1427
1428func TestConfig_configureSelectedModels(t *testing.T) {
1429 t.Run("reload mode should not persist fallback defaults", func(t *testing.T) {
1430 dir := t.TempDir()
1431 globalPath := filepath.Join(dir, "crush.json")
1432 require.NoError(t, os.WriteFile(globalPath, []byte(`{"models":{"large":{"provider":"ghost","model":"missing"}}}`), 0o600))
1433
1434 knownProviders := []catwalk.Provider{
1435 {
1436 ID: "openai",
1437 APIKey: "abc",
1438 DefaultLargeModelID: "large-model",
1439 DefaultSmallModelID: "small-model",
1440 Models: []catwalk.Model{
1441 {ID: "large-model", DefaultMaxTokens: 1000},
1442 {ID: "small-model", DefaultMaxTokens: 500},
1443 },
1444 },
1445 }
1446
1447 cfg := &Config{
1448 Models: map[SelectedModelType]SelectedModel{
1449 SelectedModelTypeLarge: {Provider: "ghost", Model: "missing"},
1450 },
1451 }
1452 cfg.setDefaults(dir, "")
1453 store := &ConfigStore{config: cfg, globalDataPath: globalPath}
1454 env := env.NewFromMap(map[string]string{})
1455 resolver := NewShellVariableResolver(env)
1456 err := cfg.configureProviders(store, env, resolver, knownProviders)
1457 require.NoError(t, err)
1458
1459 err = configureSelectedModels(store, knownProviders, false)
1460 require.NoError(t, err)
1461
1462 // In-memory falls back to default.
1463 require.Equal(t, "openai", cfg.Models[SelectedModelTypeLarge].Provider)
1464 require.Equal(t, "large-model", cfg.Models[SelectedModelTypeLarge].Model)
1465
1466 // Disk remains unchanged in reload mode.
1467 data, readErr := os.ReadFile(globalPath)
1468 require.NoError(t, readErr)
1469 require.Contains(t, string(data), `"provider":"ghost"`)
1470 require.Contains(t, string(data), `"model":"missing"`)
1471 })
1472 t.Run("should override defaults", func(t *testing.T) {
1473 knownProviders := []catwalk.Provider{
1474 {
1475 ID: "openai",
1476 APIKey: "abc",
1477 DefaultLargeModelID: "large-model",
1478 DefaultSmallModelID: "small-model",
1479 Models: []catwalk.Model{
1480 {
1481 ID: "larger-model",
1482 DefaultMaxTokens: 2000,
1483 },
1484 {
1485 ID: "large-model",
1486 DefaultMaxTokens: 1000,
1487 },
1488 {
1489 ID: "small-model",
1490 DefaultMaxTokens: 500,
1491 },
1492 },
1493 },
1494 }
1495
1496 cfg := &Config{
1497 Models: map[SelectedModelType]SelectedModel{
1498 "large": {
1499 Model: "larger-model",
1500 },
1501 },
1502 }
1503 cfg.setDefaults("/tmp", "")
1504 env := env.NewFromMap(map[string]string{})
1505 resolver := NewShellVariableResolver(env)
1506 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1507 require.NoError(t, err)
1508
1509 err = configureSelectedModels(testStore(cfg), knownProviders, true)
1510 require.NoError(t, err)
1511 large := cfg.Models[SelectedModelTypeLarge]
1512 small := cfg.Models[SelectedModelTypeSmall]
1513 require.Equal(t, "larger-model", large.Model)
1514 require.Equal(t, "openai", large.Provider)
1515 require.Equal(t, int64(2000), large.MaxTokens)
1516 require.Equal(t, "small-model", small.Model)
1517 require.Equal(t, "openai", small.Provider)
1518 require.Equal(t, int64(500), small.MaxTokens)
1519 })
1520 t.Run("should be possible to use multiple providers", func(t *testing.T) {
1521 knownProviders := []catwalk.Provider{
1522 {
1523 ID: "openai",
1524 APIKey: "abc",
1525 DefaultLargeModelID: "large-model",
1526 DefaultSmallModelID: "small-model",
1527 Models: []catwalk.Model{
1528 {
1529 ID: "large-model",
1530 DefaultMaxTokens: 1000,
1531 },
1532 {
1533 ID: "small-model",
1534 DefaultMaxTokens: 500,
1535 },
1536 },
1537 },
1538 {
1539 ID: "anthropic",
1540 APIKey: "abc",
1541 DefaultLargeModelID: "a-large-model",
1542 DefaultSmallModelID: "a-small-model",
1543 Models: []catwalk.Model{
1544 {
1545 ID: "a-large-model",
1546 DefaultMaxTokens: 1000,
1547 },
1548 {
1549 ID: "a-small-model",
1550 DefaultMaxTokens: 200,
1551 },
1552 },
1553 },
1554 }
1555
1556 cfg := &Config{
1557 Models: map[SelectedModelType]SelectedModel{
1558 "small": {
1559 Model: "a-small-model",
1560 Provider: "anthropic",
1561 MaxTokens: 300,
1562 },
1563 },
1564 }
1565 cfg.setDefaults("/tmp", "")
1566 env := env.NewFromMap(map[string]string{})
1567 resolver := NewShellVariableResolver(env)
1568 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1569 require.NoError(t, err)
1570
1571 err = configureSelectedModels(testStore(cfg), knownProviders, true)
1572 require.NoError(t, err)
1573 large := cfg.Models[SelectedModelTypeLarge]
1574 small := cfg.Models[SelectedModelTypeSmall]
1575 require.Equal(t, "large-model", large.Model)
1576 require.Equal(t, "openai", large.Provider)
1577 require.Equal(t, int64(1000), large.MaxTokens)
1578 require.Equal(t, "a-small-model", small.Model)
1579 require.Equal(t, "anthropic", small.Provider)
1580 require.Equal(t, int64(300), small.MaxTokens)
1581 })
1582
1583 t.Run("should override the max tokens only", func(t *testing.T) {
1584 knownProviders := []catwalk.Provider{
1585 {
1586 ID: "openai",
1587 APIKey: "abc",
1588 DefaultLargeModelID: "large-model",
1589 DefaultSmallModelID: "small-model",
1590 Models: []catwalk.Model{
1591 {
1592 ID: "large-model",
1593 DefaultMaxTokens: 1000,
1594 },
1595 {
1596 ID: "small-model",
1597 DefaultMaxTokens: 500,
1598 },
1599 },
1600 },
1601 }
1602
1603 cfg := &Config{
1604 Models: map[SelectedModelType]SelectedModel{
1605 "large": {
1606 MaxTokens: 100,
1607 },
1608 },
1609 }
1610 cfg.setDefaults("/tmp", "")
1611 env := env.NewFromMap(map[string]string{})
1612 resolver := NewShellVariableResolver(env)
1613 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1614 require.NoError(t, err)
1615
1616 err = configureSelectedModels(testStore(cfg), knownProviders, true)
1617 require.NoError(t, err)
1618 large := cfg.Models[SelectedModelTypeLarge]
1619 require.Equal(t, "large-model", large.Model)
1620 require.Equal(t, "openai", large.Provider)
1621 require.Equal(t, int64(100), large.MaxTokens)
1622 })
1623}
1624
1625func TestConfig_configureProviders_HyperAPIKeyFromEnv(t *testing.T) {
1626 // Test that HYPER_API_KEY environment variable works without config
1627 knownProviders := []catwalk.Provider{
1628 {
1629 ID: "hyper",
1630 APIKey: "", // No API key in provider definition
1631 DefaultLargeModelID: "large-model",
1632 DefaultSmallModelID: "small-model",
1633 Models: []catwalk.Model{
1634 {
1635 ID: "large-model",
1636 DefaultMaxTokens: 1000,
1637 },
1638 {
1639 ID: "small-model",
1640 DefaultMaxTokens: 500,
1641 },
1642 },
1643 },
1644 }
1645
1646 cfg := &Config{}
1647 cfg.setDefaults("/tmp", "")
1648 env := env.NewFromMap(map[string]string{
1649 "HYPER_API_KEY": "env-api-key",
1650 })
1651 resolver := NewShellVariableResolver(env)
1652 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1653 require.NoError(t, err)
1654 require.Equal(t, 1, cfg.Providers.Len())
1655
1656 // Verify Hyper provider is configured with the env var API key
1657 pc, ok := cfg.Providers.Get("hyper")
1658 require.True(t, ok, "Hyper provider should be configured")
1659 require.Equal(t, "env-api-key", pc.APIKey)
1660 require.Equal(t, "env-api-key", pc.APIKeyTemplate)
1661}
1662
1663func TestConfig_configureProviders_HyperAPIKeyFromConfigOverrides(t *testing.T) {
1664 // Test that config API key takes precedence when HYPER_API_KEY is also set
1665 knownProviders := []catwalk.Provider{
1666 {
1667 ID: "hyper",
1668 APIKey: "provider-api-key",
1669 DefaultLargeModelID: "large-model",
1670 DefaultSmallModelID: "small-model",
1671 Models: []catwalk.Model{
1672 {
1673 ID: "large-model",
1674 DefaultMaxTokens: 1000,
1675 },
1676 {
1677 ID: "small-model",
1678 DefaultMaxTokens: 500,
1679 },
1680 },
1681 },
1682 }
1683
1684 // User has Hyper configured with an API key
1685 cfg := &Config{
1686 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1687 "hyper": {
1688 APIKey: "config-api-key",
1689 },
1690 }),
1691 }
1692 cfg.setDefaults("/tmp", "")
1693
1694 // But they also have HYPER_API_KEY set - env var should take precedence
1695 env := env.NewFromMap(map[string]string{
1696 "HYPER_API_KEY": "env-api-key",
1697 })
1698 resolver := NewShellVariableResolver(env)
1699 err := cfg.configureProviders(testStore(cfg), env, resolver, knownProviders)
1700 require.NoError(t, err)
1701 require.Equal(t, 1, cfg.Providers.Len())
1702
1703 // Verify env var takes precedence (as per requirements)
1704 pc, ok := cfg.Providers.Get("hyper")
1705 require.True(t, ok, "Hyper provider should be configured")
1706 require.Equal(t, "env-api-key", pc.APIKey)
1707}
1708
1709// TestConfig_configureProviders_ProviderHeaderResolveError pins
1710// Phase 2 design decision #14: a failing $(cmd) in a provider header
1711// must fail the provider load with a clear message that names the
1712// offending header. The Phase 1 log-and-continue divergence at
1713// load.go:225 is gone; provider headers now share the MCP error
1714// contract.
1715func TestConfig_configureProviders_ProviderHeaderResolveError(t *testing.T) {
1716 knownProviders := []catwalk.Provider{
1717 {
1718 ID: "openai",
1719 APIKey: "$OPENAI_API_KEY",
1720 APIEndpoint: "https://api.openai.com/v1",
1721 Models: []catwalk.Model{{ID: "test-model"}},
1722 },
1723 }
1724
1725 cfg := &Config{
1726 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1727 "openai": {
1728 ExtraHeaders: map[string]string{
1729 // Failing $(...) — inner command exits 1. Must
1730 // propagate as an error, not a silent truncation.
1731 "X-Broken": "$(false)",
1732 },
1733 },
1734 }),
1735 }
1736 cfg.setDefaults("/tmp", "")
1737
1738 testEnv := env.NewFromMap(map[string]string{
1739 "OPENAI_API_KEY": "test-key",
1740 "PATH": os.Getenv("PATH"),
1741 })
1742 resolver := NewShellVariableResolver(testEnv)
1743
1744 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1745 require.Error(t, err, "failing $(cmd) in a header must fail the provider load")
1746 require.Contains(t, err.Error(), "X-Broken", "error must name the offending header")
1747}
1748
1749// TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads pins
1750// Phase 2 design decisions #11 and #18 from the provider angle: a
1751// Catwalk-style default header like
1752// "OpenAI-Organization": "$OPENAI_ORG_ID" must load cleanly under
1753// lenient nounset (unset → "" → header dropped), not fail the load
1754// and not leave the literal template on the wire.
1755func TestConfig_configureProviders_CatwalkDefaultWithUnsetVarLoads(t *testing.T) {
1756 knownProviders := []catwalk.Provider{
1757 {
1758 ID: "openai",
1759 APIKey: "$OPENAI_API_KEY",
1760 APIEndpoint: "https://api.openai.com/v1",
1761 Models: []catwalk.Model{{ID: "test-model"}},
1762 DefaultHeaders: map[string]string{
1763 "OpenAI-Organization": "$OPENAI_ORG_ID",
1764 },
1765 },
1766 }
1767
1768 cfg := &Config{}
1769 cfg.setDefaults("/tmp", "")
1770
1771 testEnv := env.NewFromMap(map[string]string{
1772 "OPENAI_API_KEY": "test-key",
1773 "PATH": os.Getenv("PATH"),
1774 })
1775 resolver := NewShellVariableResolver(testEnv)
1776
1777 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1778 require.NoError(t, err, "optional env-gated header must not fail the load")
1779
1780 pc, ok := cfg.Providers.Get("openai")
1781 require.True(t, ok, "openai provider must still be configured")
1782 _, present := pc.ExtraHeaders["OpenAI-Organization"]
1783 require.False(t, present, "header whose value resolves to empty must be absent")
1784}
1785
1786// TestConfig_configureProviders_LiteralEmptyHeaderDropped pins design
1787// decision #18 for the literal case: a user-authored
1788// "X-Custom": "" in extra_headers is absent from the resolved map.
1789// Applies to both known- and custom-provider paths; this test
1790// exercises the custom-provider loop.
1791func TestConfig_configureProviders_LiteralEmptyHeaderDropped(t *testing.T) {
1792 cfg := &Config{
1793 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1794 "my-llm": {
1795 APIKey: "test-key",
1796 BaseURL: "https://my-llm.example.com/v1",
1797 Type: catwalk.TypeOpenAI,
1798 Models: []catwalk.Model{{ID: "m"}},
1799 ExtraHeaders: map[string]string{
1800 "X-Custom": "",
1801 "X-Kept": "present",
1802 },
1803 },
1804 }),
1805 }
1806 cfg.setDefaults("/tmp", "")
1807
1808 testEnv := env.NewFromMap(map[string]string{
1809 "PATH": os.Getenv("PATH"),
1810 })
1811 resolver := NewShellVariableResolver(testEnv)
1812
1813 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, []catwalk.Provider{})
1814 require.NoError(t, err)
1815
1816 pc, ok := cfg.Providers.Get("my-llm")
1817 require.True(t, ok)
1818 _, present := pc.ExtraHeaders["X-Custom"]
1819 require.False(t, present, "literal empty-string header must be dropped")
1820 require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1821}
1822
1823// TestConfig_configureProviders_EchoEmptyHeaderDropped pins design
1824// decision #18 for the non-failing empty case: $(echo) exits 0 with
1825// empty output, resolves cleanly to "", and must be dropped the same
1826// way an unset bare $VAR is. Exercises the known-provider loop.
1827func TestConfig_configureProviders_EchoEmptyHeaderDropped(t *testing.T) {
1828 knownProviders := []catwalk.Provider{
1829 {
1830 ID: "openai",
1831 APIKey: "$OPENAI_API_KEY",
1832 APIEndpoint: "https://api.openai.com/v1",
1833 Models: []catwalk.Model{{ID: "test-model"}},
1834 DefaultHeaders: map[string]string{
1835 "X-Empty": "$(echo)",
1836 "X-Kept": "present",
1837 },
1838 },
1839 }
1840
1841 cfg := &Config{}
1842 cfg.setDefaults("/tmp", "")
1843
1844 testEnv := env.NewFromMap(map[string]string{
1845 "OPENAI_API_KEY": "test-key",
1846 "PATH": os.Getenv("PATH"),
1847 })
1848 resolver := NewShellVariableResolver(testEnv)
1849
1850 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1851 require.NoError(t, err)
1852
1853 pc, ok := cfg.Providers.Get("openai")
1854 require.True(t, ok)
1855 _, present := pc.ExtraHeaders["X-Empty"]
1856 require.False(t, present, "$(echo) → empty → header must be dropped")
1857 require.Equal(t, "present", pc.ExtraHeaders["X-Kept"])
1858}
1859
1860// TestConfig_configureProviders_UnsetAPIKeySkipsProvider pins Phase 2
1861// Step 12 / design decision #15: under the lenient-nounset shell
1862// resolver, $UNSET_API_KEY expands to ("", nil) rather than ("", err),
1863// and the existing `v == "" || err != nil` skip path at load.go:331
1864// still drops the provider. The slog.Warn line is emitted on the same
1865// path but is not asserted here — internal/config/load_test.go's
1866// TestMain replaces the default slog handler with an io.Discard
1867// writer, so capturing that log line would require mid-test handler
1868// swapping and a sync.Mutex dance that adds more flake surface than
1869// signal. The observable outcome (provider absent from the map) is
1870// what downstream code — model picker, agent wiring — actually reads,
1871// so that's what we pin.
1872func TestConfig_configureProviders_UnsetAPIKeySkipsProvider(t *testing.T) {
1873 knownProviders := []catwalk.Provider{
1874 {
1875 ID: "openai",
1876 APIKey: "$SOMETHING_UNSET",
1877 APIEndpoint: "https://api.openai.com/v1",
1878 Models: []catwalk.Model{{ID: "test-model"}},
1879 },
1880 }
1881
1882 // Existing user config for this known provider so the load.go:332
1883 // `if configExists` branch fires and actually calls Providers.Del.
1884 // Without it the provider was never in the map to begin with and
1885 // the test would pass trivially.
1886 cfg := &Config{
1887 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1888 "openai": {BaseURL: "custom-url"},
1889 }),
1890 }
1891 cfg.setDefaults("/tmp", "")
1892
1893 testEnv := env.NewFromMap(map[string]string{
1894 "PATH": os.Getenv("PATH"),
1895 })
1896 resolver := NewShellVariableResolver(testEnv)
1897
1898 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1899 require.NoError(t, err, "skip path must not surface as a load error")
1900
1901 require.Equal(t, 0, cfg.Providers.Len(), "provider with unset API key must be skipped")
1902 _, exists := cfg.Providers.Get("openai")
1903 require.False(t, exists)
1904}
1905
1906// TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider pins
1907// that the two failure modes for APIKey — ("", nil) from an unset var
1908// under lenient nounset and ("", err) from a failing $(cmd) — are
1909// equivalent for the skip outcome at load.go:331. The `v == "" ||
1910// err != nil` check fires on either branch; this test locks in that
1911// equivalence so a future refactor that splits the check into two
1912// paths doesn't accidentally start propagating $(false) as a load
1913// error while keeping unset-var as a silent skip (or vice versa).
1914func TestConfig_configureProviders_FailingAPIKeyCmdSkipsProvider(t *testing.T) {
1915 knownProviders := []catwalk.Provider{
1916 {
1917 ID: "openai",
1918 APIKey: "$(false)",
1919 APIEndpoint: "https://api.openai.com/v1",
1920 Models: []catwalk.Model{{ID: "test-model"}},
1921 },
1922 }
1923
1924 cfg := &Config{
1925 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1926 "openai": {BaseURL: "custom-url"},
1927 }),
1928 }
1929 cfg.setDefaults("/tmp", "")
1930
1931 testEnv := env.NewFromMap(map[string]string{
1932 "PATH": os.Getenv("PATH"),
1933 })
1934 resolver := NewShellVariableResolver(testEnv)
1935
1936 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1937 require.NoError(t, err, "failing $(cmd) in API key must skip provider, not fail load")
1938
1939 require.Equal(t, 0, cfg.Providers.Len(), "provider with failing $(cmd) API key must be skipped")
1940 _, exists := cfg.Providers.Get("openai")
1941 require.False(t, exists)
1942}
1943
1944// TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider pins
1945// the same contract on the Azure path at load.go:287 — APIEndpoint is
1946// the field that gates Azure and goes through the same
1947// `v == "" || err != nil` skip check. Covered here so both branches
1948// of the shared skip pattern (APIKey default path and APIEndpoint
1949// Azure path) are tested; a future refactor that unifies them can
1950// rely on these two tests to catch drift.
1951func TestConfig_configureProviders_UnsetAzureEndpointSkipsProvider(t *testing.T) {
1952 knownProviders := []catwalk.Provider{
1953 {
1954 ID: catwalk.InferenceProviderAzure,
1955 APIKey: "test-key",
1956 APIEndpoint: "$UNSET_AZURE_ENDPOINT",
1957 Models: []catwalk.Model{{ID: "test-model"}},
1958 },
1959 }
1960
1961 cfg := &Config{
1962 Providers: csync.NewMapFrom(map[string]ProviderConfig{
1963 "azure": {BaseURL: ""},
1964 }),
1965 }
1966 cfg.setDefaults("/tmp", "")
1967
1968 testEnv := env.NewFromMap(map[string]string{
1969 "PATH": os.Getenv("PATH"),
1970 })
1971 resolver := NewShellVariableResolver(testEnv)
1972
1973 err := cfg.configureProviders(testStore(cfg), testEnv, resolver, knownProviders)
1974 require.NoError(t, err)
1975
1976 require.Equal(t, 0, cfg.Providers.Len(), "azure provider with unset endpoint must be skipped")
1977 _, exists := cfg.Providers.Get("azure")
1978 require.False(t, exists)
1979}