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