on main help sign in
feat: add config validation and provider system with mock support
commite16fc3deeb917fdc6b7b19e3e84557fe67c00f73 authorKujtim Hoxha <kujtimii.h@gmail.com> dateFri 27 Jun 2025, 17:46 (1 year ago) parentf2cb232
5 files changed+909−19
internal/config/config.go+517−7 internal/config/config_test.go+96−12 internal/config/provider.go+14 internal/config/provider_mock.go+177 internal/config/provider_test.go+105
internal/config/config.go+517−7
@@ -325,6 +325,11 @@ func loadConfig(cwd string, debug bool) (*Config, error) {
325325 mergeMCPs(cfg, globalCfg, localConfig)
326326 mergeLSPs(cfg, globalCfg, localConfig)
327327
328+ // Validate the final configuration
329+ if err := cfg.Validate(); err != nil {
330+ return cfg, fmt.Errorf("configuration validation failed: %w", err)
331+ }
332+
328333 return cfg, nil
329334}
330335
@@ -503,7 +508,7 @@ func mergeAgents(base, global, local *Config) {
503508 base.Agents[agentID] = newAgent
504509 } else {
505510 baseAgent := base.Agents[agentID]
506−
511+
507512 // Special handling for known agents - only allow model changes
508513 if agentID == AgentCoder || agentID == AgentTask {
509514 if newAgent.Model != "" {
@@ -533,10 +538,10 @@ func mergeAgents(base, global, local *Config) {
533538 } else if baseAgent.Model == "" {
534539 baseAgent.Model = LargeModel // Default fallback
535540 }
536−
541+
537542 // Boolean fields - always update (including false values)
538543 baseAgent.Disabled = newAgent.Disabled
539−
544+
540545 // Slice/Map fields - update if provided (including empty slices/maps)
541546 if newAgent.AllowedTools != nil {
542547 baseAgent.AllowedTools = newAgent.AllowedTools
@@ -552,7 +557,7 @@ func mergeAgents(base, global, local *Config) {
552557 baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
553558 }
554559 }
555−
560+
556561 base.Agents[agentID] = baseAgent
557562 }
558563 }
@@ -666,6 +671,9 @@ func defaultConfigBasedOnEnv() *Config {
666671 ContextPaths: defaultContextPaths,
667672 },
668673 Providers: make(map[provider.InferenceProvider]ProviderConfig),
674+ Agents: make(map[AgentID]Agent),
675+ LSP: make(map[string]LSPConfig),
676+ MCP: make(map[string]MCP),
669677 }
670678
671679 providers := Providers()
@@ -681,9 +689,7 @@ func defaultConfigBasedOnEnv() *Config {
681689 baseURL := p.APIEndpoint
682690 if strings.HasPrefix(baseURL, "$") {
683691 envVar := strings.TrimPrefix(baseURL, "$")
684− if url := os.Getenv(envVar); url != "" {
685− baseURL = url
686− }
692+ baseURL = os.Getenv(envVar)
687693 }
688694 providerConfig.BaseURL = baseURL
689695 for _, model := range p.Models {
@@ -871,3 +877,507 @@ func UpdatePreferredModel(modelType ModelType, model PreferredModel) error {
871877 }
872878 return nil
873879}
880+
881+// ValidationError represents a configuration validation error
882+type ValidationError struct {
883+ Field string
884+ Message string
885+}
886+
887+func (e ValidationError) Error() string {
888+ return fmt.Sprintf("validation error in %s: %s", e.Field, e.Message)
889+}
890+
891+// ValidationErrors represents multiple validation errors
892+type ValidationErrors []ValidationError
893+
894+func (e ValidationErrors) Error() string {
895+ if len(e) == 0 {
896+ return "no validation errors"
897+ }
898+ if len(e) == 1 {
899+ return e[0].Error()
900+ }
901+
902+ var messages []string
903+ for _, err := range e {
904+ messages = append(messages, err.Error())
905+ }
906+ return fmt.Sprintf("multiple validation errors: %s", strings.Join(messages, "; "))
907+}
908+
909+// HasErrors returns true if there are any validation errors
910+func (e ValidationErrors) HasErrors() bool {
911+ return len(e) > 0
912+}
913+
914+// Add appends a new validation error
915+func (e *ValidationErrors) Add(field, message string) {
916+ *e = append(*e, ValidationError{Field: field, Message: message})
917+}
918+
919+// Validate performs comprehensive validation of the configuration
920+func (c *Config) Validate() error {
921+ var errors ValidationErrors
922+
923+ // Validate providers
924+ c.validateProviders(&errors)
925+
926+ // Validate models
927+ c.validateModels(&errors)
928+
929+ // Validate agents
930+ c.validateAgents(&errors)
931+
932+ // Validate options
933+ c.validateOptions(&errors)
934+
935+ // Validate MCP configurations
936+ c.validateMCPs(&errors)
937+
938+ // Validate LSP configurations
939+ c.validateLSPs(&errors)
940+
941+ // Validate cross-references
942+ c.validateCrossReferences(&errors)
943+
944+ // Validate completeness
945+ c.validateCompleteness(&errors)
946+
947+ if errors.HasErrors() {
948+ return errors
949+ }
950+
951+ return nil
952+}
953+
954+// validateProviders validates all provider configurations
955+func (c *Config) validateProviders(errors *ValidationErrors) {
956+ if c.Providers == nil {
957+ c.Providers = make(map[provider.InferenceProvider]ProviderConfig)
958+ }
959+
960+ knownProviders := provider.KnownProviders()
961+ validTypes := []provider.Type{
962+ provider.TypeOpenAI,
963+ provider.TypeAnthropic,
964+ provider.TypeGemini,
965+ provider.TypeAzure,
966+ provider.TypeBedrock,
967+ provider.TypeVertexAI,
968+ provider.TypeXAI,
969+ }
970+
971+ for providerID, providerConfig := range c.Providers {
972+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
973+
974+ // Validate API key for non-disabled providers
975+ if !providerConfig.Disabled && providerConfig.APIKey == "" {
976+ // Special case for AWS Bedrock and VertexAI which may use other auth methods
977+ if providerID != provider.InferenceProviderBedrock && providerID != provider.InferenceProviderVertexAI {
978+ errors.Add(fieldPrefix+".api_key", "API key is required for non-disabled providers")
979+ }
980+ }
981+
982+ // Validate provider type
983+ validType := false
984+ for _, vt := range validTypes {
985+ if providerConfig.ProviderType == vt {
986+ validType = true
987+ break
988+ }
989+ }
990+ if !validType {
991+ errors.Add(fieldPrefix+".provider_type", fmt.Sprintf("invalid provider type: %s", providerConfig.ProviderType))
992+ }
993+
994+ // Validate custom providers
995+ isKnownProvider := false
996+ for _, kp := range knownProviders {
997+ if providerID == kp {
998+ isKnownProvider = true
999+ break
1000+ }
1001+ }
1002+
1003+ if !isKnownProvider {
1004+ // Custom provider validation
1005+ if providerConfig.BaseURL == "" {
1006+ errors.Add(fieldPrefix+".base_url", "BaseURL is required for custom providers")
1007+ }
1008+ if providerConfig.ProviderType != provider.TypeOpenAI {
1009+ errors.Add(fieldPrefix+".provider_type", "custom providers currently only support OpenAI type")
1010+ }
1011+ }
1012+
1013+ // Validate models
1014+ modelIDs := make(map[string]bool)
1015+ for i, model := range providerConfig.Models {
1016+ modelFieldPrefix := fmt.Sprintf("%s.models[%d]", fieldPrefix, i)
1017+
1018+ // Check for duplicate model IDs
1019+ if modelIDs[model.ID] {
1020+ errors.Add(modelFieldPrefix+".id", fmt.Sprintf("duplicate model ID: %s", model.ID))
1021+ }
1022+ modelIDs[model.ID] = true
1023+
1024+ // Validate required model fields
1025+ if model.ID == "" {
1026+ errors.Add(modelFieldPrefix+".id", "model ID is required")
1027+ }
1028+ if model.Name == "" {
1029+ errors.Add(modelFieldPrefix+".name", "model name is required")
1030+ }
1031+ if model.ContextWindow <= 0 {
1032+ errors.Add(modelFieldPrefix+".context_window", "context window must be positive")
1033+ }
1034+ if model.DefaultMaxTokens <= 0 {
1035+ errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens must be positive")
1036+ }
1037+ if model.DefaultMaxTokens > model.ContextWindow {
1038+ errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens cannot exceed context window")
1039+ }
1040+
1041+ // Validate cost fields
1042+ if model.CostPer1MIn < 0 {
1043+ errors.Add(modelFieldPrefix+".cost_per_1m_in", "cost per 1M input tokens cannot be negative")
1044+ }
1045+ if model.CostPer1MOut < 0 {
1046+ errors.Add(modelFieldPrefix+".cost_per_1m_out", "cost per 1M output tokens cannot be negative")
1047+ }
1048+ if model.CostPer1MInCached < 0 {
1049+ errors.Add(modelFieldPrefix+".cost_per_1m_in_cached", "cached cost per 1M input tokens cannot be negative")
1050+ }
1051+ if model.CostPer1MOutCached < 0 {
1052+ errors.Add(modelFieldPrefix+".cost_per_1m_out_cached", "cached cost per 1M output tokens cannot be negative")
1053+ }
1054+ }
1055+
1056+ // Validate default model references
1057+ if providerConfig.DefaultLargeModel != "" {
1058+ if !modelIDs[providerConfig.DefaultLargeModel] {
1059+ errors.Add(fieldPrefix+".default_large_model", fmt.Sprintf("default large model '%s' not found in provider models", providerConfig.DefaultLargeModel))
1060+ }
1061+ }
1062+ if providerConfig.DefaultSmallModel != "" {
1063+ if !modelIDs[providerConfig.DefaultSmallModel] {
1064+ errors.Add(fieldPrefix+".default_small_model", fmt.Sprintf("default small model '%s' not found in provider models", providerConfig.DefaultSmallModel))
1065+ }
1066+ }
1067+
1068+ // Validate provider-specific requirements
1069+ c.validateProviderSpecific(providerID, providerConfig, errors)
1070+ }
1071+}
1072+
1073+// validateProviderSpecific validates provider-specific requirements
1074+func (c *Config) validateProviderSpecific(providerID provider.InferenceProvider, providerConfig ProviderConfig, errors *ValidationErrors) {
1075+ fieldPrefix := fmt.Sprintf("providers.%s", providerID)
1076+
1077+ switch providerID {
1078+ case provider.InferenceProviderVertexAI:
1079+ if !providerConfig.Disabled {
1080+ if providerConfig.ExtraParams == nil {
1081+ errors.Add(fieldPrefix+".extra_params", "VertexAI requires extra_params configuration")
1082+ } else {
1083+ if providerConfig.ExtraParams["project"] == "" {
1084+ errors.Add(fieldPrefix+".extra_params.project", "VertexAI requires project parameter")
1085+ }
1086+ if providerConfig.ExtraParams["location"] == "" {
1087+ errors.Add(fieldPrefix+".extra_params.location", "VertexAI requires location parameter")
1088+ }
1089+ }
1090+ }
1091+ case provider.InferenceProviderBedrock:
1092+ if !providerConfig.Disabled {
1093+ if providerConfig.ExtraParams == nil || providerConfig.ExtraParams["region"] == "" {
1094+ errors.Add(fieldPrefix+".extra_params.region", "Bedrock requires region parameter")
1095+ }
1096+ // Check for AWS credentials in environment
1097+ if !hasAWSCredentials() {
1098+ errors.Add(fieldPrefix, "Bedrock requires AWS credentials in environment")
1099+ }
1100+ }
1101+ }
1102+}
1103+
1104+// validateModels validates preferred model configurations
1105+func (c *Config) validateModels(errors *ValidationErrors) {
1106+ // Validate large model
1107+ if c.Models.Large.ModelID != "" || c.Models.Large.Provider != "" {
1108+ if c.Models.Large.ModelID == "" {
1109+ errors.Add("models.large.model_id", "large model ID is required when provider is set")
1110+ }
1111+ if c.Models.Large.Provider == "" {
1112+ errors.Add("models.large.provider", "large model provider is required when model ID is set")
1113+ }
1114+
1115+ // Check if provider exists and is not disabled
1116+ if providerConfig, exists := c.Providers[c.Models.Large.Provider]; exists {
1117+ if providerConfig.Disabled {
1118+ errors.Add("models.large.provider", "large model provider is disabled")
1119+ }
1120+
1121+ // Check if model exists in provider
1122+ modelExists := false
1123+ for _, model := range providerConfig.Models {
1124+ if model.ID == c.Models.Large.ModelID {
1125+ modelExists = true
1126+ break
1127+ }
1128+ }
1129+ if !modelExists {
1130+ errors.Add("models.large.model_id", fmt.Sprintf("large model '%s' not found in provider '%s'", c.Models.Large.ModelID, c.Models.Large.Provider))
1131+ }
1132+ } else {
1133+ errors.Add("models.large.provider", fmt.Sprintf("large model provider '%s' not found", c.Models.Large.Provider))
1134+ }
1135+ }
1136+
1137+ // Validate small model
1138+ if c.Models.Small.ModelID != "" || c.Models.Small.Provider != "" {
1139+ if c.Models.Small.ModelID == "" {
1140+ errors.Add("models.small.model_id", "small model ID is required when provider is set")
1141+ }
1142+ if c.Models.Small.Provider == "" {
1143+ errors.Add("models.small.provider", "small model provider is required when model ID is set")
1144+ }
1145+
1146+ // Check if provider exists and is not disabled
1147+ if providerConfig, exists := c.Providers[c.Models.Small.Provider]; exists {
1148+ if providerConfig.Disabled {
1149+ errors.Add("models.small.provider", "small model provider is disabled")
1150+ }
1151+
1152+ // Check if model exists in provider
1153+ modelExists := false
1154+ for _, model := range providerConfig.Models {
1155+ if model.ID == c.Models.Small.ModelID {
1156+ modelExists = true
1157+ break
1158+ }
1159+ }
1160+ if !modelExists {
1161+ errors.Add("models.small.model_id", fmt.Sprintf("small model '%s' not found in provider '%s'", c.Models.Small.ModelID, c.Models.Small.Provider))
1162+ }
1163+ } else {
1164+ errors.Add("models.small.provider", fmt.Sprintf("small model provider '%s' not found", c.Models.Small.Provider))
1165+ }
1166+ }
1167+}
1168+
1169+// validateAgents validates agent configurations
1170+func (c *Config) validateAgents(errors *ValidationErrors) {
1171+ if c.Agents == nil {
1172+ c.Agents = make(map[AgentID]Agent)
1173+ }
1174+
1175+ validTools := []string{
1176+ "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
1177+ }
1178+
1179+ for agentID, agent := range c.Agents {
1180+ fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1181+
1182+ // Validate agent ID consistency
1183+ if agent.ID != agentID {
1184+ errors.Add(fieldPrefix+".id", fmt.Sprintf("agent ID mismatch: expected '%s', got '%s'", agentID, agent.ID))
1185+ }
1186+
1187+ // Validate required fields
1188+ if agent.ID == "" {
1189+ errors.Add(fieldPrefix+".id", "agent ID is required")
1190+ }
1191+ if agent.Name == "" {
1192+ errors.Add(fieldPrefix+".name", "agent name is required")
1193+ }
1194+
1195+ // Validate model type
1196+ if agent.Model != LargeModel && agent.Model != SmallModel {
1197+ errors.Add(fieldPrefix+".model", fmt.Sprintf("invalid model type: %s (must be 'large' or 'small')", agent.Model))
1198+ }
1199+
1200+ // Validate allowed tools
1201+ if agent.AllowedTools != nil {
1202+ for i, tool := range agent.AllowedTools {
1203+ validTool := false
1204+ for _, vt := range validTools {
1205+ if tool == vt {
1206+ validTool = true
1207+ break
1208+ }
1209+ }
1210+ if !validTool {
1211+ errors.Add(fmt.Sprintf("%s.allowed_tools[%d]", fieldPrefix, i), fmt.Sprintf("unknown tool: %s", tool))
1212+ }
1213+ }
1214+ }
1215+
1216+ // Validate MCP references
1217+ if agent.AllowedMCP != nil {
1218+ for mcpName := range agent.AllowedMCP {
1219+ if _, exists := c.MCP[mcpName]; !exists {
1220+ errors.Add(fieldPrefix+".allowed_mcp", fmt.Sprintf("referenced MCP '%s' not found", mcpName))
1221+ }
1222+ }
1223+ }
1224+
1225+ // Validate LSP references
1226+ if agent.AllowedLSP != nil {
1227+ for _, lspName := range agent.AllowedLSP {
1228+ if _, exists := c.LSP[lspName]; !exists {
1229+ errors.Add(fieldPrefix+".allowed_lsp", fmt.Sprintf("referenced LSP '%s' not found", lspName))
1230+ }
1231+ }
1232+ }
1233+
1234+ // Validate context paths (basic path validation)
1235+ for i, contextPath := range agent.ContextPaths {
1236+ if contextPath == "" {
1237+ errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path cannot be empty")
1238+ }
1239+ // Check for invalid characters in path
1240+ if strings.Contains(contextPath, "\x00") {
1241+ errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path contains invalid characters")
1242+ }
1243+ }
1244+
1245+ // Validate known agents maintain their core properties
1246+ if agentID == AgentCoder {
1247+ if agent.Name != "Coder" {
1248+ errors.Add(fieldPrefix+".name", "coder agent name cannot be changed")
1249+ }
1250+ if agent.Description != "An agent that helps with executing coding tasks." {
1251+ errors.Add(fieldPrefix+".description", "coder agent description cannot be changed")
1252+ }
1253+ } else if agentID == AgentTask {
1254+ if agent.Name != "Task" {
1255+ errors.Add(fieldPrefix+".name", "task agent name cannot be changed")
1256+ }
1257+ if agent.Description != "An agent that helps with searching for context and finding implementation details." {
1258+ errors.Add(fieldPrefix+".description", "task agent description cannot be changed")
1259+ }
1260+ expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1261+ if agent.AllowedTools != nil && !slices.Equal(agent.AllowedTools, expectedTools) {
1262+ errors.Add(fieldPrefix+".allowed_tools", "task agent allowed tools cannot be changed")
1263+ }
1264+ }
1265+ }
1266+}
1267+
1268+// validateOptions validates configuration options
1269+func (c *Config) validateOptions(errors *ValidationErrors) {
1270+ // Validate data directory
1271+ if c.Options.DataDirectory == "" {
1272+ errors.Add("options.data_directory", "data directory is required")
1273+ }
1274+
1275+ // Validate context paths
1276+ for i, contextPath := range c.Options.ContextPaths {
1277+ if contextPath == "" {
1278+ errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path cannot be empty")
1279+ }
1280+ if strings.Contains(contextPath, "\x00") {
1281+ errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path contains invalid characters")
1282+ }
1283+ }
1284+}
1285+
1286+// validateMCPs validates MCP configurations
1287+func (c *Config) validateMCPs(errors *ValidationErrors) {
1288+ if c.MCP == nil {
1289+ c.MCP = make(map[string]MCP)
1290+ }
1291+
1292+ for mcpName, mcpConfig := range c.MCP {
1293+ fieldPrefix := fmt.Sprintf("mcp.%s", mcpName)
1294+
1295+ // Validate MCP type
1296+ if mcpConfig.Type != MCPStdio && mcpConfig.Type != MCPSse {
1297+ errors.Add(fieldPrefix+".type", fmt.Sprintf("invalid MCP type: %s (must be 'stdio' or 'sse')", mcpConfig.Type))
1298+ }
1299+
1300+ // Validate based on type
1301+ if mcpConfig.Type == MCPStdio {
1302+ if mcpConfig.Command == "" {
1303+ errors.Add(fieldPrefix+".command", "command is required for stdio MCP")
1304+ }
1305+ } else if mcpConfig.Type == MCPSse {
1306+ if mcpConfig.URL == "" {
1307+ errors.Add(fieldPrefix+".url", "URL is required for SSE MCP")
1308+ }
1309+ }
1310+ }
1311+}
1312+
1313+// validateLSPs validates LSP configurations
1314+func (c *Config) validateLSPs(errors *ValidationErrors) {
1315+ if c.LSP == nil {
1316+ c.LSP = make(map[string]LSPConfig)
1317+ }
1318+
1319+ for lspName, lspConfig := range c.LSP {
1320+ fieldPrefix := fmt.Sprintf("lsp.%s", lspName)
1321+
1322+ if lspConfig.Command == "" {
1323+ errors.Add(fieldPrefix+".command", "command is required for LSP")
1324+ }
1325+ }
1326+}
1327+
1328+// validateCrossReferences validates cross-references between different config sections
1329+func (c *Config) validateCrossReferences(errors *ValidationErrors) {
1330+ // Validate that agents can use their assigned model types
1331+ for agentID, agent := range c.Agents {
1332+ fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1333+
1334+ var preferredModel PreferredModel
1335+ switch agent.Model {
1336+ case LargeModel:
1337+ preferredModel = c.Models.Large
1338+ case SmallModel:
1339+ preferredModel = c.Models.Small
1340+ }
1341+
1342+ if preferredModel.Provider != "" {
1343+ if providerConfig, exists := c.Providers[preferredModel.Provider]; exists {
1344+ if providerConfig.Disabled {
1345+ errors.Add(fieldPrefix+".model", fmt.Sprintf("agent cannot use model type '%s' because provider '%s' is disabled", agent.Model, preferredModel.Provider))
1346+ }
1347+ }
1348+ }
1349+ }
1350+}
1351+
1352+// validateCompleteness validates that the configuration is complete and usable
1353+func (c *Config) validateCompleteness(errors *ValidationErrors) {
1354+ // Check for at least one valid, non-disabled provider
1355+ hasValidProvider := false
1356+ for _, providerConfig := range c.Providers {
1357+ if !providerConfig.Disabled {
1358+ hasValidProvider = true
1359+ break
1360+ }
1361+ }
1362+ if !hasValidProvider {
1363+ errors.Add("providers", "at least one non-disabled provider is required")
1364+ }
1365+
1366+ // Check that default agents exist
1367+ if _, exists := c.Agents[AgentCoder]; !exists {
1368+ errors.Add("agents", "coder agent is required")
1369+ }
1370+ if _, exists := c.Agents[AgentTask]; !exists {
1371+ errors.Add("agents", "task agent is required")
1372+ }
1373+
1374+ // Check that preferred models are set if providers exist
1375+ if hasValidProvider {
1376+ if c.Models.Large.ModelID == "" || c.Models.Large.Provider == "" {
1377+ errors.Add("models.large", "large preferred model must be configured when providers are available")
1378+ }
1379+ if c.Models.Small.ModelID == "" || c.Models.Small.Provider == "" {
1380+ errors.Add("models.small", "small preferred model must be configured when providers are available")
1381+ }
1382+ }
1383+}
internal/config/config_test.go+96−12
@@ -50,6 +50,10 @@ func reset() {
5050 instance = nil
5151 cwd = ""
5252 testConfigDir = ""
53+
54+ // Enable mock providers for all tests to avoid API calls
55+ UseMockProviders = true
56+ ResetProviders()
5357}
5458
5559// Core Configuration Loading Tests
@@ -133,9 +137,29 @@ func TestLoadConfig_OnlyGlobalConfig(t *testing.T) {
133137 globalConfig := Config{
134138 Providers: map[provider.InferenceProvider]ProviderConfig{
135139 provider.InferenceProviderOpenAI: {
136− ID: provider.InferenceProviderOpenAI,
137− APIKey: "test-key",
138− ProviderType: provider.TypeOpenAI,
140+ ID: provider.InferenceProviderOpenAI,
141+ APIKey: "test-key",
142+ ProviderType: provider.TypeOpenAI,
143+ DefaultLargeModel: "gpt-4",
144+ DefaultSmallModel: "gpt-3.5-turbo",
145+ Models: []Model{
146+ {
147+ ID: "gpt-4",
148+ Name: "GPT-4",
149+ CostPer1MIn: 30.0,
150+ CostPer1MOut: 60.0,
151+ ContextWindow: 8192,
152+ DefaultMaxTokens: 4096,
153+ },
154+ {
155+ ID: "gpt-3.5-turbo",
156+ Name: "GPT-3.5 Turbo",
157+ CostPer1MIn: 1.0,
158+ CostPer1MOut: 2.0,
159+ ContextWindow: 4096,
160+ DefaultMaxTokens: 4096,
161+ },
162+ },
139163 },
140164 },
141165 Options: Options{
@@ -167,9 +191,29 @@ func TestLoadConfig_OnlyLocalConfig(t *testing.T) {
167191 localConfig := Config{
168192 Providers: map[provider.InferenceProvider]ProviderConfig{
169193 provider.InferenceProviderAnthropic: {
170− ID: provider.InferenceProviderAnthropic,
171− APIKey: "local-key",
172− ProviderType: provider.TypeAnthropic,
194+ ID: provider.InferenceProviderAnthropic,
195+ APIKey: "local-key",
196+ ProviderType: provider.TypeAnthropic,
197+ DefaultLargeModel: "claude-3-opus",
198+ DefaultSmallModel: "claude-3-haiku",
199+ Models: []Model{
200+ {
201+ ID: "claude-3-opus",
202+ Name: "Claude 3 Opus",
203+ CostPer1MIn: 15.0,
204+ CostPer1MOut: 75.0,
205+ ContextWindow: 200000,
206+ DefaultMaxTokens: 4096,
207+ },
208+ {
209+ ID: "claude-3-haiku",
210+ Name: "Claude 3 Haiku",
211+ CostPer1MIn: 0.25,
212+ CostPer1MOut: 1.25,
213+ ContextWindow: 200000,
214+ DefaultMaxTokens: 4096,
215+ },
216+ },
173217 },
174218 },
175219 Options: Options{
@@ -199,9 +243,29 @@ func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
199243 globalConfig := Config{
200244 Providers: map[provider.InferenceProvider]ProviderConfig{
201245 provider.InferenceProviderOpenAI: {
202− ID: provider.InferenceProviderOpenAI,
203− APIKey: "global-key",
204− ProviderType: provider.TypeOpenAI,
246+ ID: provider.InferenceProviderOpenAI,
247+ APIKey: "global-key",
248+ ProviderType: provider.TypeOpenAI,
249+ DefaultLargeModel: "gpt-4",
250+ DefaultSmallModel: "gpt-3.5-turbo",
251+ Models: []Model{
252+ {
253+ ID: "gpt-4",
254+ Name: "GPT-4",
255+ CostPer1MIn: 30.0,
256+ CostPer1MOut: 60.0,
257+ ContextWindow: 8192,
258+ DefaultMaxTokens: 4096,
259+ },
260+ {
261+ ID: "gpt-3.5-turbo",
262+ Name: "GPT-3.5 Turbo",
263+ CostPer1MIn: 1.0,
264+ CostPer1MOut: 2.0,
265+ ContextWindow: 4096,
266+ DefaultMaxTokens: 4096,
267+ },
268+ },
205269 },
206270 },
207271 Options: Options{
@@ -222,9 +286,29 @@ func TestLoadConfig_BothGlobalAndLocal(t *testing.T) {
222286 APIKey: "local-key", // Override global
223287 },
224288 provider.InferenceProviderAnthropic: {
225− ID: provider.InferenceProviderAnthropic,
226− APIKey: "anthropic-key",
227− ProviderType: provider.TypeAnthropic,
289+ ID: provider.InferenceProviderAnthropic,
290+ APIKey: "anthropic-key",
291+ ProviderType: provider.TypeAnthropic,
292+ DefaultLargeModel: "claude-3-opus",
293+ DefaultSmallModel: "claude-3-haiku",
294+ Models: []Model{
295+ {
296+ ID: "claude-3-opus",
297+ Name: "Claude 3 Opus",
298+ CostPer1MIn: 15.0,
299+ CostPer1MOut: 75.0,
300+ ContextWindow: 200000,
301+ DefaultMaxTokens: 4096,
302+ },
303+ {
304+ ID: "claude-3-haiku",
305+ Name: "Claude 3 Haiku",
306+ CostPer1MIn: 0.25,
307+ CostPer1MOut: 1.25,
308+ ContextWindow: 200000,
309+ DefaultMaxTokens: 4096,
310+ },
311+ },
228312 },
229313 },
230314 Options: Options{
internal/config/provider.go+14
@@ -15,6 +15,8 @@ var fur = client.New()
1515var (
1616 providerOnc sync.Once // Ensures the initialization happens only once
1717 providerList []provider.Provider
18+ // UseMockProviders can be set to true in tests to avoid API calls
19+ UseMockProviders bool
1820)
1921
2022func providersPath() string {
@@ -50,6 +52,12 @@ func loadProviders() ([]provider.Provider, error) {
5052
5153func Providers() []provider.Provider {
5254 providerOnc.Do(func() {
55+ // Use mock providers when testing
56+ if UseMockProviders {
57+ providerList = MockProviders()
58+ return
59+ }
60+
5361 // Try to get providers from upstream API
5462 if providers, err := fur.GetProviders(); err == nil {
5563 providerList = providers
@@ -67,3 +75,9 @@ func Providers() []provider.Provider {
6775 })
6876 return providerList
6977}
78+
79+// ResetProviders resets the provider cache. Useful for testing.
80+func ResetProviders() {
81+ providerOnc = sync.Once{}
82+ providerList = nil
83+}
internal/config/provider_mock.gonew file+177
@@ -0,0 +1,177 @@
1+package config
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/fur/provider"
5+)
6+
7+// MockProviders returns a mock list of providers for testing.
8+// This avoids making API calls during tests and provides consistent test data.
9+func MockProviders() []provider.Provider {
10+ return []provider.Provider{
11+ {
12+ Name: "Anthropic",
13+ ID: provider.InferenceProviderAnthropic,
14+ APIKey: "$ANTHROPIC_API_KEY",
15+ APIEndpoint: "$ANTHROPIC_API_ENDPOINT",
16+ Type: provider.TypeAnthropic,
17+ DefaultLargeModelID: "claude-3-opus",
18+ DefaultSmallModelID: "claude-3-haiku",
19+ Models: []provider.Model{
20+ {
21+ ID: "claude-3-opus",
22+ Name: "Claude 3 Opus",
23+ CostPer1MIn: 15.0,
24+ CostPer1MOut: 75.0,
25+ CostPer1MInCached: 18.75,
26+ CostPer1MOutCached: 1.5,
27+ ContextWindow: 200000,
28+ DefaultMaxTokens: 4096,
29+ CanReason: false,
30+ SupportsImages: true,
31+ },
32+ {
33+ ID: "claude-3-haiku",
34+ Name: "Claude 3 Haiku",
35+ CostPer1MIn: 0.25,
36+ CostPer1MOut: 1.25,
37+ CostPer1MInCached: 0.3,
38+ CostPer1MOutCached: 0.03,
39+ ContextWindow: 200000,
40+ DefaultMaxTokens: 4096,
41+ CanReason: false,
42+ SupportsImages: true,
43+ },
44+ {
45+ ID: "claude-3-5-sonnet-20241022",
46+ Name: "Claude 3.5 Sonnet",
47+ CostPer1MIn: 3.0,
48+ CostPer1MOut: 15.0,
49+ CostPer1MInCached: 3.75,
50+ CostPer1MOutCached: 0.3,
51+ ContextWindow: 200000,
52+ DefaultMaxTokens: 8192,
53+ CanReason: false,
54+ SupportsImages: true,
55+ },
56+ {
57+ ID: "claude-3-5-haiku-20241022",
58+ Name: "Claude 3.5 Haiku",
59+ CostPer1MIn: 0.8,
60+ CostPer1MOut: 4.0,
61+ CostPer1MInCached: 1.0,
62+ CostPer1MOutCached: 0.08,
63+ ContextWindow: 200000,
64+ DefaultMaxTokens: 8192,
65+ CanReason: false,
66+ SupportsImages: true,
67+ },
68+ },
69+ },
70+ {
71+ Name: "OpenAI",
72+ ID: provider.InferenceProviderOpenAI,
73+ APIKey: "$OPENAI_API_KEY",
74+ APIEndpoint: "$OPENAI_API_ENDPOINT",
75+ Type: provider.TypeOpenAI,
76+ DefaultLargeModelID: "gpt-4",
77+ DefaultSmallModelID: "gpt-3.5-turbo",
78+ Models: []provider.Model{
79+ {
80+ ID: "gpt-4",
81+ Name: "GPT-4",
82+ CostPer1MIn: 30.0,
83+ CostPer1MOut: 60.0,
84+ CostPer1MInCached: 0.0,
85+ CostPer1MOutCached: 0.0,
86+ ContextWindow: 8192,
87+ DefaultMaxTokens: 4096,
88+ CanReason: false,
89+ SupportsImages: false,
90+ },
91+ {
92+ ID: "gpt-3.5-turbo",
93+ Name: "GPT-3.5 Turbo",
94+ CostPer1MIn: 1.0,
95+ CostPer1MOut: 2.0,
96+ CostPer1MInCached: 0.0,
97+ CostPer1MOutCached: 0.0,
98+ ContextWindow: 4096,
99+ DefaultMaxTokens: 4096,
100+ CanReason: false,
101+ SupportsImages: false,
102+ },
103+ {
104+ ID: "gpt-4-turbo",
105+ Name: "GPT-4 Turbo",
106+ CostPer1MIn: 10.0,
107+ CostPer1MOut: 30.0,
108+ CostPer1MInCached: 0.0,
109+ CostPer1MOutCached: 0.0,
110+ ContextWindow: 128000,
111+ DefaultMaxTokens: 4096,
112+ CanReason: false,
113+ SupportsImages: true,
114+ },
115+ {
116+ ID: "gpt-4o",
117+ Name: "GPT-4o",
118+ CostPer1MIn: 2.5,
119+ CostPer1MOut: 10.0,
120+ CostPer1MInCached: 0.0,
121+ CostPer1MOutCached: 1.25,
122+ ContextWindow: 128000,
123+ DefaultMaxTokens: 16384,
124+ CanReason: false,
125+ SupportsImages: true,
126+ },
127+ {
128+ ID: "gpt-4o-mini",
129+ Name: "GPT-4o-mini",
130+ CostPer1MIn: 0.15,
131+ CostPer1MOut: 0.6,
132+ CostPer1MInCached: 0.0,
133+ CostPer1MOutCached: 0.075,
134+ ContextWindow: 128000,
135+ DefaultMaxTokens: 16384,
136+ CanReason: false,
137+ SupportsImages: true,
138+ },
139+ },
140+ },
141+ {
142+ Name: "Google Gemini",
143+ ID: provider.InferenceProviderGemini,
144+ APIKey: "$GEMINI_API_KEY",
145+ APIEndpoint: "$GEMINI_API_ENDPOINT",
146+ Type: provider.TypeGemini,
147+ DefaultLargeModelID: "gemini-2.5-pro",
148+ DefaultSmallModelID: "gemini-2.5-flash",
149+ Models: []provider.Model{
150+ {
151+ ID: "gemini-2.5-pro",
152+ Name: "Gemini 2.5 Pro",
153+ CostPer1MIn: 1.25,
154+ CostPer1MOut: 10.0,
155+ CostPer1MInCached: 1.625,
156+ CostPer1MOutCached: 0.31,
157+ ContextWindow: 1048576,
158+ DefaultMaxTokens: 65536,
159+ CanReason: true,
160+ SupportsImages: true,
161+ },
162+ {
163+ ID: "gemini-2.5-flash",
164+ Name: "Gemini 2.5 Flash",
165+ CostPer1MIn: 0.3,
166+ CostPer1MOut: 2.5,
167+ CostPer1MInCached: 0.3833,
168+ CostPer1MOutCached: 0.075,
169+ ContextWindow: 1048576,
170+ DefaultMaxTokens: 65535,
171+ CanReason: true,
172+ SupportsImages: true,
173+ },
174+ },
175+ },
176+ }
177+}
internal/config/provider_test.gonew file+105
@@ -0,0 +1,105 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/crush/internal/fur/provider"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestMockProviders(t *testing.T) {
12+ // Enable mock providers for testing
13+ originalUseMock := UseMockProviders
14+ UseMockProviders = true
15+ defer func() {
16+ UseMockProviders = originalUseMock
17+ ResetProviders()
18+ }()
19+
20+ // Reset providers to ensure we get fresh mock data
21+ ResetProviders()
22+
23+ providers := Providers()
24+ require.NotEmpty(t, providers, "Mock providers should not be empty")
25+
26+ // Verify we have the expected mock providers
27+ providerIDs := make(map[provider.InferenceProvider]bool)
28+ for _, p := range providers {
29+ providerIDs[p.ID] = true
30+ }
31+
32+ assert.True(t, providerIDs[provider.InferenceProviderAnthropic], "Should have Anthropic provider")
33+ assert.True(t, providerIDs[provider.InferenceProviderOpenAI], "Should have OpenAI provider")
34+ assert.True(t, providerIDs[provider.InferenceProviderGemini], "Should have Gemini provider")
35+
36+ // Verify Anthropic provider details
37+ var anthropicProvider provider.Provider
38+ for _, p := range providers {
39+ if p.ID == provider.InferenceProviderAnthropic {
40+ anthropicProvider = p
41+ break
42+ }
43+ }
44+
45+ assert.Equal(t, "Anthropic", anthropicProvider.Name)
46+ assert.Equal(t, provider.TypeAnthropic, anthropicProvider.Type)
47+ assert.Equal(t, "claude-3-opus", anthropicProvider.DefaultLargeModelID)
48+ assert.Equal(t, "claude-3-haiku", anthropicProvider.DefaultSmallModelID)
49+ assert.Len(t, anthropicProvider.Models, 4, "Anthropic should have 4 models")
50+
51+ // Verify model details
52+ var opusModel provider.Model
53+ for _, m := range anthropicProvider.Models {
54+ if m.ID == "claude-3-opus" {
55+ opusModel = m
56+ break
57+ }
58+ }
59+
60+ assert.Equal(t, "Claude 3 Opus", opusModel.Name)
61+ assert.Equal(t, int64(200000), opusModel.ContextWindow)
62+ assert.Equal(t, int64(4096), opusModel.DefaultMaxTokens)
63+ assert.True(t, opusModel.SupportsImages)
64+}
65+
66+func TestProvidersWithoutMock(t *testing.T) {
67+ // Ensure mock is disabled
68+ originalUseMock := UseMockProviders
69+ UseMockProviders = false
70+ defer func() {
71+ UseMockProviders = originalUseMock
72+ ResetProviders()
73+ }()
74+
75+ // Reset providers to ensure we get fresh data
76+ ResetProviders()
77+
78+ // This will try to make an actual API call or use cached data
79+ providers := Providers()
80+
81+ // We can't guarantee what we'll get here since it depends on network/cache
82+ // but we can at least verify the function doesn't panic
83+ t.Logf("Got %d providers without mock", len(providers))
84+}
85+
86+func TestResetProviders(t *testing.T) {
87+ // Enable mock providers
88+ UseMockProviders = true
89+ defer func() {
90+ UseMockProviders = false
91+ ResetProviders()
92+ }()
93+
94+ // Get providers once
95+ providers1 := Providers()
96+ require.NotEmpty(t, providers1)
97+
98+ // Reset and get again
99+ ResetProviders()
100+ providers2 := Providers()
101+ require.NotEmpty(t, providers2)
102+
103+ // Should get the same mock data
104+ assert.Equal(t, len(providers1), len(providers2))
105+}