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