@@ -0,0 +1,2079 @@
1+package config
2+
3+import (
4+ "encoding/json"
5+ "os"
6+ "path/filepath"
7+ "sync"
8+ "testing"
9+
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func reset() {
16+ // Clear all environment variables that could affect config
17+ envVarsToUnset := []string{
18+ // API Keys
19+ "ANTHROPIC_API_KEY",
20+ "OPENAI_API_KEY",
21+ "GEMINI_API_KEY",
22+ "XAI_API_KEY",
23+ "OPENROUTER_API_KEY",
24+
25+ // Google Cloud / VertexAI
26+ "GOOGLE_GENAI_USE_VERTEXAI",
27+ "GOOGLE_CLOUD_PROJECT",
28+ "GOOGLE_CLOUD_LOCATION",
29+
30+ // AWS Credentials
31+ "AWS_ACCESS_KEY_ID",
32+ "AWS_SECRET_ACCESS_KEY",
33+ "AWS_REGION",
34+ "AWS_DEFAULT_REGION",
35+ "AWS_PROFILE",
36+ "AWS_DEFAULT_PROFILE",
37+ "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
38+ "AWS_CONTAINER_CREDENTIALS_FULL_URI",
39+
40+ // Other
41+ "CRUSH_DEV_DEBUG",
42+ }
43+
44+ for _, envVar := range envVarsToUnset {
45+ os.Unsetenv(envVar)
46+ }
47+
48+ // Reset singleton
49+ once = sync.Once{}
50+ instance = nil
51+ cwd = ""
52+ testConfigDir = ""
53+
54+ // Enable mock providers for all tests to avoid API calls
55+ UseMockProviders = true
56+ ResetProviders()
57+}
58+
59+// Core Configuration Loading Tests
60+
61+func TestInit_ValidWorkingDirectory(t *testing.T) {
62+ reset()
63+ testConfigDir = t.TempDir()
64+ cwdDir := t.TempDir()
65+
66+ cfg, err := Init(cwdDir, false)
67+
68+ require.NoError(t, err)
69+ assert.NotNil(t, cfg)
70+ assert.Equal(t, cwdDir, WorkingDirectory())
71+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
72+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
73+}
74+
75+func TestInit_WithDebugFlag(t *testing.T) {
76+ reset()
77+ testConfigDir = t.TempDir()
78+ cwdDir := t.TempDir()
79+
80+ cfg, err := Init(cwdDir, true)
81+
82+ require.NoError(t, err)
83+ assert.True(t, cfg.Options.Debug)
84+}
85+
86+func TestInit_SingletonBehavior(t *testing.T) {
87+ reset()
88+ testConfigDir = t.TempDir()
89+ cwdDir := t.TempDir()
90+
91+ cfg1, err1 := Init(cwdDir, false)
92+ cfg2, err2 := Init(cwdDir, false)
93+
94+ require.NoError(t, err1)
95+ require.NoError(t, err2)
96+ assert.Same(t, cfg1, cfg2)
97+}
98+
99+func TestGet_BeforeInitialization(t *testing.T) {
100+ reset()
101+
102+ assert.Panics(t, func() {
103+ Get()
104+ })
105+}
106+
107+func TestGet_AfterInitialization(t *testing.T) {
108+ reset()
109+ testConfigDir = t.TempDir()
110+ cwdDir := t.TempDir()
111+
112+ cfg1, err := Init(cwdDir, false)
113+ require.NoError(t, err)
114+
115+ cfg2 := Get()
116+ assert.Same(t, cfg1, cfg2)
117+}
118+
119+func TestLoadConfig_NoConfigFiles(t *testing.T) {
120+ reset()
121+ testConfigDir = t.TempDir()
122+ cwdDir := t.TempDir()
123+
124+ cfg, err := Init(cwdDir, false)
125+
126+ require.NoError(t, err)
127+ assert.Len(t, cfg.Providers, 0)
128+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
129+}
130+
131+func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
132+ reset()
133+ testConfigDir = t.TempDir()
134+ cwdDir := t.TempDir()
135+
136+ globalConfig := Config{
137+ Providers: map[provider.InferenceProvider]ProviderConfig{
138+ provider.InferenceProviderOpenAI: {
139+ ID: provider.InferenceProviderOpenAI,
140+ APIKey: "test-key",
141+ ProviderType: provider.TypeOpenAI,
142+ DefaultLargeModel: "gpt-4",
143+ DefaultSmallModel: "gpt-3.5-turbo",
144+ Models: []Model{
145+ {
146+ ID: "gpt-4",
147+ Name: "GPT-4",
148+ CostPer1MIn: 30.0,
149+ CostPer1MOut: 60.0,
150+ ContextWindow: 8192,
151+ DefaultMaxTokens: 4096,
152+ },
153+ {
154+ ID: "gpt-3.5-turbo",
155+ Name: "GPT-3.5 Turbo",
156+ CostPer1MIn: 1.0,
157+ CostPer1MOut: 2.0,
158+ ContextWindow: 4096,
159+ DefaultMaxTokens: 4096,
160+ },
161+ },
162+ },
163+ },
164+ Options: Options{
165+ ContextPaths: []string{"custom-context.md"},
166+ },
167+ }
168+
169+ configPath := filepath.Join(testConfigDir, "crush.json")
170+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
171+
172+ data, err := json.Marshal(globalConfig)
173+ require.NoError(t, err)
174+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
175+
176+ cfg, err := Init(cwdDir, false)
177+
178+ require.NoError(t, err)
179+ assert.Len(t, cfg.Providers, 1)
180+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
181+ assert.Contains(t, cfg.Options.ContextPaths, "custom-context.md")
182+}
183+
184+func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
185+ reset()
186+ testConfigDir = t.TempDir()
187+ cwdDir := t.TempDir()
188+
189+ localConfig := Config{
190+ Providers: map[provider.InferenceProvider]ProviderConfig{
191+ provider.InferenceProviderAnthropic: {
192+ ID: provider.InferenceProviderAnthropic,
193+ APIKey: "local-key",
194+ ProviderType: provider.TypeAnthropic,
195+ DefaultLargeModel: "claude-3-opus",
196+ DefaultSmallModel: "claude-3-haiku",
197+ Models: []Model{
198+ {
199+ ID: "claude-3-opus",
200+ Name: "Claude 3 Opus",
201+ CostPer1MIn: 15.0,
202+ CostPer1MOut: 75.0,
203+ ContextWindow: 200000,
204+ DefaultMaxTokens: 4096,
205+ },
206+ {
207+ ID: "claude-3-haiku",
208+ Name: "Claude 3 Haiku",
209+ CostPer1MIn: 0.25,
210+ CostPer1MOut: 1.25,
211+ ContextWindow: 200000,
212+ DefaultMaxTokens: 4096,
213+ },
214+ },
215+ },
216+ },
217+ Options: Options{
218+ TUI: TUIOptions{CompactMode: true},
219+ },
220+ }
221+
222+ localConfigPath := filepath.Join(cwdDir, "crush.json")
223+ data, err := json.Marshal(localConfig)
224+ require.NoError(t, err)
225+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
226+
227+ cfg, err := Init(cwdDir, false)
228+
229+ require.NoError(t, err)
230+ assert.Len(t, cfg.Providers, 1)
231+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
232+ assert.True(t, cfg.Options.TUI.CompactMode)
233+}
234+
235+func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
236+ reset()
237+ testConfigDir = t.TempDir()
238+ cwdDir := t.TempDir()
239+
240+ globalConfig := Config{
241+ Providers: map[provider.InferenceProvider]ProviderConfig{
242+ provider.InferenceProviderOpenAI: {
243+ ID: provider.InferenceProviderOpenAI,
244+ APIKey: "global-key",
245+ ProviderType: provider.TypeOpenAI,
246+ DefaultLargeModel: "gpt-4",
247+ DefaultSmallModel: "gpt-3.5-turbo",
248+ Models: []Model{
249+ {
250+ ID: "gpt-4",
251+ Name: "GPT-4",
252+ CostPer1MIn: 30.0,
253+ CostPer1MOut: 60.0,
254+ ContextWindow: 8192,
255+ DefaultMaxTokens: 4096,
256+ },
257+ {
258+ ID: "gpt-3.5-turbo",
259+ Name: "GPT-3.5 Turbo",
260+ CostPer1MIn: 1.0,
261+ CostPer1MOut: 2.0,
262+ ContextWindow: 4096,
263+ DefaultMaxTokens: 4096,
264+ },
265+ },
266+ },
267+ },
268+ Options: Options{
269+ ContextPaths: []string{"global-context.md"},
270+ },
271+ }
272+
273+ configPath := filepath.Join(testConfigDir, "crush.json")
274+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
275+ data, err := json.Marshal(globalConfig)
276+ require.NoError(t, err)
277+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
278+
279+ localConfig := Config{
280+ Providers: map[provider.InferenceProvider]ProviderConfig{
281+ provider.InferenceProviderOpenAI: {
282+ APIKey: "local-key", // Override global
283+ },
284+ provider.InferenceProviderAnthropic: {
285+ ID: provider.InferenceProviderAnthropic,
286+ APIKey: "anthropic-key",
287+ ProviderType: provider.TypeAnthropic,
288+ DefaultLargeModel: "claude-3-opus",
289+ DefaultSmallModel: "claude-3-haiku",
290+ Models: []Model{
291+ {
292+ ID: "claude-3-opus",
293+ Name: "Claude 3 Opus",
294+ CostPer1MIn: 15.0,
295+ CostPer1MOut: 75.0,
296+ ContextWindow: 200000,
297+ DefaultMaxTokens: 4096,
298+ },
299+ {
300+ ID: "claude-3-haiku",
301+ Name: "Claude 3 Haiku",
302+ CostPer1MIn: 0.25,
303+ CostPer1MOut: 1.25,
304+ ContextWindow: 200000,
305+ DefaultMaxTokens: 4096,
306+ },
307+ },
308+ },
309+ },
310+ Options: Options{
311+ ContextPaths: []string{"local-context.md"},
312+ TUI: TUIOptions{CompactMode: true},
313+ },
314+ }
315+
316+ localConfigPath := filepath.Join(cwdDir, "crush.json")
317+ data, err = json.Marshal(localConfig)
318+ require.NoError(t, err)
319+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
320+
321+ cfg, err := Init(cwdDir, false)
322+
323+ require.NoError(t, err)
324+ assert.Len(t, cfg.Providers, 2)
325+
326+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
327+ assert.Equal(t, "local-key", openaiProvider.APIKey)
328+
329+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
330+
331+ assert.Contains(t, cfg.Options.ContextPaths, "global-context.md")
332+ assert.Contains(t, cfg.Options.ContextPaths, "local-context.md")
333+ assert.True(t, cfg.Options.TUI.CompactMode)
334+}
335+
336+func TestLoadConfig_MalformedGlobalJSON(t *testing.T) {
337+ reset()
338+ testConfigDir = t.TempDir()
339+ cwdDir := t.TempDir()
340+
341+ configPath := filepath.Join(testConfigDir, "crush.json")
342+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
343+ require.NoError(t, os.WriteFile(configPath, []byte(`{invalid json`), 0o644))
344+
345+ _, err := Init(cwdDir, false)
346+ assert.Error(t, err)
347+}
348+
349+func TestLoadConfig_MalformedLocalJSON(t *testing.T) {
350+ reset()
351+ testConfigDir = t.TempDir()
352+ cwdDir := t.TempDir()
353+
354+ localConfigPath := filepath.Join(cwdDir, "crush.json")
355+ require.NoError(t, os.WriteFile(localConfigPath, []byte(`{invalid json`), 0o644))
356+
357+ _, err := Init(cwdDir, false)
358+ assert.Error(t, err)
359+}
360+
361+func TestConfigWithoutEnv(t *testing.T) {
362+ reset()
363+ testConfigDir = t.TempDir()
364+ cwdDir := t.TempDir()
365+
366+ cfg, _ := Init(cwdDir, false)
367+ assert.Len(t, cfg.Providers, 0)
368+}
369+
370+func TestConfigWithEnv(t *testing.T) {
371+ reset()
372+ testConfigDir = t.TempDir()
373+ cwdDir := t.TempDir()
374+
375+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
376+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
377+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
378+ os.Setenv("XAI_API_KEY", "test-xai-key")
379+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
380+
381+ cfg, _ := Init(cwdDir, false)
382+ assert.Len(t, cfg.Providers, 5)
383+}
384+
385+// Environment Variable Tests
386+
387+func TestEnvVars_NoEnvironmentVariables(t *testing.T) {
388+ reset()
389+ testConfigDir = t.TempDir()
390+ cwdDir := t.TempDir()
391+
392+ cfg, err := Init(cwdDir, false)
393+
394+ require.NoError(t, err)
395+ assert.Len(t, cfg.Providers, 0)
396+}
397+
398+func TestEnvVars_AllSupportedAPIKeys(t *testing.T) {
399+ reset()
400+ testConfigDir = t.TempDir()
401+ cwdDir := t.TempDir()
402+
403+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
404+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
405+ os.Setenv("GEMINI_API_KEY", "test-gemini-key")
406+ os.Setenv("XAI_API_KEY", "test-xai-key")
407+ os.Setenv("OPENROUTER_API_KEY", "test-openrouter-key")
408+
409+ cfg, err := Init(cwdDir, false)
410+
411+ require.NoError(t, err)
412+ assert.Len(t, cfg.Providers, 5)
413+
414+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
415+ assert.Equal(t, "test-anthropic-key", anthropicProvider.APIKey)
416+ assert.Equal(t, provider.TypeAnthropic, anthropicProvider.ProviderType)
417+
418+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
419+ assert.Equal(t, "test-openai-key", openaiProvider.APIKey)
420+ assert.Equal(t, provider.TypeOpenAI, openaiProvider.ProviderType)
421+
422+ geminiProvider := cfg.Providers[provider.InferenceProviderGemini]
423+ assert.Equal(t, "test-gemini-key", geminiProvider.APIKey)
424+ assert.Equal(t, provider.TypeGemini, geminiProvider.ProviderType)
425+
426+ xaiProvider := cfg.Providers[provider.InferenceProviderXAI]
427+ assert.Equal(t, "test-xai-key", xaiProvider.APIKey)
428+ assert.Equal(t, provider.TypeXAI, xaiProvider.ProviderType)
429+
430+ openrouterProvider := cfg.Providers[provider.InferenceProviderOpenRouter]
431+ assert.Equal(t, "test-openrouter-key", openrouterProvider.APIKey)
432+ assert.Equal(t, provider.TypeOpenAI, openrouterProvider.ProviderType)
433+ assert.Equal(t, "https://openrouter.ai/api/v1", openrouterProvider.BaseURL)
434+}
435+
436+func TestEnvVars_PartialEnvironmentVariables(t *testing.T) {
437+ reset()
438+ testConfigDir = t.TempDir()
439+ cwdDir := t.TempDir()
440+
441+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
442+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
443+
444+ cfg, err := Init(cwdDir, false)
445+
446+ require.NoError(t, err)
447+ assert.Len(t, cfg.Providers, 2)
448+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
449+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
450+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderGemini)
451+}
452+
453+func TestEnvVars_VertexAIConfiguration(t *testing.T) {
454+ reset()
455+ testConfigDir = t.TempDir()
456+ cwdDir := t.TempDir()
457+
458+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
459+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
460+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
461+
462+ cfg, err := Init(cwdDir, false)
463+
464+ require.NoError(t, err)
465+ assert.Contains(t, cfg.Providers, provider.InferenceProviderVertexAI)
466+
467+ vertexProvider := cfg.Providers[provider.InferenceProviderVertexAI]
468+ assert.Equal(t, provider.TypeVertexAI, vertexProvider.ProviderType)
469+ assert.Equal(t, "test-project", vertexProvider.ExtraParams["project"])
470+ assert.Equal(t, "us-central1", vertexProvider.ExtraParams["location"])
471+}
472+
473+func TestEnvVars_VertexAIWithoutUseFlag(t *testing.T) {
474+ reset()
475+ testConfigDir = t.TempDir()
476+ cwdDir := t.TempDir()
477+
478+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
479+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
480+
481+ cfg, err := Init(cwdDir, false)
482+
483+ require.NoError(t, err)
484+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderVertexAI)
485+}
486+
487+func TestEnvVars_AWSBedrockWithAccessKeys(t *testing.T) {
488+ reset()
489+ testConfigDir = t.TempDir()
490+ cwdDir := t.TempDir()
491+
492+ os.Setenv("AWS_ACCESS_KEY_ID", "test-access-key")
493+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret-key")
494+ os.Setenv("AWS_DEFAULT_REGION", "us-east-1")
495+
496+ cfg, err := Init(cwdDir, false)
497+
498+ require.NoError(t, err)
499+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
500+
501+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
502+ assert.Equal(t, provider.TypeBedrock, bedrockProvider.ProviderType)
503+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
504+}
505+
506+func TestEnvVars_AWSBedrockWithProfile(t *testing.T) {
507+ reset()
508+ testConfigDir = t.TempDir()
509+ cwdDir := t.TempDir()
510+
511+ os.Setenv("AWS_PROFILE", "test-profile")
512+ os.Setenv("AWS_REGION", "eu-west-1")
513+
514+ cfg, err := Init(cwdDir, false)
515+
516+ require.NoError(t, err)
517+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
518+
519+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
520+ assert.Equal(t, "eu-west-1", bedrockProvider.ExtraParams["region"])
521+}
522+
523+func TestEnvVars_AWSBedrockWithContainerCredentials(t *testing.T) {
524+ reset()
525+ testConfigDir = t.TempDir()
526+ cwdDir := t.TempDir()
527+
528+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
529+ os.Setenv("AWS_DEFAULT_REGION", "ap-southeast-1")
530+
531+ cfg, err := Init(cwdDir, false)
532+
533+ require.NoError(t, err)
534+ assert.Contains(t, cfg.Providers, provider.InferenceProviderBedrock)
535+}
536+
537+func TestEnvVars_AWSBedrockRegionPriority(t *testing.T) {
538+ reset()
539+ testConfigDir = t.TempDir()
540+ cwdDir := t.TempDir()
541+
542+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
543+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
544+ os.Setenv("AWS_DEFAULT_REGION", "us-west-2")
545+ os.Setenv("AWS_REGION", "us-east-1")
546+
547+ cfg, err := Init(cwdDir, false)
548+
549+ require.NoError(t, err)
550+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
551+ assert.Equal(t, "us-west-2", bedrockProvider.ExtraParams["region"])
552+}
553+
554+func TestEnvVars_AWSBedrockFallbackRegion(t *testing.T) {
555+ reset()
556+ testConfigDir = t.TempDir()
557+ cwdDir := t.TempDir()
558+
559+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
560+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
561+ os.Setenv("AWS_REGION", "us-east-1")
562+
563+ cfg, err := Init(cwdDir, false)
564+
565+ require.NoError(t, err)
566+ bedrockProvider := cfg.Providers[provider.InferenceProviderBedrock]
567+ assert.Equal(t, "us-east-1", bedrockProvider.ExtraParams["region"])
568+}
569+
570+func TestEnvVars_NoAWSCredentials(t *testing.T) {
571+ reset()
572+ testConfigDir = t.TempDir()
573+ cwdDir := t.TempDir()
574+
575+ cfg, err := Init(cwdDir, false)
576+
577+ require.NoError(t, err)
578+ assert.NotContains(t, cfg.Providers, provider.InferenceProviderBedrock)
579+}
580+
581+func TestEnvVars_CustomEnvironmentVariables(t *testing.T) {
582+ reset()
583+ testConfigDir = t.TempDir()
584+ cwdDir := t.TempDir()
585+
586+ os.Setenv("ANTHROPIC_API_KEY", "resolved-anthropic-key")
587+
588+ cfg, err := Init(cwdDir, false)
589+
590+ require.NoError(t, err)
591+ if len(cfg.Providers) > 0 {
592+ if anthropicProvider, exists := cfg.Providers[provider.InferenceProviderAnthropic]; exists {
593+ assert.Equal(t, "resolved-anthropic-key", anthropicProvider.APIKey)
594+ }
595+ }
596+}
597+
598+func TestEnvVars_CombinedEnvironmentVariables(t *testing.T) {
599+ reset()
600+ testConfigDir = t.TempDir()
601+ cwdDir := t.TempDir()
602+
603+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic")
604+ os.Setenv("OPENAI_API_KEY", "test-openai")
605+ os.Setenv("GOOGLE_GENAI_USE_VERTEXAI", "true")
606+ os.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
607+ os.Setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
608+ os.Setenv("AWS_ACCESS_KEY_ID", "test-aws-key")
609+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-aws-secret")
610+ os.Setenv("AWS_DEFAULT_REGION", "us-west-1")
611+
612+ cfg, err := Init(cwdDir, false)
613+
614+ require.NoError(t, err)
615+
616+ expectedProviders := []provider.InferenceProvider{
617+ provider.InferenceProviderAnthropic,
618+ provider.InferenceProviderOpenAI,
619+ provider.InferenceProviderVertexAI,
620+ provider.InferenceProviderBedrock,
621+ }
622+
623+ for _, expectedProvider := range expectedProviders {
624+ assert.Contains(t, cfg.Providers, expectedProvider)
625+ }
626+}
627+
628+func TestHasAWSCredentials_AccessKeys(t *testing.T) {
629+ reset()
630+
631+ os.Setenv("AWS_ACCESS_KEY_ID", "test-key")
632+ os.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")
633+
634+ assert.True(t, hasAWSCredentials())
635+}
636+
637+func TestHasAWSCredentials_Profile(t *testing.T) {
638+ reset()
639+
640+ os.Setenv("AWS_PROFILE", "test-profile")
641+
642+ assert.True(t, hasAWSCredentials())
643+}
644+
645+func TestHasAWSCredentials_DefaultProfile(t *testing.T) {
646+ reset()
647+
648+ os.Setenv("AWS_DEFAULT_PROFILE", "default")
649+
650+ assert.True(t, hasAWSCredentials())
651+}
652+
653+func TestHasAWSCredentials_Region(t *testing.T) {
654+ reset()
655+
656+ os.Setenv("AWS_REGION", "us-east-1")
657+
658+ assert.True(t, hasAWSCredentials())
659+}
660+
661+func TestHasAWSCredentials_ContainerCredentials(t *testing.T) {
662+ reset()
663+
664+ os.Setenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "/v2/credentials/test")
665+
666+ assert.True(t, hasAWSCredentials())
667+}
668+
669+func TestHasAWSCredentials_NoCredentials(t *testing.T) {
670+ reset()
671+
672+ assert.False(t, hasAWSCredentials())
673+}
674+
675+func TestProviderMerging_GlobalToBase(t *testing.T) {
676+ reset()
677+ testConfigDir = t.TempDir()
678+ cwdDir := t.TempDir()
679+
680+ globalConfig := Config{
681+ Providers: map[provider.InferenceProvider]ProviderConfig{
682+ provider.InferenceProviderOpenAI: {
683+ ID: provider.InferenceProviderOpenAI,
684+ APIKey: "global-openai-key",
685+ ProviderType: provider.TypeOpenAI,
686+ DefaultLargeModel: "gpt-4",
687+ DefaultSmallModel: "gpt-3.5-turbo",
688+ Models: []Model{
689+ {
690+ ID: "gpt-4",
691+ Name: "GPT-4",
692+ ContextWindow: 8192,
693+ DefaultMaxTokens: 4096,
694+ },
695+ {
696+ ID: "gpt-3.5-turbo",
697+ Name: "GPT-3.5 Turbo",
698+ ContextWindow: 4096,
699+ DefaultMaxTokens: 2048,
700+ },
701+ },
702+ },
703+ },
704+ }
705+
706+ configPath := filepath.Join(testConfigDir, "crush.json")
707+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
708+ data, err := json.Marshal(globalConfig)
709+ require.NoError(t, err)
710+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
711+
712+ cfg, err := Init(cwdDir, false)
713+
714+ require.NoError(t, err)
715+ assert.Len(t, cfg.Providers, 1)
716+
717+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
718+ assert.Equal(t, "global-openai-key", openaiProvider.APIKey)
719+ assert.Equal(t, "gpt-4", openaiProvider.DefaultLargeModel)
720+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
721+ assert.Len(t, openaiProvider.Models, 2)
722+}
723+
724+func TestProviderMerging_LocalToBase(t *testing.T) {
725+ reset()
726+ testConfigDir = t.TempDir()
727+ cwdDir := t.TempDir()
728+
729+ localConfig := Config{
730+ Providers: map[provider.InferenceProvider]ProviderConfig{
731+ provider.InferenceProviderAnthropic: {
732+ ID: provider.InferenceProviderAnthropic,
733+ APIKey: "local-anthropic-key",
734+ ProviderType: provider.TypeAnthropic,
735+ DefaultLargeModel: "claude-3-opus",
736+ DefaultSmallModel: "claude-3-haiku",
737+ Models: []Model{
738+ {
739+ ID: "claude-3-opus",
740+ Name: "Claude 3 Opus",
741+ ContextWindow: 200000,
742+ DefaultMaxTokens: 4096,
743+ CostPer1MIn: 15.0,
744+ CostPer1MOut: 75.0,
745+ },
746+ {
747+ ID: "claude-3-haiku",
748+ Name: "Claude 3 Haiku",
749+ ContextWindow: 200000,
750+ DefaultMaxTokens: 4096,
751+ CostPer1MIn: 0.25,
752+ CostPer1MOut: 1.25,
753+ },
754+ },
755+ },
756+ },
757+ }
758+
759+ localConfigPath := filepath.Join(cwdDir, "crush.json")
760+ data, err := json.Marshal(localConfig)
761+ require.NoError(t, err)
762+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
763+
764+ cfg, err := Init(cwdDir, false)
765+
766+ require.NoError(t, err)
767+ assert.Len(t, cfg.Providers, 1)
768+
769+ anthropicProvider := cfg.Providers[provider.InferenceProviderAnthropic]
770+ assert.Equal(t, "local-anthropic-key", anthropicProvider.APIKey)
771+ assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModel)
772+ assert.Equal(t, "claude-3-haiku", anthropicProvider.DefaultSmallModel)
773+ assert.Len(t, anthropicProvider.Models, 2)
774+}
775+
776+func TestProviderMerging_ConflictingSettings(t *testing.T) {
777+ reset()
778+ testConfigDir = t.TempDir()
779+ cwdDir := t.TempDir()
780+
781+ globalConfig := Config{
782+ Providers: map[provider.InferenceProvider]ProviderConfig{
783+ provider.InferenceProviderOpenAI: {
784+ ID: provider.InferenceProviderOpenAI,
785+ APIKey: "global-key",
786+ ProviderType: provider.TypeOpenAI,
787+ DefaultLargeModel: "gpt-4",
788+ DefaultSmallModel: "gpt-3.5-turbo",
789+ Models: []Model{
790+ {
791+ ID: "gpt-4",
792+ Name: "GPT-4",
793+ ContextWindow: 8192,
794+ DefaultMaxTokens: 4096,
795+ },
796+ {
797+ ID: "gpt-3.5-turbo",
798+ Name: "GPT-3.5 Turbo",
799+ ContextWindow: 4096,
800+ DefaultMaxTokens: 2048,
801+ },
802+ {
803+ ID: "gpt-4-turbo",
804+ Name: "GPT-4 Turbo",
805+ ContextWindow: 128000,
806+ DefaultMaxTokens: 4096,
807+ },
808+ },
809+ },
810+ },
811+ }
812+
813+ configPath := filepath.Join(testConfigDir, "crush.json")
814+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
815+ data, err := json.Marshal(globalConfig)
816+ require.NoError(t, err)
817+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
818+
819+ // Create local config that overrides
820+ localConfig := Config{
821+ Providers: map[provider.InferenceProvider]ProviderConfig{
822+ provider.InferenceProviderOpenAI: {
823+ APIKey: "local-key",
824+ DefaultLargeModel: "gpt-4-turbo",
825+ },
826+ },
827+ }
828+
829+ localConfigPath := filepath.Join(cwdDir, "crush.json")
830+ data, err = json.Marshal(localConfig)
831+ require.NoError(t, err)
832+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
833+
834+ cfg, err := Init(cwdDir, false)
835+
836+ require.NoError(t, err)
837+
838+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
839+ assert.Equal(t, "local-key", openaiProvider.APIKey)
840+ assert.Equal(t, "gpt-4-turbo", openaiProvider.DefaultLargeModel)
841+ assert.False(t, openaiProvider.Disabled)
842+ assert.Equal(t, "gpt-3.5-turbo", openaiProvider.DefaultSmallModel)
843+}
844+
845+func TestProviderMerging_CustomVsKnownProviders(t *testing.T) {
846+ reset()
847+ testConfigDir = t.TempDir()
848+ cwdDir := t.TempDir()
849+
850+ customProviderID := provider.InferenceProvider("custom-provider")
851+
852+ globalConfig := Config{
853+ Providers: map[provider.InferenceProvider]ProviderConfig{
854+ provider.InferenceProviderOpenAI: {
855+ ID: provider.InferenceProviderOpenAI,
856+ APIKey: "openai-key",
857+ BaseURL: "should-not-override",
858+ ProviderType: provider.TypeAnthropic,
859+ DefaultLargeModel: "gpt-4",
860+ DefaultSmallModel: "gpt-3.5-turbo",
861+ Models: []Model{
862+ {
863+ ID: "gpt-4",
864+ Name: "GPT-4",
865+ ContextWindow: 8192,
866+ DefaultMaxTokens: 4096,
867+ },
868+ {
869+ ID: "gpt-3.5-turbo",
870+ Name: "GPT-3.5 Turbo",
871+ ContextWindow: 4096,
872+ DefaultMaxTokens: 2048,
873+ },
874+ },
875+ },
876+ customProviderID: {
877+ ID: customProviderID,
878+ APIKey: "custom-key",
879+ BaseURL: "https://custom.api.com",
880+ ProviderType: provider.TypeOpenAI,
881+ DefaultLargeModel: "custom-large",
882+ DefaultSmallModel: "custom-small",
883+ Models: []Model{
884+ {
885+ ID: "custom-large",
886+ Name: "Custom Large",
887+ ContextWindow: 8192,
888+ DefaultMaxTokens: 4096,
889+ },
890+ {
891+ ID: "custom-small",
892+ Name: "Custom Small",
893+ ContextWindow: 4096,
894+ DefaultMaxTokens: 2048,
895+ },
896+ },
897+ },
898+ },
899+ }
900+
901+ localConfig := Config{
902+ Providers: map[provider.InferenceProvider]ProviderConfig{
903+ provider.InferenceProviderOpenAI: {
904+ BaseURL: "https://should-not-change.com",
905+ ProviderType: provider.TypeGemini, // Should not change
906+ },
907+ customProviderID: {
908+ BaseURL: "https://updated-custom.api.com",
909+ ProviderType: provider.TypeOpenAI,
910+ },
911+ },
912+ }
913+
914+ configPath := filepath.Join(testConfigDir, "crush.json")
915+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
916+ data, err := json.Marshal(globalConfig)
917+ require.NoError(t, err)
918+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
919+
920+ localConfigPath := filepath.Join(cwdDir, "crush.json")
921+ data, err = json.Marshal(localConfig)
922+ require.NoError(t, err)
923+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
924+
925+ cfg, err := Init(cwdDir, false)
926+
927+ require.NoError(t, err)
928+
929+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
930+ assert.NotEqual(t, "https://should-not-change.com", openaiProvider.BaseURL)
931+ assert.NotEqual(t, provider.TypeGemini, openaiProvider.ProviderType)
932+
933+ customProvider := cfg.Providers[customProviderID]
934+ assert.Equal(t, "custom-key", customProvider.APIKey)
935+ assert.Equal(t, "https://updated-custom.api.com", customProvider.BaseURL)
936+ assert.Equal(t, provider.TypeOpenAI, customProvider.ProviderType)
937+}
938+
939+func TestProviderValidation_CustomProviderMissingBaseURL(t *testing.T) {
940+ reset()
941+ testConfigDir = t.TempDir()
942+ cwdDir := t.TempDir()
943+
944+ customProviderID := provider.InferenceProvider("custom-provider")
945+
946+ globalConfig := Config{
947+ Providers: map[provider.InferenceProvider]ProviderConfig{
948+ customProviderID: {
949+ ID: customProviderID,
950+ APIKey: "custom-key",
951+ ProviderType: provider.TypeOpenAI,
952+ },
953+ },
954+ }
955+
956+ configPath := filepath.Join(testConfigDir, "crush.json")
957+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
958+ data, err := json.Marshal(globalConfig)
959+ require.NoError(t, err)
960+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
961+
962+ cfg, err := Init(cwdDir, false)
963+
964+ require.NoError(t, err)
965+ assert.NotContains(t, cfg.Providers, customProviderID)
966+}
967+
968+func TestProviderValidation_CustomProviderMissingAPIKey(t *testing.T) {
969+ reset()
970+ testConfigDir = t.TempDir()
971+ cwdDir := t.TempDir()
972+
973+ customProviderID := provider.InferenceProvider("custom-provider")
974+
975+ globalConfig := Config{
976+ Providers: map[provider.InferenceProvider]ProviderConfig{
977+ customProviderID: {
978+ ID: customProviderID,
979+ BaseURL: "https://custom.api.com",
980+ ProviderType: provider.TypeOpenAI,
981+ },
982+ },
983+ }
984+
985+ configPath := filepath.Join(testConfigDir, "crush.json")
986+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
987+ data, err := json.Marshal(globalConfig)
988+ require.NoError(t, err)
989+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
990+
991+ cfg, err := Init(cwdDir, false)
992+
993+ require.NoError(t, err)
994+ assert.NotContains(t, cfg.Providers, customProviderID)
995+}
996+
997+func TestProviderValidation_CustomProviderInvalidType(t *testing.T) {
998+ reset()
999+ testConfigDir = t.TempDir()
1000+ cwdDir := t.TempDir()
1001+
1002+ customProviderID := provider.InferenceProvider("custom-provider")
1003+
1004+ globalConfig := Config{
1005+ Providers: map[provider.InferenceProvider]ProviderConfig{
1006+ customProviderID: {
1007+ ID: customProviderID,
1008+ APIKey: "custom-key",
1009+ BaseURL: "https://custom.api.com",
1010+ ProviderType: provider.Type("invalid-type"),
1011+ },
1012+ },
1013+ }
1014+
1015+ configPath := filepath.Join(testConfigDir, "crush.json")
1016+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1017+ data, err := json.Marshal(globalConfig)
1018+ require.NoError(t, err)
1019+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1020+
1021+ cfg, err := Init(cwdDir, false)
1022+
1023+ require.NoError(t, err)
1024+ assert.NotContains(t, cfg.Providers, customProviderID)
1025+}
1026+
1027+func TestProviderValidation_KnownProviderValid(t *testing.T) {
1028+ reset()
1029+ testConfigDir = t.TempDir()
1030+ cwdDir := t.TempDir()
1031+
1032+ globalConfig := Config{
1033+ Providers: map[provider.InferenceProvider]ProviderConfig{
1034+ provider.InferenceProviderOpenAI: {
1035+ ID: provider.InferenceProviderOpenAI,
1036+ APIKey: "openai-key",
1037+ ProviderType: provider.TypeOpenAI,
1038+ DefaultLargeModel: "gpt-4",
1039+ DefaultSmallModel: "gpt-3.5-turbo",
1040+ Models: []Model{
1041+ {
1042+ ID: "gpt-4",
1043+ Name: "GPT-4",
1044+ ContextWindow: 8192,
1045+ DefaultMaxTokens: 4096,
1046+ },
1047+ {
1048+ ID: "gpt-3.5-turbo",
1049+ Name: "GPT-3.5 Turbo",
1050+ ContextWindow: 4096,
1051+ DefaultMaxTokens: 2048,
1052+ },
1053+ },
1054+ },
1055+ },
1056+ }
1057+
1058+ configPath := filepath.Join(testConfigDir, "crush.json")
1059+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1060+ data, err := json.Marshal(globalConfig)
1061+ require.NoError(t, err)
1062+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1063+
1064+ cfg, err := Init(cwdDir, false)
1065+
1066+ require.NoError(t, err)
1067+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1068+}
1069+
1070+func TestProviderValidation_DisabledProvider(t *testing.T) {
1071+ reset()
1072+ testConfigDir = t.TempDir()
1073+ cwdDir := t.TempDir()
1074+
1075+ globalConfig := Config{
1076+ Providers: map[provider.InferenceProvider]ProviderConfig{
1077+ provider.InferenceProviderOpenAI: {
1078+ ID: provider.InferenceProviderOpenAI,
1079+ APIKey: "openai-key",
1080+ ProviderType: provider.TypeOpenAI,
1081+ Disabled: true,
1082+ DefaultLargeModel: "gpt-4",
1083+ DefaultSmallModel: "gpt-3.5-turbo",
1084+ Models: []Model{
1085+ {
1086+ ID: "gpt-4",
1087+ Name: "GPT-4",
1088+ ContextWindow: 8192,
1089+ DefaultMaxTokens: 4096,
1090+ },
1091+ {
1092+ ID: "gpt-3.5-turbo",
1093+ Name: "GPT-3.5 Turbo",
1094+ ContextWindow: 4096,
1095+ DefaultMaxTokens: 2048,
1096+ },
1097+ },
1098+ },
1099+ provider.InferenceProviderAnthropic: {
1100+ ID: provider.InferenceProviderAnthropic,
1101+ APIKey: "anthropic-key",
1102+ ProviderType: provider.TypeAnthropic,
1103+ Disabled: false, // This one is enabled
1104+ DefaultLargeModel: "claude-3-opus",
1105+ DefaultSmallModel: "claude-3-haiku",
1106+ Models: []Model{
1107+ {
1108+ ID: "claude-3-opus",
1109+ Name: "Claude 3 Opus",
1110+ ContextWindow: 200000,
1111+ DefaultMaxTokens: 4096,
1112+ },
1113+ {
1114+ ID: "claude-3-haiku",
1115+ Name: "Claude 3 Haiku",
1116+ ContextWindow: 200000,
1117+ DefaultMaxTokens: 4096,
1118+ },
1119+ },
1120+ },
1121+ },
1122+ }
1123+
1124+ configPath := filepath.Join(testConfigDir, "crush.json")
1125+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1126+ data, err := json.Marshal(globalConfig)
1127+ require.NoError(t, err)
1128+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1129+
1130+ cfg, err := Init(cwdDir, false)
1131+
1132+ require.NoError(t, err)
1133+ assert.Contains(t, cfg.Providers, provider.InferenceProviderOpenAI)
1134+ assert.True(t, cfg.Providers[provider.InferenceProviderOpenAI].Disabled)
1135+ assert.Contains(t, cfg.Providers, provider.InferenceProviderAnthropic)
1136+ assert.False(t, cfg.Providers[provider.InferenceProviderAnthropic].Disabled)
1137+}
1138+
1139+func TestProviderModels_AddingNewModels(t *testing.T) {
1140+ reset()
1141+ testConfigDir = t.TempDir()
1142+ cwdDir := t.TempDir()
1143+
1144+ globalConfig := Config{
1145+ Providers: map[provider.InferenceProvider]ProviderConfig{
1146+ provider.InferenceProviderOpenAI: {
1147+ ID: provider.InferenceProviderOpenAI,
1148+ APIKey: "openai-key",
1149+ ProviderType: provider.TypeOpenAI,
1150+ DefaultLargeModel: "gpt-4",
1151+ DefaultSmallModel: "gpt-4-turbo",
1152+ Models: []Model{
1153+ {
1154+ ID: "gpt-4",
1155+ Name: "GPT-4",
1156+ ContextWindow: 8192,
1157+ DefaultMaxTokens: 4096,
1158+ },
1159+ },
1160+ },
1161+ },
1162+ }
1163+
1164+ localConfig := Config{
1165+ Providers: map[provider.InferenceProvider]ProviderConfig{
1166+ provider.InferenceProviderOpenAI: {
1167+ Models: []Model{
1168+ {
1169+ ID: "gpt-4-turbo",
1170+ Name: "GPT-4 Turbo",
1171+ ContextWindow: 128000,
1172+ DefaultMaxTokens: 4096,
1173+ },
1174+ },
1175+ },
1176+ },
1177+ }
1178+
1179+ configPath := filepath.Join(testConfigDir, "crush.json")
1180+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1181+ data, err := json.Marshal(globalConfig)
1182+ require.NoError(t, err)
1183+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1184+
1185+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1186+ data, err = json.Marshal(localConfig)
1187+ require.NoError(t, err)
1188+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1189+
1190+ cfg, err := Init(cwdDir, false)
1191+
1192+ require.NoError(t, err)
1193+
1194+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1195+ assert.Len(t, openaiProvider.Models, 2)
1196+
1197+ modelIDs := make([]string, len(openaiProvider.Models))
1198+ for i, model := range openaiProvider.Models {
1199+ modelIDs[i] = model.ID
1200+ }
1201+ assert.Contains(t, modelIDs, "gpt-4")
1202+ assert.Contains(t, modelIDs, "gpt-4-turbo")
1203+}
1204+
1205+func TestProviderModels_DuplicateModelHandling(t *testing.T) {
1206+ reset()
1207+ testConfigDir = t.TempDir()
1208+ cwdDir := t.TempDir()
1209+
1210+ globalConfig := Config{
1211+ Providers: map[provider.InferenceProvider]ProviderConfig{
1212+ provider.InferenceProviderOpenAI: {
1213+ ID: provider.InferenceProviderOpenAI,
1214+ APIKey: "openai-key",
1215+ ProviderType: provider.TypeOpenAI,
1216+ DefaultLargeModel: "gpt-4",
1217+ DefaultSmallModel: "gpt-4",
1218+ Models: []Model{
1219+ {
1220+ ID: "gpt-4",
1221+ Name: "GPT-4",
1222+ ContextWindow: 8192,
1223+ DefaultMaxTokens: 4096,
1224+ },
1225+ },
1226+ },
1227+ },
1228+ }
1229+
1230+ localConfig := Config{
1231+ Providers: map[provider.InferenceProvider]ProviderConfig{
1232+ provider.InferenceProviderOpenAI: {
1233+ Models: []Model{
1234+ {
1235+ ID: "gpt-4",
1236+ Name: "GPT-4 Updated",
1237+ ContextWindow: 16384,
1238+ DefaultMaxTokens: 8192,
1239+ },
1240+ },
1241+ },
1242+ },
1243+ }
1244+
1245+ configPath := filepath.Join(testConfigDir, "crush.json")
1246+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1247+ data, err := json.Marshal(globalConfig)
1248+ require.NoError(t, err)
1249+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1250+
1251+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1252+ data, err = json.Marshal(localConfig)
1253+ require.NoError(t, err)
1254+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1255+
1256+ cfg, err := Init(cwdDir, false)
1257+
1258+ require.NoError(t, err)
1259+
1260+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1261+ assert.Len(t, openaiProvider.Models, 1)
1262+
1263+ model := openaiProvider.Models[0]
1264+ assert.Equal(t, "gpt-4", model.ID)
1265+ assert.Equal(t, "GPT-4", model.Name)
1266+ assert.Equal(t, int64(8192), model.ContextWindow)
1267+}
1268+
1269+func TestProviderModels_ModelCostAndCapabilities(t *testing.T) {
1270+ reset()
1271+ testConfigDir = t.TempDir()
1272+ cwdDir := t.TempDir()
1273+
1274+ globalConfig := Config{
1275+ Providers: map[provider.InferenceProvider]ProviderConfig{
1276+ provider.InferenceProviderOpenAI: {
1277+ ID: provider.InferenceProviderOpenAI,
1278+ APIKey: "openai-key",
1279+ ProviderType: provider.TypeOpenAI,
1280+ DefaultLargeModel: "gpt-4",
1281+ DefaultSmallModel: "gpt-4",
1282+ Models: []Model{
1283+ {
1284+ ID: "gpt-4",
1285+ Name: "GPT-4",
1286+ CostPer1MIn: 30.0,
1287+ CostPer1MOut: 60.0,
1288+ CostPer1MInCached: 15.0,
1289+ CostPer1MOutCached: 30.0,
1290+ ContextWindow: 8192,
1291+ DefaultMaxTokens: 4096,
1292+ CanReason: true,
1293+ ReasoningEffort: "medium",
1294+ SupportsImages: true,
1295+ },
1296+ },
1297+ },
1298+ },
1299+ }
1300+
1301+ configPath := filepath.Join(testConfigDir, "crush.json")
1302+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1303+ data, err := json.Marshal(globalConfig)
1304+ require.NoError(t, err)
1305+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1306+
1307+ cfg, err := Init(cwdDir, false)
1308+
1309+ require.NoError(t, err)
1310+
1311+ openaiProvider := cfg.Providers[provider.InferenceProviderOpenAI]
1312+ require.Len(t, openaiProvider.Models, 1)
1313+
1314+ model := openaiProvider.Models[0]
1315+ assert.Equal(t, 30.0, model.CostPer1MIn)
1316+ assert.Equal(t, 60.0, model.CostPer1MOut)
1317+ assert.Equal(t, 15.0, model.CostPer1MInCached)
1318+ assert.Equal(t, 30.0, model.CostPer1MOutCached)
1319+ assert.True(t, model.CanReason)
1320+ assert.Equal(t, "medium", model.ReasoningEffort)
1321+ assert.True(t, model.SupportsImages)
1322+}
1323+
1324+func TestDefaultAgents_CoderAgent(t *testing.T) {
1325+ reset()
1326+ testConfigDir = t.TempDir()
1327+ cwdDir := t.TempDir()
1328+
1329+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1330+
1331+ cfg, err := Init(cwdDir, false)
1332+
1333+ require.NoError(t, err)
1334+ assert.Contains(t, cfg.Agents, AgentCoder)
1335+
1336+ coderAgent := cfg.Agents[AgentCoder]
1337+ assert.Equal(t, AgentCoder, coderAgent.ID)
1338+ assert.Equal(t, "Coder", coderAgent.Name)
1339+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1340+ assert.Equal(t, LargeModel, coderAgent.Model)
1341+ assert.False(t, coderAgent.Disabled)
1342+ assert.Equal(t, cfg.Options.ContextPaths, coderAgent.ContextPaths)
1343+ assert.Nil(t, coderAgent.AllowedTools)
1344+}
1345+
1346+func TestDefaultAgents_TaskAgent(t *testing.T) {
1347+ reset()
1348+ testConfigDir = t.TempDir()
1349+ cwdDir := t.TempDir()
1350+
1351+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1352+
1353+ cfg, err := Init(cwdDir, false)
1354+
1355+ require.NoError(t, err)
1356+ assert.Contains(t, cfg.Agents, AgentTask)
1357+
1358+ taskAgent := cfg.Agents[AgentTask]
1359+ assert.Equal(t, AgentTask, taskAgent.ID)
1360+ assert.Equal(t, "Task", taskAgent.Name)
1361+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1362+ assert.Equal(t, LargeModel, taskAgent.Model)
1363+ assert.False(t, taskAgent.Disabled)
1364+ assert.Equal(t, cfg.Options.ContextPaths, taskAgent.ContextPaths)
1365+
1366+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1367+ assert.Equal(t, expectedTools, taskAgent.AllowedTools)
1368+
1369+ assert.Equal(t, map[string][]string{}, taskAgent.AllowedMCP)
1370+ assert.Equal(t, []string{}, taskAgent.AllowedLSP)
1371+}
1372+
1373+func TestAgentMerging_CustomAgent(t *testing.T) {
1374+ reset()
1375+ testConfigDir = t.TempDir()
1376+ cwdDir := t.TempDir()
1377+
1378+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1379+
1380+ globalConfig := Config{
1381+ Agents: map[AgentID]Agent{
1382+ AgentID("custom-agent"): {
1383+ ID: AgentID("custom-agent"),
1384+ Name: "Custom Agent",
1385+ Description: "A custom agent for testing",
1386+ Model: SmallModel,
1387+ AllowedTools: []string{"glob", "grep"},
1388+ AllowedMCP: map[string][]string{"mcp1": {"tool1", "tool2"}},
1389+ AllowedLSP: []string{"typescript", "go"},
1390+ ContextPaths: []string{"custom-context.md"},
1391+ },
1392+ },
1393+ MCP: map[string]MCP{
1394+ "mcp1": {
1395+ Type: MCPStdio,
1396+ Command: "test-mcp-command",
1397+ Args: []string{"--test"},
1398+ },
1399+ },
1400+ LSP: map[string]LSPConfig{
1401+ "typescript": {
1402+ Command: "typescript-language-server",
1403+ Args: []string{"--stdio"},
1404+ },
1405+ "go": {
1406+ Command: "gopls",
1407+ Args: []string{},
1408+ },
1409+ },
1410+ }
1411+
1412+ configPath := filepath.Join(testConfigDir, "crush.json")
1413+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1414+ data, err := json.Marshal(globalConfig)
1415+ require.NoError(t, err)
1416+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1417+
1418+ cfg, err := Init(cwdDir, false)
1419+
1420+ require.NoError(t, err)
1421+
1422+ assert.Contains(t, cfg.Agents, AgentCoder)
1423+ assert.Contains(t, cfg.Agents, AgentTask)
1424+ assert.Contains(t, cfg.Agents, AgentID("custom-agent"))
1425+
1426+ customAgent := cfg.Agents[AgentID("custom-agent")]
1427+ assert.Equal(t, "Custom Agent", customAgent.Name)
1428+ assert.Equal(t, "A custom agent for testing", customAgent.Description)
1429+ assert.Equal(t, SmallModel, customAgent.Model)
1430+ assert.Equal(t, []string{"glob", "grep"}, customAgent.AllowedTools)
1431+ assert.Equal(t, map[string][]string{"mcp1": {"tool1", "tool2"}}, customAgent.AllowedMCP)
1432+ assert.Equal(t, []string{"typescript", "go"}, customAgent.AllowedLSP)
1433+ expectedContextPaths := append(defaultContextPaths, "custom-context.md")
1434+ assert.Equal(t, expectedContextPaths, customAgent.ContextPaths)
1435+}
1436+
1437+func TestAgentMerging_ModifyDefaultCoderAgent(t *testing.T) {
1438+ reset()
1439+ testConfigDir = t.TempDir()
1440+ cwdDir := t.TempDir()
1441+
1442+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1443+
1444+ globalConfig := Config{
1445+ Agents: map[AgentID]Agent{
1446+ AgentCoder: {
1447+ Model: SmallModel,
1448+ AllowedMCP: map[string][]string{"mcp1": {"tool1"}},
1449+ AllowedLSP: []string{"typescript"},
1450+ ContextPaths: []string{"coder-specific.md"},
1451+ },
1452+ },
1453+ MCP: map[string]MCP{
1454+ "mcp1": {
1455+ Type: MCPStdio,
1456+ Command: "test-mcp-command",
1457+ Args: []string{"--test"},
1458+ },
1459+ },
1460+ LSP: map[string]LSPConfig{
1461+ "typescript": {
1462+ Command: "typescript-language-server",
1463+ Args: []string{"--stdio"},
1464+ },
1465+ },
1466+ }
1467+
1468+ configPath := filepath.Join(testConfigDir, "crush.json")
1469+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1470+ data, err := json.Marshal(globalConfig)
1471+ require.NoError(t, err)
1472+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1473+
1474+ cfg, err := Init(cwdDir, false)
1475+
1476+ require.NoError(t, err)
1477+
1478+ coderAgent := cfg.Agents[AgentCoder]
1479+ assert.Equal(t, AgentCoder, coderAgent.ID)
1480+ assert.Equal(t, "Coder", coderAgent.Name)
1481+ assert.Equal(t, "An agent that helps with executing coding tasks.", coderAgent.Description)
1482+
1483+ expectedContextPaths := append(cfg.Options.ContextPaths, "coder-specific.md")
1484+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1485+
1486+ assert.Equal(t, SmallModel, coderAgent.Model)
1487+ assert.Equal(t, map[string][]string{"mcp1": {"tool1"}}, coderAgent.AllowedMCP)
1488+ assert.Equal(t, []string{"typescript"}, coderAgent.AllowedLSP)
1489+}
1490+
1491+func TestAgentMerging_ModifyDefaultTaskAgent(t *testing.T) {
1492+ reset()
1493+ testConfigDir = t.TempDir()
1494+ cwdDir := t.TempDir()
1495+
1496+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1497+
1498+ globalConfig := Config{
1499+ Agents: map[AgentID]Agent{
1500+ AgentTask: {
1501+ Model: SmallModel,
1502+ AllowedMCP: map[string][]string{"search-mcp": nil},
1503+ AllowedLSP: []string{"python"},
1504+ Name: "Search Agent",
1505+ Description: "Custom search agent",
1506+ Disabled: true,
1507+ AllowedTools: []string{"glob", "grep", "view"},
1508+ },
1509+ },
1510+ MCP: map[string]MCP{
1511+ "search-mcp": {
1512+ Type: MCPStdio,
1513+ Command: "search-mcp-command",
1514+ Args: []string{"--search"},
1515+ },
1516+ },
1517+ LSP: map[string]LSPConfig{
1518+ "python": {
1519+ Command: "pylsp",
1520+ Args: []string{},
1521+ },
1522+ },
1523+ }
1524+
1525+ configPath := filepath.Join(testConfigDir, "crush.json")
1526+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1527+ data, err := json.Marshal(globalConfig)
1528+ require.NoError(t, err)
1529+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1530+
1531+ cfg, err := Init(cwdDir, false)
1532+
1533+ require.NoError(t, err)
1534+
1535+ taskAgent := cfg.Agents[AgentTask]
1536+ assert.Equal(t, "Task", taskAgent.Name)
1537+ assert.Equal(t, "An agent that helps with searching for context and finding implementation details.", taskAgent.Description)
1538+ assert.False(t, taskAgent.Disabled)
1539+ assert.Equal(t, []string{"glob", "grep", "ls", "sourcegraph", "view"}, taskAgent.AllowedTools)
1540+
1541+ assert.Equal(t, SmallModel, taskAgent.Model)
1542+ assert.Equal(t, map[string][]string{"search-mcp": nil}, taskAgent.AllowedMCP)
1543+ assert.Equal(t, []string{"python"}, taskAgent.AllowedLSP)
1544+}
1545+
1546+func TestAgentMerging_LocalOverridesGlobal(t *testing.T) {
1547+ reset()
1548+ testConfigDir = t.TempDir()
1549+ cwdDir := t.TempDir()
1550+
1551+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1552+
1553+ globalConfig := Config{
1554+ Agents: map[AgentID]Agent{
1555+ AgentID("test-agent"): {
1556+ ID: AgentID("test-agent"),
1557+ Name: "Global Agent",
1558+ Description: "Global description",
1559+ Model: LargeModel,
1560+ AllowedTools: []string{"glob"},
1561+ },
1562+ },
1563+ }
1564+
1565+ configPath := filepath.Join(testConfigDir, "crush.json")
1566+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1567+ data, err := json.Marshal(globalConfig)
1568+ require.NoError(t, err)
1569+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1570+
1571+ // Create local config that overrides
1572+ localConfig := Config{
1573+ Agents: map[AgentID]Agent{
1574+ AgentID("test-agent"): {
1575+ Name: "Local Agent",
1576+ Description: "Local description",
1577+ Model: SmallModel,
1578+ Disabled: true,
1579+ AllowedTools: []string{"grep", "view"},
1580+ AllowedMCP: map[string][]string{"local-mcp": {"tool1"}},
1581+ },
1582+ },
1583+ MCP: map[string]MCP{
1584+ "local-mcp": {
1585+ Type: MCPStdio,
1586+ Command: "local-mcp-command",
1587+ Args: []string{"--local"},
1588+ },
1589+ },
1590+ }
1591+
1592+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1593+ data, err = json.Marshal(localConfig)
1594+ require.NoError(t, err)
1595+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1596+
1597+ cfg, err := Init(cwdDir, false)
1598+
1599+ require.NoError(t, err)
1600+
1601+ testAgent := cfg.Agents[AgentID("test-agent")]
1602+ assert.Equal(t, "Local Agent", testAgent.Name)
1603+ assert.Equal(t, "Local description", testAgent.Description)
1604+ assert.Equal(t, SmallModel, testAgent.Model)
1605+ assert.True(t, testAgent.Disabled)
1606+ assert.Equal(t, []string{"grep", "view"}, testAgent.AllowedTools)
1607+ assert.Equal(t, map[string][]string{"local-mcp": {"tool1"}}, testAgent.AllowedMCP)
1608+}
1609+
1610+func TestAgentModelTypeAssignment(t *testing.T) {
1611+ reset()
1612+ testConfigDir = t.TempDir()
1613+ cwdDir := t.TempDir()
1614+
1615+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1616+
1617+ globalConfig := Config{
1618+ Agents: map[AgentID]Agent{
1619+ AgentID("large-agent"): {
1620+ ID: AgentID("large-agent"),
1621+ Name: "Large Model Agent",
1622+ Model: LargeModel,
1623+ },
1624+ AgentID("small-agent"): {
1625+ ID: AgentID("small-agent"),
1626+ Name: "Small Model Agent",
1627+ Model: SmallModel,
1628+ },
1629+ AgentID("default-agent"): {
1630+ ID: AgentID("default-agent"),
1631+ Name: "Default Model Agent",
1632+ },
1633+ },
1634+ }
1635+
1636+ configPath := filepath.Join(testConfigDir, "crush.json")
1637+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1638+ data, err := json.Marshal(globalConfig)
1639+ require.NoError(t, err)
1640+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1641+
1642+ cfg, err := Init(cwdDir, false)
1643+
1644+ require.NoError(t, err)
1645+
1646+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("large-agent")].Model)
1647+ assert.Equal(t, SmallModel, cfg.Agents[AgentID("small-agent")].Model)
1648+ assert.Equal(t, LargeModel, cfg.Agents[AgentID("default-agent")].Model)
1649+}
1650+
1651+func TestAgentContextPathOverrides(t *testing.T) {
1652+ reset()
1653+ testConfigDir = t.TempDir()
1654+ cwdDir := t.TempDir()
1655+
1656+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1657+
1658+ globalConfig := Config{
1659+ Options: Options{
1660+ ContextPaths: []string{"global-context.md", "shared-context.md"},
1661+ },
1662+ Agents: map[AgentID]Agent{
1663+ AgentID("custom-context-agent"): {
1664+ ID: AgentID("custom-context-agent"),
1665+ Name: "Custom Context Agent",
1666+ ContextPaths: []string{"agent-specific.md", "custom.md"},
1667+ },
1668+ AgentID("default-context-agent"): {
1669+ ID: AgentID("default-context-agent"),
1670+ Name: "Default Context Agent",
1671+ },
1672+ },
1673+ }
1674+
1675+ configPath := filepath.Join(testConfigDir, "crush.json")
1676+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1677+ data, err := json.Marshal(globalConfig)
1678+ require.NoError(t, err)
1679+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1680+
1681+ cfg, err := Init(cwdDir, false)
1682+
1683+ require.NoError(t, err)
1684+
1685+ customAgent := cfg.Agents[AgentID("custom-context-agent")]
1686+ expectedCustomPaths := append(defaultContextPaths, "global-context.md", "shared-context.md", "agent-specific.md", "custom.md")
1687+ assert.Equal(t, expectedCustomPaths, customAgent.ContextPaths)
1688+
1689+ defaultAgent := cfg.Agents[AgentID("default-context-agent")]
1690+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "shared-context.md")
1691+ assert.Equal(t, expectedContextPaths, defaultAgent.ContextPaths)
1692+
1693+ coderAgent := cfg.Agents[AgentCoder]
1694+ assert.Equal(t, expectedContextPaths, coderAgent.ContextPaths)
1695+}
1696+
1697+func TestOptionsMerging_ContextPaths(t *testing.T) {
1698+ reset()
1699+ testConfigDir = t.TempDir()
1700+ cwdDir := t.TempDir()
1701+
1702+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1703+
1704+ globalConfig := Config{
1705+ Options: Options{
1706+ ContextPaths: []string{"global1.md", "global2.md"},
1707+ },
1708+ }
1709+
1710+ configPath := filepath.Join(testConfigDir, "crush.json")
1711+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1712+ data, err := json.Marshal(globalConfig)
1713+ require.NoError(t, err)
1714+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1715+
1716+ localConfig := Config{
1717+ Options: Options{
1718+ ContextPaths: []string{"local1.md", "local2.md"},
1719+ },
1720+ }
1721+
1722+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1723+ data, err = json.Marshal(localConfig)
1724+ require.NoError(t, err)
1725+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1726+
1727+ cfg, err := Init(cwdDir, false)
1728+
1729+ require.NoError(t, err)
1730+
1731+ expectedContextPaths := append(defaultContextPaths, "global1.md", "global2.md", "local1.md", "local2.md")
1732+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1733+}
1734+
1735+func TestOptionsMerging_TUIOptions(t *testing.T) {
1736+ reset()
1737+ testConfigDir = t.TempDir()
1738+ cwdDir := t.TempDir()
1739+
1740+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1741+
1742+ globalConfig := Config{
1743+ Options: Options{
1744+ TUI: TUIOptions{
1745+ CompactMode: false,
1746+ },
1747+ },
1748+ }
1749+
1750+ configPath := filepath.Join(testConfigDir, "crush.json")
1751+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1752+ data, err := json.Marshal(globalConfig)
1753+ require.NoError(t, err)
1754+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1755+
1756+ localConfig := Config{
1757+ Options: Options{
1758+ TUI: TUIOptions{
1759+ CompactMode: true,
1760+ },
1761+ },
1762+ }
1763+
1764+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1765+ data, err = json.Marshal(localConfig)
1766+ require.NoError(t, err)
1767+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1768+
1769+ cfg, err := Init(cwdDir, false)
1770+
1771+ require.NoError(t, err)
1772+
1773+ assert.True(t, cfg.Options.TUI.CompactMode)
1774+}
1775+
1776+func TestOptionsMerging_DebugFlags(t *testing.T) {
1777+ reset()
1778+ testConfigDir = t.TempDir()
1779+ cwdDir := t.TempDir()
1780+
1781+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1782+
1783+ globalConfig := Config{
1784+ Options: Options{
1785+ Debug: false,
1786+ DebugLSP: false,
1787+ DisableAutoSummarize: false,
1788+ },
1789+ }
1790+
1791+ configPath := filepath.Join(testConfigDir, "crush.json")
1792+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1793+ data, err := json.Marshal(globalConfig)
1794+ require.NoError(t, err)
1795+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1796+
1797+ localConfig := Config{
1798+ Options: Options{
1799+ DebugLSP: true,
1800+ DisableAutoSummarize: true,
1801+ },
1802+ }
1803+
1804+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1805+ data, err = json.Marshal(localConfig)
1806+ require.NoError(t, err)
1807+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1808+
1809+ cfg, err := Init(cwdDir, false)
1810+
1811+ require.NoError(t, err)
1812+
1813+ assert.False(t, cfg.Options.Debug)
1814+ assert.True(t, cfg.Options.DebugLSP)
1815+ assert.True(t, cfg.Options.DisableAutoSummarize)
1816+}
1817+
1818+func TestOptionsMerging_DataDirectory(t *testing.T) {
1819+ reset()
1820+ testConfigDir = t.TempDir()
1821+ cwdDir := t.TempDir()
1822+
1823+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1824+
1825+ globalConfig := Config{
1826+ Options: Options{
1827+ DataDirectory: "global-data",
1828+ },
1829+ }
1830+
1831+ configPath := filepath.Join(testConfigDir, "crush.json")
1832+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1833+ data, err := json.Marshal(globalConfig)
1834+ require.NoError(t, err)
1835+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1836+
1837+ localConfig := Config{
1838+ Options: Options{
1839+ DataDirectory: "local-data",
1840+ },
1841+ }
1842+
1843+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1844+ data, err = json.Marshal(localConfig)
1845+ require.NoError(t, err)
1846+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1847+
1848+ cfg, err := Init(cwdDir, false)
1849+
1850+ require.NoError(t, err)
1851+
1852+ assert.Equal(t, "local-data", cfg.Options.DataDirectory)
1853+}
1854+
1855+func TestOptionsMerging_DefaultValues(t *testing.T) {
1856+ reset()
1857+ testConfigDir = t.TempDir()
1858+ cwdDir := t.TempDir()
1859+
1860+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1861+
1862+ cfg, err := Init(cwdDir, false)
1863+
1864+ require.NoError(t, err)
1865+
1866+ assert.Equal(t, defaultDataDirectory, cfg.Options.DataDirectory)
1867+ assert.Equal(t, defaultContextPaths, cfg.Options.ContextPaths)
1868+ assert.False(t, cfg.Options.TUI.CompactMode)
1869+ assert.False(t, cfg.Options.Debug)
1870+ assert.False(t, cfg.Options.DebugLSP)
1871+ assert.False(t, cfg.Options.DisableAutoSummarize)
1872+}
1873+
1874+func TestOptionsMerging_DebugFlagFromInit(t *testing.T) {
1875+ reset()
1876+ testConfigDir = t.TempDir()
1877+ cwdDir := t.TempDir()
1878+
1879+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1880+
1881+ globalConfig := Config{
1882+ Options: Options{
1883+ Debug: false,
1884+ },
1885+ }
1886+
1887+ configPath := filepath.Join(testConfigDir, "crush.json")
1888+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1889+ data, err := json.Marshal(globalConfig)
1890+ require.NoError(t, err)
1891+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1892+
1893+ cfg, err := Init(cwdDir, true)
1894+
1895+ require.NoError(t, err)
1896+
1897+ // Debug flag from Init should take precedence
1898+ assert.True(t, cfg.Options.Debug)
1899+}
1900+
1901+func TestOptionsMerging_ComplexScenario(t *testing.T) {
1902+ reset()
1903+ testConfigDir = t.TempDir()
1904+ cwdDir := t.TempDir()
1905+
1906+ // Set up a provider
1907+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
1908+
1909+ // Create global config with various options
1910+ globalConfig := Config{
1911+ Options: Options{
1912+ ContextPaths: []string{"global-context.md"},
1913+ DataDirectory: "global-data",
1914+ Debug: false,
1915+ DebugLSP: false,
1916+ DisableAutoSummarize: false,
1917+ TUI: TUIOptions{
1918+ CompactMode: false,
1919+ },
1920+ },
1921+ }
1922+
1923+ configPath := filepath.Join(testConfigDir, "crush.json")
1924+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
1925+ data, err := json.Marshal(globalConfig)
1926+ require.NoError(t, err)
1927+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
1928+
1929+ // Create local config that partially overrides
1930+ localConfig := Config{
1931+ Options: Options{
1932+ ContextPaths: []string{"local-context.md"},
1933+ DebugLSP: true, // Override
1934+ DisableAutoSummarize: true, // Override
1935+ TUI: TUIOptions{
1936+ CompactMode: true, // Override
1937+ },
1938+ // DataDirectory and Debug not specified - should keep global values
1939+ },
1940+ }
1941+
1942+ localConfigPath := filepath.Join(cwdDir, "crush.json")
1943+ data, err = json.Marshal(localConfig)
1944+ require.NoError(t, err)
1945+ require.NoError(t, os.WriteFile(localConfigPath, data, 0o644))
1946+
1947+ cfg, err := Init(cwdDir, false)
1948+
1949+ require.NoError(t, err)
1950+
1951+ // Check merged results
1952+ expectedContextPaths := append(defaultContextPaths, "global-context.md", "local-context.md")
1953+ assert.Equal(t, expectedContextPaths, cfg.Options.ContextPaths)
1954+ assert.Equal(t, "global-data", cfg.Options.DataDirectory) // From global
1955+ assert.False(t, cfg.Options.Debug) // From global
1956+ assert.True(t, cfg.Options.DebugLSP) // From local
1957+ assert.True(t, cfg.Options.DisableAutoSummarize) // From local
1958+ assert.True(t, cfg.Options.TUI.CompactMode) // From local
1959+}
1960+
1961+// Model Selection Tests
1962+
1963+func TestModelSelection_PreferredModelSelection(t *testing.T) {
1964+ reset()
1965+ testConfigDir = t.TempDir()
1966+ cwdDir := t.TempDir()
1967+
1968+ // Set up multiple providers to test selection logic
1969+ os.Setenv("ANTHROPIC_API_KEY", "test-anthropic-key")
1970+ os.Setenv("OPENAI_API_KEY", "test-openai-key")
1971+
1972+ cfg, err := Init(cwdDir, false)
1973+
1974+ require.NoError(t, err)
1975+ require.Len(t, cfg.Providers, 2)
1976+
1977+ // Should have preferred models set
1978+ assert.NotEmpty(t, cfg.Models.Large.ModelID)
1979+ assert.NotEmpty(t, cfg.Models.Large.Provider)
1980+ assert.NotEmpty(t, cfg.Models.Small.ModelID)
1981+ assert.NotEmpty(t, cfg.Models.Small.Provider)
1982+
1983+ // Both should use the same provider (first available)
1984+ assert.Equal(t, cfg.Models.Large.Provider, cfg.Models.Small.Provider)
1985+}
1986+
1987+func TestValidation_InvalidModelReference(t *testing.T) {
1988+ reset()
1989+ testConfigDir = t.TempDir()
1990+ cwdDir := t.TempDir()
1991+
1992+ globalConfig := Config{
1993+ Providers: map[provider.InferenceProvider]ProviderConfig{
1994+ provider.InferenceProviderOpenAI: {
1995+ ID: provider.InferenceProviderOpenAI,
1996+ APIKey: "test-key",
1997+ ProviderType: provider.TypeOpenAI,
1998+ DefaultLargeModel: "non-existent-model",
1999+ DefaultSmallModel: "gpt-3.5-turbo",
2000+ Models: []Model{
2001+ {
2002+ ID: "gpt-3.5-turbo",
2003+ Name: "GPT-3.5 Turbo",
2004+ ContextWindow: 4096,
2005+ DefaultMaxTokens: 2048,
2006+ },
2007+ },
2008+ },
2009+ },
2010+ }
2011+
2012+ configPath := filepath.Join(testConfigDir, "crush.json")
2013+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2014+ data, err := json.Marshal(globalConfig)
2015+ require.NoError(t, err)
2016+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2017+
2018+ _, err = Init(cwdDir, false)
2019+ assert.Error(t, err)
2020+}
2021+
2022+func TestValidation_EmptyAPIKey(t *testing.T) {
2023+ reset()
2024+ testConfigDir = t.TempDir()
2025+ cwdDir := t.TempDir()
2026+
2027+ globalConfig := Config{
2028+ Providers: map[provider.InferenceProvider]ProviderConfig{
2029+ provider.InferenceProviderOpenAI: {
2030+ ID: provider.InferenceProviderOpenAI,
2031+ ProviderType: provider.TypeOpenAI,
2032+ Models: []Model{
2033+ {
2034+ ID: "gpt-4",
2035+ Name: "GPT-4",
2036+ ContextWindow: 8192,
2037+ DefaultMaxTokens: 4096,
2038+ },
2039+ },
2040+ },
2041+ },
2042+ }
2043+
2044+ configPath := filepath.Join(testConfigDir, "crush.json")
2045+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2046+ data, err := json.Marshal(globalConfig)
2047+ require.NoError(t, err)
2048+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2049+
2050+ _, err = Init(cwdDir, false)
2051+ assert.Error(t, err)
2052+}
2053+
2054+func TestValidation_InvalidAgentModelType(t *testing.T) {
2055+ reset()
2056+ testConfigDir = t.TempDir()
2057+ cwdDir := t.TempDir()
2058+
2059+ os.Setenv("ANTHROPIC_API_KEY", "test-key")
2060+
2061+ globalConfig := Config{
2062+ Agents: map[AgentID]Agent{
2063+ AgentID("invalid-agent"): {
2064+ ID: AgentID("invalid-agent"),
2065+ Name: "Invalid Agent",
2066+ Model: ModelType("invalid"),
2067+ },
2068+ },
2069+ }
2070+
2071+ configPath := filepath.Join(testConfigDir, "crush.json")
2072+ require.NoError(t, os.MkdirAll(filepath.Dir(configPath), 0o755))
2073+ data, err := json.Marshal(globalConfig)
2074+ require.NoError(t, err)
2075+ require.NoError(t, os.WriteFile(configPath, data, 0o644))
2076+
2077+ _, err = Init(cwdDir, false)
2078+ assert.Error(t, err)
2079+}