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