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