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