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