1package config
2
3import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "maps"
9 "os"
10 "path/filepath"
11 "slices"
12 "strings"
13 "sync"
14
15 "github.com/charmbracelet/crush/internal/fur/provider"
16 "github.com/charmbracelet/crush/internal/logging"
17)
18
19const (
20 defaultDataDirectory = ".crush"
21 defaultLogLevel = "info"
22 appName = "crush"
23
24 MaxTokensFallbackDefault = 4096
25)
26
27var defaultContextPaths = []string{
28 ".github/copilot-instructions.md",
29 ".cursorrules",
30 ".cursor/rules/",
31 "CLAUDE.md",
32 "CLAUDE.local.md",
33 "GEMINI.md",
34 "gemini.md",
35 "crush.md",
36 "crush.local.md",
37 "Crush.md",
38 "Crush.local.md",
39 "CRUSH.md",
40 "CRUSH.local.md",
41}
42
43type AgentID string
44
45const (
46 AgentCoder AgentID = "coder"
47 AgentTask AgentID = "task"
48)
49
50type ModelType string
51
52const (
53 LargeModel ModelType = "large"
54 SmallModel ModelType = "small"
55)
56
57type Model struct {
58 ID string `json:"id"`
59 Name string `json:"model"`
60 CostPer1MIn float64 `json:"cost_per_1m_in"`
61 CostPer1MOut float64 `json:"cost_per_1m_out"`
62 CostPer1MInCached float64 `json:"cost_per_1m_in_cached"`
63 CostPer1MOutCached float64 `json:"cost_per_1m_out_cached"`
64 ContextWindow int64 `json:"context_window"`
65 DefaultMaxTokens int64 `json:"default_max_tokens"`
66 CanReason bool `json:"can_reason"`
67 ReasoningEffort string `json:"reasoning_effort"`
68 HasReasoningEffort bool `json:"has_reasoning_effort"`
69 SupportsImages bool `json:"supports_attachments"`
70}
71
72type VertexAIOptions struct {
73 APIKey string `json:"api_key,omitempty"`
74 Project string `json:"project,omitempty"`
75 Location string `json:"location,omitempty"`
76}
77
78type ProviderConfig struct {
79 ID provider.InferenceProvider `json:"id"`
80 BaseURL string `json:"base_url,omitempty"`
81 ProviderType provider.Type `json:"provider_type"`
82 APIKey string `json:"api_key,omitempty"`
83 Disabled bool `json:"disabled"`
84 ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
85 // used for e.x for vertex to set the project
86 ExtraParams map[string]string `json:"extra_params,omitempty"`
87
88 DefaultLargeModel string `json:"default_large_model,omitempty"`
89 DefaultSmallModel string `json:"default_small_model,omitempty"`
90
91 Models []Model `json:"models,omitempty"`
92}
93
94type Agent struct {
95 ID AgentID `json:"id"`
96 Name string `json:"name"`
97 Description string `json:"description,omitempty"`
98 // This is the id of the system prompt used by the agent
99 Disabled bool `json:"disabled"`
100
101 Model ModelType `json:"model"`
102
103 // The available tools for the agent
104 // if this is nil, all tools are available
105 AllowedTools []string `json:"allowed_tools"`
106
107 // this tells us which MCPs are available for this agent
108 // if this is empty all mcps are available
109 // the string array is the list of tools from the AllowedMCP the agent has available
110 // if the string array is nil, all tools from the AllowedMCP are available
111 AllowedMCP map[string][]string `json:"allowed_mcp"`
112
113 // The list of LSPs that this agent can use
114 // if this is nil, all LSPs are available
115 AllowedLSP []string `json:"allowed_lsp"`
116
117 // Overrides the context paths for this agent
118 ContextPaths []string `json:"context_paths"`
119}
120
121type MCPType string
122
123const (
124 MCPStdio MCPType = "stdio"
125 MCPSse MCPType = "sse"
126)
127
128type MCP struct {
129 Command string `json:"command"`
130 Env []string `json:"env"`
131 Args []string `json:"args"`
132 Type MCPType `json:"type"`
133 URL string `json:"url"`
134 Headers map[string]string `json:"headers"`
135}
136
137type LSPConfig struct {
138 Disabled bool `json:"enabled"`
139 Command string `json:"command"`
140 Args []string `json:"args"`
141 Options any `json:"options"`
142}
143
144type TUIOptions struct {
145 CompactMode bool `json:"compact_mode"`
146 // Here we can add themes later or any TUI related options
147}
148
149type Options struct {
150 ContextPaths []string `json:"context_paths"`
151 TUI TUIOptions `json:"tui"`
152 Debug bool `json:"debug"`
153 DebugLSP bool `json:"debug_lsp"`
154 DisableAutoSummarize bool `json:"disable_auto_summarize"`
155 // Relative to the cwd
156 DataDirectory string `json:"data_directory"`
157}
158
159type PreferredModel struct {
160 ModelID string `json:"model_id"`
161 Provider provider.InferenceProvider `json:"provider"`
162 // ReasoningEffort overrides the default reasoning effort for this model
163 ReasoningEffort string `json:"reasoning_effort,omitempty"`
164 // MaxTokens overrides the default max tokens for this model
165 MaxTokens int64 `json:"max_tokens,omitempty"`
166
167 // Think indicates if the model should think, only applicable for anthropic reasoning models
168 Think bool `json:"think,omitempty"`
169}
170
171type PreferredModels struct {
172 Large PreferredModel `json:"large"`
173 Small PreferredModel `json:"small"`
174}
175
176type Config struct {
177 Models PreferredModels `json:"models"`
178 // List of configured providers
179 Providers map[provider.InferenceProvider]ProviderConfig `json:"providers,omitempty"`
180
181 // List of configured agents
182 Agents map[AgentID]Agent `json:"agents,omitempty"`
183
184 // List of configured MCPs
185 MCP map[string]MCP `json:"mcp,omitempty"`
186
187 // List of configured LSPs
188 LSP map[string]LSPConfig `json:"lsp,omitempty"`
189
190 // Miscellaneous options
191 Options Options `json:"options"`
192}
193
194var (
195 instance *Config // The single instance of the Singleton
196 cwd string
197 once sync.Once // Ensures the initialization happens only once
198
199)
200
201func loadConfig(cwd string, debug bool) (*Config, error) {
202 // First read the global config file
203 cfgPath := ConfigPath()
204
205 cfg := defaultConfigBasedOnEnv()
206 cfg.Options.Debug = debug
207 defaultLevel := slog.LevelInfo
208 if cfg.Options.Debug {
209 defaultLevel = slog.LevelDebug
210 }
211 if os.Getenv("CRUSH_DEV_DEBUG") == "true" {
212 loggingFile := fmt.Sprintf("%s/%s", cfg.Options.DataDirectory, "debug.log")
213
214 // if file does not exist create it
215 if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
216 if err := os.MkdirAll(cfg.Options.DataDirectory, 0o755); err != nil {
217 return cfg, fmt.Errorf("failed to create directory: %w", err)
218 }
219 if _, err := os.Create(loggingFile); err != nil {
220 return cfg, fmt.Errorf("failed to create log file: %w", err)
221 }
222 }
223
224 sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
225 if err != nil {
226 return cfg, fmt.Errorf("failed to open log file: %w", err)
227 }
228 // Configure logger
229 logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
230 Level: defaultLevel,
231 }))
232 slog.SetDefault(logger)
233 } else {
234 // Configure logger
235 logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
236 Level: defaultLevel,
237 }))
238 slog.SetDefault(logger)
239 }
240 var globalCfg *Config
241 if _, err := os.Stat(cfgPath); err != nil && !os.IsNotExist(err) {
242 // some other error occurred while checking the file
243 return nil, err
244 } else if err == nil {
245 // config file exists, read it
246 file, err := os.ReadFile(cfgPath)
247 if err != nil {
248 return nil, err
249 }
250 globalCfg = &Config{}
251 if err := json.Unmarshal(file, globalCfg); err != nil {
252 return nil, err
253 }
254 } else {
255 // config file does not exist, create a new one
256 globalCfg = &Config{}
257 }
258
259 var localConfig *Config
260 // Global config loaded, now read the local config file
261 localConfigPath := filepath.Join(cwd, "crush.json")
262 if _, err := os.Stat(localConfigPath); err != nil && !os.IsNotExist(err) {
263 // some other error occurred while checking the file
264 return nil, err
265 } else if err == nil {
266 // local config file exists, read it
267 file, err := os.ReadFile(localConfigPath)
268 if err != nil {
269 return nil, err
270 }
271 localConfig = &Config{}
272 if err := json.Unmarshal(file, localConfig); err != nil {
273 return nil, err
274 }
275 }
276
277 // merge options
278 mergeOptions(cfg, globalCfg, localConfig)
279
280 mergeProviderConfigs(cfg, globalCfg, localConfig)
281 // no providers found the app is not initialized yet
282 if len(cfg.Providers) == 0 {
283 return cfg, nil
284 }
285 preferredProvider := getPreferredProvider(cfg.Providers)
286 if preferredProvider != nil {
287 cfg.Models = PreferredModels{
288 Large: PreferredModel{
289 ModelID: preferredProvider.DefaultLargeModel,
290 Provider: preferredProvider.ID,
291 },
292 Small: PreferredModel{
293 ModelID: preferredProvider.DefaultSmallModel,
294 Provider: preferredProvider.ID,
295 },
296 }
297 } else {
298 // No valid providers found, set empty models
299 cfg.Models = PreferredModels{}
300 }
301
302 mergeModels(cfg, globalCfg, localConfig)
303
304 agents := map[AgentID]Agent{
305 AgentCoder: {
306 ID: AgentCoder,
307 Name: "Coder",
308 Description: "An agent that helps with executing coding tasks.",
309 Model: LargeModel,
310 ContextPaths: cfg.Options.ContextPaths,
311 // All tools allowed
312 },
313 AgentTask: {
314 ID: AgentTask,
315 Name: "Task",
316 Description: "An agent that helps with searching for context and finding implementation details.",
317 Model: LargeModel,
318 ContextPaths: cfg.Options.ContextPaths,
319 AllowedTools: []string{
320 "glob",
321 "grep",
322 "ls",
323 "sourcegraph",
324 "view",
325 },
326 // NO MCPs or LSPs by default
327 AllowedMCP: map[string][]string{},
328 AllowedLSP: []string{},
329 },
330 }
331 cfg.Agents = agents
332 mergeAgents(cfg, globalCfg, localConfig)
333 mergeMCPs(cfg, globalCfg, localConfig)
334 mergeLSPs(cfg, globalCfg, localConfig)
335
336 // Validate the final configuration
337 if err := cfg.Validate(); err != nil {
338 return cfg, fmt.Errorf("configuration validation failed: %w", err)
339 }
340
341 return cfg, nil
342}
343
344func Init(workingDir string, debug bool) (*Config, error) {
345 var err error
346 once.Do(func() {
347 cwd = workingDir
348 instance, err = loadConfig(cwd, debug)
349 if err != nil {
350 logging.Error("Failed to load config", "error", err)
351 }
352 })
353
354 return instance, err
355}
356
357func Get() *Config {
358 if instance == nil {
359 // TODO: Handle this better
360 panic("Config not initialized. Call InitConfig first.")
361 }
362 return instance
363}
364
365func getPreferredProvider(configuredProviders map[provider.InferenceProvider]ProviderConfig) *ProviderConfig {
366 providers := Providers()
367 for _, p := range providers {
368 if providerConfig, ok := configuredProviders[p.ID]; ok && !providerConfig.Disabled {
369 return &providerConfig
370 }
371 }
372 // if none found return the first configured provider
373 for _, providerConfig := range configuredProviders {
374 if !providerConfig.Disabled {
375 return &providerConfig
376 }
377 }
378 return nil
379}
380
381func mergeProviderConfig(p provider.InferenceProvider, base, other ProviderConfig) ProviderConfig {
382 if other.APIKey != "" {
383 base.APIKey = other.APIKey
384 }
385 // Only change these options if the provider is not a known provider
386 if !slices.Contains(provider.KnownProviders(), p) {
387 if other.BaseURL != "" {
388 base.BaseURL = other.BaseURL
389 }
390 if other.ProviderType != "" {
391 base.ProviderType = other.ProviderType
392 }
393 if len(other.ExtraHeaders) > 0 {
394 if base.ExtraHeaders == nil {
395 base.ExtraHeaders = make(map[string]string)
396 }
397 maps.Copy(base.ExtraHeaders, other.ExtraHeaders)
398 }
399 if len(other.ExtraParams) > 0 {
400 if base.ExtraParams == nil {
401 base.ExtraParams = make(map[string]string)
402 }
403 maps.Copy(base.ExtraParams, other.ExtraParams)
404 }
405 }
406
407 if other.Disabled {
408 base.Disabled = other.Disabled
409 }
410
411 if other.DefaultLargeModel != "" {
412 base.DefaultLargeModel = other.DefaultLargeModel
413 }
414 // Add new models if they don't exist
415 if other.Models != nil {
416 for _, model := range other.Models {
417 // check if the model already exists
418 exists := false
419 for _, existingModel := range base.Models {
420 if existingModel.ID == model.ID {
421 exists = true
422 break
423 }
424 }
425 if !exists {
426 base.Models = append(base.Models, model)
427 }
428 }
429 }
430
431 return base
432}
433
434func validateProvider(p provider.InferenceProvider, providerConfig ProviderConfig) error {
435 if !slices.Contains(provider.KnownProviders(), p) {
436 if providerConfig.ProviderType != provider.TypeOpenAI {
437 return errors.New("invalid provider type: " + string(providerConfig.ProviderType))
438 }
439 if providerConfig.BaseURL == "" {
440 return errors.New("base URL must be set for custom providers")
441 }
442 if providerConfig.APIKey == "" {
443 return errors.New("API key must be set for custom providers")
444 }
445 }
446 return nil
447}
448
449func mergeModels(base, global, local *Config) {
450 for _, cfg := range []*Config{global, local} {
451 if cfg == nil {
452 continue
453 }
454 if cfg.Models.Large.ModelID != "" && cfg.Models.Large.Provider != "" {
455 base.Models.Large = cfg.Models.Large
456 }
457
458 if cfg.Models.Small.ModelID != "" && cfg.Models.Small.Provider != "" {
459 base.Models.Small = cfg.Models.Small
460 }
461 }
462}
463
464func mergeOptions(base, global, local *Config) {
465 for _, cfg := range []*Config{global, local} {
466 if cfg == nil {
467 continue
468 }
469 baseOptions := base.Options
470 other := cfg.Options
471 if len(other.ContextPaths) > 0 {
472 baseOptions.ContextPaths = append(baseOptions.ContextPaths, other.ContextPaths...)
473 }
474
475 if other.TUI.CompactMode {
476 baseOptions.TUI.CompactMode = other.TUI.CompactMode
477 }
478
479 if other.Debug {
480 baseOptions.Debug = other.Debug
481 }
482
483 if other.DebugLSP {
484 baseOptions.DebugLSP = other.DebugLSP
485 }
486
487 if other.DisableAutoSummarize {
488 baseOptions.DisableAutoSummarize = other.DisableAutoSummarize
489 }
490
491 if other.DataDirectory != "" {
492 baseOptions.DataDirectory = other.DataDirectory
493 }
494 base.Options = baseOptions
495 }
496}
497
498func mergeAgents(base, global, local *Config) {
499 for _, cfg := range []*Config{global, local} {
500 if cfg == nil {
501 continue
502 }
503 for agentID, newAgent := range cfg.Agents {
504 if _, ok := base.Agents[agentID]; !ok {
505 // New agent - apply defaults
506 newAgent.ID = agentID // Ensure the ID is set correctly
507 if newAgent.Model == "" {
508 newAgent.Model = LargeModel // Default model type
509 }
510 // Context paths are always additive - start with global, then add custom
511 if len(newAgent.ContextPaths) > 0 {
512 newAgent.ContextPaths = append(base.Options.ContextPaths, newAgent.ContextPaths...)
513 } else {
514 newAgent.ContextPaths = base.Options.ContextPaths // Use global context paths only
515 }
516 base.Agents[agentID] = newAgent
517 } else {
518 baseAgent := base.Agents[agentID]
519
520 // Special handling for known agents - only allow model changes
521 if agentID == AgentCoder || agentID == AgentTask {
522 if newAgent.Model != "" {
523 baseAgent.Model = newAgent.Model
524 }
525 // For known agents, only allow MCP and LSP configuration
526 if newAgent.AllowedMCP != nil {
527 baseAgent.AllowedMCP = newAgent.AllowedMCP
528 }
529 if newAgent.AllowedLSP != nil {
530 baseAgent.AllowedLSP = newAgent.AllowedLSP
531 }
532 // Context paths are additive for known agents too
533 if len(newAgent.ContextPaths) > 0 {
534 baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
535 }
536 } else {
537 // Custom agents - allow full merging
538 if newAgent.Name != "" {
539 baseAgent.Name = newAgent.Name
540 }
541 if newAgent.Description != "" {
542 baseAgent.Description = newAgent.Description
543 }
544 if newAgent.Model != "" {
545 baseAgent.Model = newAgent.Model
546 } else if baseAgent.Model == "" {
547 baseAgent.Model = LargeModel // Default fallback
548 }
549
550 // Boolean fields - always update (including false values)
551 baseAgent.Disabled = newAgent.Disabled
552
553 // Slice/Map fields - update if provided (including empty slices/maps)
554 if newAgent.AllowedTools != nil {
555 baseAgent.AllowedTools = newAgent.AllowedTools
556 }
557 if newAgent.AllowedMCP != nil {
558 baseAgent.AllowedMCP = newAgent.AllowedMCP
559 }
560 if newAgent.AllowedLSP != nil {
561 baseAgent.AllowedLSP = newAgent.AllowedLSP
562 }
563 // Context paths are additive for custom agents too
564 if len(newAgent.ContextPaths) > 0 {
565 baseAgent.ContextPaths = append(baseAgent.ContextPaths, newAgent.ContextPaths...)
566 }
567 }
568
569 base.Agents[agentID] = baseAgent
570 }
571 }
572 }
573}
574
575func mergeMCPs(base, global, local *Config) {
576 for _, cfg := range []*Config{global, local} {
577 if cfg == nil {
578 continue
579 }
580 maps.Copy(base.MCP, cfg.MCP)
581 }
582}
583
584func mergeLSPs(base, global, local *Config) {
585 for _, cfg := range []*Config{global, local} {
586 if cfg == nil {
587 continue
588 }
589 maps.Copy(base.LSP, cfg.LSP)
590 }
591}
592
593func mergeProviderConfigs(base, global, local *Config) {
594 for _, cfg := range []*Config{global, local} {
595 if cfg == nil {
596 continue
597 }
598 for providerName, p := range cfg.Providers {
599 if _, ok := base.Providers[providerName]; !ok {
600 base.Providers[providerName] = p
601 } else {
602 base.Providers[providerName] = mergeProviderConfig(providerName, base.Providers[providerName], p)
603 }
604 }
605 }
606
607 finalProviders := make(map[provider.InferenceProvider]ProviderConfig)
608 for providerName, providerConfig := range base.Providers {
609 err := validateProvider(providerName, providerConfig)
610 if err != nil {
611 logging.Warn("Skipping provider", "name", providerName, "error", err)
612 continue // Skip invalid providers
613 }
614 finalProviders[providerName] = providerConfig
615 }
616 base.Providers = finalProviders
617}
618
619func providerDefaultConfig(providerId provider.InferenceProvider) ProviderConfig {
620 switch providerId {
621 case provider.InferenceProviderAnthropic:
622 return ProviderConfig{
623 ID: providerId,
624 ProviderType: provider.TypeAnthropic,
625 }
626 case provider.InferenceProviderOpenAI:
627 return ProviderConfig{
628 ID: providerId,
629 ProviderType: provider.TypeOpenAI,
630 }
631 case provider.InferenceProviderGemini:
632 return ProviderConfig{
633 ID: providerId,
634 ProviderType: provider.TypeGemini,
635 }
636 case provider.InferenceProviderBedrock:
637 return ProviderConfig{
638 ID: providerId,
639 ProviderType: provider.TypeBedrock,
640 }
641 case provider.InferenceProviderAzure:
642 return ProviderConfig{
643 ID: providerId,
644 ProviderType: provider.TypeAzure,
645 }
646 case provider.InferenceProviderOpenRouter:
647 return ProviderConfig{
648 ID: providerId,
649 ProviderType: provider.TypeOpenAI,
650 BaseURL: "https://openrouter.ai/api/v1",
651 ExtraHeaders: map[string]string{
652 "HTTP-Referer": "crush.charm.land",
653 "X-Title": "Crush",
654 },
655 }
656 case provider.InferenceProviderXAI:
657 return ProviderConfig{
658 ID: providerId,
659 ProviderType: provider.TypeXAI,
660 BaseURL: "https://api.x.ai/v1",
661 }
662 case provider.InferenceProviderVertexAI:
663 return ProviderConfig{
664 ID: providerId,
665 ProviderType: provider.TypeVertexAI,
666 }
667 default:
668 return ProviderConfig{
669 ID: providerId,
670 ProviderType: provider.TypeOpenAI,
671 }
672 }
673}
674
675func defaultConfigBasedOnEnv() *Config {
676 cfg := &Config{
677 Options: Options{
678 DataDirectory: defaultDataDirectory,
679 ContextPaths: defaultContextPaths,
680 },
681 Providers: make(map[provider.InferenceProvider]ProviderConfig),
682 Agents: make(map[AgentID]Agent),
683 LSP: make(map[string]LSPConfig),
684 MCP: make(map[string]MCP),
685 }
686
687 providers := Providers()
688
689 for _, p := range providers {
690 if strings.HasPrefix(p.APIKey, "$") {
691 envVar := strings.TrimPrefix(p.APIKey, "$")
692 if apiKey := os.Getenv(envVar); apiKey != "" {
693 providerConfig := providerDefaultConfig(p.ID)
694 providerConfig.APIKey = apiKey
695 providerConfig.DefaultLargeModel = p.DefaultLargeModelID
696 providerConfig.DefaultSmallModel = p.DefaultSmallModelID
697 baseURL := p.APIEndpoint
698 if strings.HasPrefix(baseURL, "$") {
699 envVar := strings.TrimPrefix(baseURL, "$")
700 baseURL = os.Getenv(envVar)
701 }
702 providerConfig.BaseURL = baseURL
703 for _, model := range p.Models {
704 configModel := Model{
705 ID: model.ID,
706 Name: model.Name,
707 CostPer1MIn: model.CostPer1MIn,
708 CostPer1MOut: model.CostPer1MOut,
709 CostPer1MInCached: model.CostPer1MInCached,
710 CostPer1MOutCached: model.CostPer1MOutCached,
711 ContextWindow: model.ContextWindow,
712 DefaultMaxTokens: model.DefaultMaxTokens,
713 CanReason: model.CanReason,
714 SupportsImages: model.SupportsImages,
715 }
716 // Set reasoning effort for reasoning models
717 if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
718 configModel.HasReasoningEffort = model.HasReasoningEffort
719 configModel.ReasoningEffort = model.DefaultReasoningEffort
720 }
721 providerConfig.Models = append(providerConfig.Models, configModel)
722 }
723 cfg.Providers[p.ID] = providerConfig
724 }
725 }
726 }
727 // TODO: support local models
728
729 if useVertexAI := os.Getenv("GOOGLE_GENAI_USE_VERTEXAI"); useVertexAI == "true" {
730 providerConfig := providerDefaultConfig(provider.InferenceProviderVertexAI)
731 providerConfig.ExtraParams = map[string]string{
732 "project": os.Getenv("GOOGLE_CLOUD_PROJECT"),
733 "location": os.Getenv("GOOGLE_CLOUD_LOCATION"),
734 }
735 // Find the VertexAI provider definition to get default models
736 for _, p := range providers {
737 if p.ID == provider.InferenceProviderVertexAI {
738 providerConfig.DefaultLargeModel = p.DefaultLargeModelID
739 providerConfig.DefaultSmallModel = p.DefaultSmallModelID
740 for _, model := range p.Models {
741 configModel := Model{
742 ID: model.ID,
743 Name: model.Name,
744 CostPer1MIn: model.CostPer1MIn,
745 CostPer1MOut: model.CostPer1MOut,
746 CostPer1MInCached: model.CostPer1MInCached,
747 CostPer1MOutCached: model.CostPer1MOutCached,
748 ContextWindow: model.ContextWindow,
749 DefaultMaxTokens: model.DefaultMaxTokens,
750 CanReason: model.CanReason,
751 SupportsImages: model.SupportsImages,
752 }
753 // Set reasoning effort for reasoning models
754 if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
755 configModel.HasReasoningEffort = model.HasReasoningEffort
756 configModel.ReasoningEffort = model.DefaultReasoningEffort
757 }
758 providerConfig.Models = append(providerConfig.Models, configModel)
759 }
760 break
761 }
762 }
763 cfg.Providers[provider.InferenceProviderVertexAI] = providerConfig
764 }
765
766 if hasAWSCredentials() {
767 providerConfig := providerDefaultConfig(provider.InferenceProviderBedrock)
768 providerConfig.ExtraParams = map[string]string{
769 "region": os.Getenv("AWS_DEFAULT_REGION"),
770 }
771 if providerConfig.ExtraParams["region"] == "" {
772 providerConfig.ExtraParams["region"] = os.Getenv("AWS_REGION")
773 }
774 // Find the Bedrock provider definition to get default models
775 for _, p := range providers {
776 if p.ID == provider.InferenceProviderBedrock {
777 providerConfig.DefaultLargeModel = p.DefaultLargeModelID
778 providerConfig.DefaultSmallModel = p.DefaultSmallModelID
779 for _, model := range p.Models {
780 configModel := Model{
781 ID: model.ID,
782 Name: model.Name,
783 CostPer1MIn: model.CostPer1MIn,
784 CostPer1MOut: model.CostPer1MOut,
785 CostPer1MInCached: model.CostPer1MInCached,
786 CostPer1MOutCached: model.CostPer1MOutCached,
787 ContextWindow: model.ContextWindow,
788 DefaultMaxTokens: model.DefaultMaxTokens,
789 CanReason: model.CanReason,
790 SupportsImages: model.SupportsImages,
791 }
792 // Set reasoning effort for reasoning models
793 if model.HasReasoningEffort && model.DefaultReasoningEffort != "" {
794 configModel.HasReasoningEffort = model.HasReasoningEffort
795 configModel.ReasoningEffort = model.DefaultReasoningEffort
796 }
797 providerConfig.Models = append(providerConfig.Models, configModel)
798 }
799 break
800 }
801 }
802 cfg.Providers[provider.InferenceProviderBedrock] = providerConfig
803 }
804 return cfg
805}
806
807func hasAWSCredentials() bool {
808 if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
809 return true
810 }
811
812 if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
813 return true
814 }
815
816 if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
817 return true
818 }
819
820 if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
821 os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
822 return true
823 }
824
825 return false
826}
827
828func WorkingDirectory() string {
829 return cwd
830}
831
832// TODO: Handle error state
833
834func GetAgentModel(agentID AgentID) Model {
835 cfg := Get()
836 agent, ok := cfg.Agents[agentID]
837 if !ok {
838 logging.Error("Agent not found", "agent_id", agentID)
839 return Model{}
840 }
841
842 var model PreferredModel
843 switch agent.Model {
844 case LargeModel:
845 model = cfg.Models.Large
846 case SmallModel:
847 model = cfg.Models.Small
848 default:
849 logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
850 model = cfg.Models.Large // Fallback to large model
851 }
852 providerConfig, ok := cfg.Providers[model.Provider]
853 if !ok {
854 logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
855 return Model{}
856 }
857
858 for _, m := range providerConfig.Models {
859 if m.ID == model.ModelID {
860 return m
861 }
862 }
863
864 logging.Error("Model not found for agent", "agent_id", agentID, "model", agent.Model)
865 return Model{}
866}
867
868func GetAgentProvider(agentID AgentID) ProviderConfig {
869 cfg := Get()
870 agent, ok := cfg.Agents[agentID]
871 if !ok {
872 logging.Error("Agent not found", "agent_id", agentID)
873 return ProviderConfig{}
874 }
875
876 var model PreferredModel
877 switch agent.Model {
878 case LargeModel:
879 model = cfg.Models.Large
880 case SmallModel:
881 model = cfg.Models.Small
882 default:
883 logging.Warn("Unknown model type for agent", "agent_id", agentID, "model_type", agent.Model)
884 model = cfg.Models.Large // Fallback to large model
885 }
886
887 providerConfig, ok := cfg.Providers[model.Provider]
888 if !ok {
889 logging.Error("Provider not found for agent", "agent_id", agentID, "provider", model.Provider)
890 return ProviderConfig{}
891 }
892
893 return providerConfig
894}
895
896func GetProviderModel(provider provider.InferenceProvider, modelID string) Model {
897 cfg := Get()
898 providerConfig, ok := cfg.Providers[provider]
899 if !ok {
900 logging.Error("Provider not found", "provider", provider)
901 return Model{}
902 }
903
904 for _, model := range providerConfig.Models {
905 if model.ID == modelID {
906 return model
907 }
908 }
909
910 logging.Error("Model not found for provider", "provider", provider, "model_id", modelID)
911 return Model{}
912}
913
914func GetModel(modelType ModelType) Model {
915 cfg := Get()
916 var model PreferredModel
917 switch modelType {
918 case LargeModel:
919 model = cfg.Models.Large
920 case SmallModel:
921 model = cfg.Models.Small
922 default:
923 model = cfg.Models.Large // Fallback to large model
924 }
925 providerConfig, ok := cfg.Providers[model.Provider]
926 if !ok {
927 return Model{}
928 }
929
930 for _, m := range providerConfig.Models {
931 if m.ID == model.ModelID {
932 return m
933 }
934 }
935 return Model{}
936}
937
938func UpdatePreferredModel(modelType ModelType, model PreferredModel) error {
939 cfg := Get()
940 switch modelType {
941 case LargeModel:
942 cfg.Models.Large = model
943 case SmallModel:
944 cfg.Models.Small = model
945 default:
946 return fmt.Errorf("unknown model type: %s", modelType)
947 }
948 return nil
949}
950
951// ValidationError represents a configuration validation error
952type ValidationError struct {
953 Field string
954 Message string
955}
956
957func (e ValidationError) Error() string {
958 return fmt.Sprintf("validation error in %s: %s", e.Field, e.Message)
959}
960
961// ValidationErrors represents multiple validation errors
962type ValidationErrors []ValidationError
963
964func (e ValidationErrors) Error() string {
965 if len(e) == 0 {
966 return "no validation errors"
967 }
968 if len(e) == 1 {
969 return e[0].Error()
970 }
971
972 var messages []string
973 for _, err := range e {
974 messages = append(messages, err.Error())
975 }
976 return fmt.Sprintf("multiple validation errors: %s", strings.Join(messages, "; "))
977}
978
979// HasErrors returns true if there are any validation errors
980func (e ValidationErrors) HasErrors() bool {
981 return len(e) > 0
982}
983
984// Add appends a new validation error
985func (e *ValidationErrors) Add(field, message string) {
986 *e = append(*e, ValidationError{Field: field, Message: message})
987}
988
989// Validate performs comprehensive validation of the configuration
990func (c *Config) Validate() error {
991 var errors ValidationErrors
992
993 // Validate providers
994 c.validateProviders(&errors)
995
996 // Validate models
997 c.validateModels(&errors)
998
999 // Validate agents
1000 c.validateAgents(&errors)
1001
1002 // Validate options
1003 c.validateOptions(&errors)
1004
1005 // Validate MCP configurations
1006 c.validateMCPs(&errors)
1007
1008 // Validate LSP configurations
1009 c.validateLSPs(&errors)
1010
1011 // Validate cross-references
1012 c.validateCrossReferences(&errors)
1013
1014 // Validate completeness
1015 c.validateCompleteness(&errors)
1016
1017 if errors.HasErrors() {
1018 return errors
1019 }
1020
1021 return nil
1022}
1023
1024// validateProviders validates all provider configurations
1025func (c *Config) validateProviders(errors *ValidationErrors) {
1026 if c.Providers == nil {
1027 c.Providers = make(map[provider.InferenceProvider]ProviderConfig)
1028 }
1029
1030 knownProviders := provider.KnownProviders()
1031 validTypes := []provider.Type{
1032 provider.TypeOpenAI,
1033 provider.TypeAnthropic,
1034 provider.TypeGemini,
1035 provider.TypeAzure,
1036 provider.TypeBedrock,
1037 provider.TypeVertexAI,
1038 provider.TypeXAI,
1039 }
1040
1041 for providerID, providerConfig := range c.Providers {
1042 fieldPrefix := fmt.Sprintf("providers.%s", providerID)
1043
1044 // Validate API key for non-disabled providers
1045 if !providerConfig.Disabled && providerConfig.APIKey == "" {
1046 // Special case for AWS Bedrock and VertexAI which may use other auth methods
1047 if providerID != provider.InferenceProviderBedrock && providerID != provider.InferenceProviderVertexAI {
1048 errors.Add(fieldPrefix+".api_key", "API key is required for non-disabled providers")
1049 }
1050 }
1051
1052 // Validate provider type
1053 validType := slices.Contains(validTypes, providerConfig.ProviderType)
1054 if !validType {
1055 errors.Add(fieldPrefix+".provider_type", fmt.Sprintf("invalid provider type: %s", providerConfig.ProviderType))
1056 }
1057
1058 // Validate custom providers
1059 isKnownProvider := slices.Contains(knownProviders, providerID)
1060
1061 if !isKnownProvider {
1062 // Custom provider validation
1063 if providerConfig.BaseURL == "" {
1064 errors.Add(fieldPrefix+".base_url", "BaseURL is required for custom providers")
1065 }
1066 if providerConfig.ProviderType != provider.TypeOpenAI {
1067 errors.Add(fieldPrefix+".provider_type", "custom providers currently only support OpenAI type")
1068 }
1069 }
1070
1071 // Validate models
1072 modelIDs := make(map[string]bool)
1073 for i, model := range providerConfig.Models {
1074 modelFieldPrefix := fmt.Sprintf("%s.models[%d]", fieldPrefix, i)
1075
1076 // Check for duplicate model IDs
1077 if modelIDs[model.ID] {
1078 errors.Add(modelFieldPrefix+".id", fmt.Sprintf("duplicate model ID: %s", model.ID))
1079 }
1080 modelIDs[model.ID] = true
1081
1082 // Validate required model fields
1083 if model.ID == "" {
1084 errors.Add(modelFieldPrefix+".id", "model ID is required")
1085 }
1086 if model.Name == "" {
1087 errors.Add(modelFieldPrefix+".name", "model name is required")
1088 }
1089 if model.ContextWindow <= 0 {
1090 errors.Add(modelFieldPrefix+".context_window", "context window must be positive")
1091 }
1092 if model.DefaultMaxTokens <= 0 {
1093 errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens must be positive")
1094 }
1095 if model.DefaultMaxTokens > model.ContextWindow {
1096 errors.Add(modelFieldPrefix+".default_max_tokens", "default max tokens cannot exceed context window")
1097 }
1098
1099 // Validate cost fields
1100 if model.CostPer1MIn < 0 {
1101 errors.Add(modelFieldPrefix+".cost_per_1m_in", "cost per 1M input tokens cannot be negative")
1102 }
1103 if model.CostPer1MOut < 0 {
1104 errors.Add(modelFieldPrefix+".cost_per_1m_out", "cost per 1M output tokens cannot be negative")
1105 }
1106 if model.CostPer1MInCached < 0 {
1107 errors.Add(modelFieldPrefix+".cost_per_1m_in_cached", "cached cost per 1M input tokens cannot be negative")
1108 }
1109 if model.CostPer1MOutCached < 0 {
1110 errors.Add(modelFieldPrefix+".cost_per_1m_out_cached", "cached cost per 1M output tokens cannot be negative")
1111 }
1112 }
1113
1114 // Validate default model references
1115 if providerConfig.DefaultLargeModel != "" {
1116 if !modelIDs[providerConfig.DefaultLargeModel] {
1117 errors.Add(fieldPrefix+".default_large_model", fmt.Sprintf("default large model '%s' not found in provider models", providerConfig.DefaultLargeModel))
1118 }
1119 }
1120 if providerConfig.DefaultSmallModel != "" {
1121 if !modelIDs[providerConfig.DefaultSmallModel] {
1122 errors.Add(fieldPrefix+".default_small_model", fmt.Sprintf("default small model '%s' not found in provider models", providerConfig.DefaultSmallModel))
1123 }
1124 }
1125
1126 // Validate provider-specific requirements
1127 c.validateProviderSpecific(providerID, providerConfig, errors)
1128 }
1129}
1130
1131// validateProviderSpecific validates provider-specific requirements
1132func (c *Config) validateProviderSpecific(providerID provider.InferenceProvider, providerConfig ProviderConfig, errors *ValidationErrors) {
1133 fieldPrefix := fmt.Sprintf("providers.%s", providerID)
1134
1135 switch providerID {
1136 case provider.InferenceProviderVertexAI:
1137 if !providerConfig.Disabled {
1138 if providerConfig.ExtraParams == nil {
1139 errors.Add(fieldPrefix+".extra_params", "VertexAI requires extra_params configuration")
1140 } else {
1141 if providerConfig.ExtraParams["project"] == "" {
1142 errors.Add(fieldPrefix+".extra_params.project", "VertexAI requires project parameter")
1143 }
1144 if providerConfig.ExtraParams["location"] == "" {
1145 errors.Add(fieldPrefix+".extra_params.location", "VertexAI requires location parameter")
1146 }
1147 }
1148 }
1149 case provider.InferenceProviderBedrock:
1150 if !providerConfig.Disabled {
1151 if providerConfig.ExtraParams == nil || providerConfig.ExtraParams["region"] == "" {
1152 errors.Add(fieldPrefix+".extra_params.region", "Bedrock requires region parameter")
1153 }
1154 // Check for AWS credentials in environment
1155 if !hasAWSCredentials() {
1156 errors.Add(fieldPrefix, "Bedrock requires AWS credentials in environment")
1157 }
1158 }
1159 }
1160}
1161
1162// validateModels validates preferred model configurations
1163func (c *Config) validateModels(errors *ValidationErrors) {
1164 // Validate large model
1165 if c.Models.Large.ModelID != "" || c.Models.Large.Provider != "" {
1166 if c.Models.Large.ModelID == "" {
1167 errors.Add("models.large.model_id", "large model ID is required when provider is set")
1168 }
1169 if c.Models.Large.Provider == "" {
1170 errors.Add("models.large.provider", "large model provider is required when model ID is set")
1171 }
1172
1173 // Check if provider exists and is not disabled
1174 if providerConfig, exists := c.Providers[c.Models.Large.Provider]; exists {
1175 if providerConfig.Disabled {
1176 errors.Add("models.large.provider", "large model provider is disabled")
1177 }
1178
1179 // Check if model exists in provider
1180 modelExists := false
1181 for _, model := range providerConfig.Models {
1182 if model.ID == c.Models.Large.ModelID {
1183 modelExists = true
1184 break
1185 }
1186 }
1187 if !modelExists {
1188 errors.Add("models.large.model_id", fmt.Sprintf("large model '%s' not found in provider '%s'", c.Models.Large.ModelID, c.Models.Large.Provider))
1189 }
1190 } else {
1191 errors.Add("models.large.provider", fmt.Sprintf("large model provider '%s' not found", c.Models.Large.Provider))
1192 }
1193 }
1194
1195 // Validate small model
1196 if c.Models.Small.ModelID != "" || c.Models.Small.Provider != "" {
1197 if c.Models.Small.ModelID == "" {
1198 errors.Add("models.small.model_id", "small model ID is required when provider is set")
1199 }
1200 if c.Models.Small.Provider == "" {
1201 errors.Add("models.small.provider", "small model provider is required when model ID is set")
1202 }
1203
1204 // Check if provider exists and is not disabled
1205 if providerConfig, exists := c.Providers[c.Models.Small.Provider]; exists {
1206 if providerConfig.Disabled {
1207 errors.Add("models.small.provider", "small model provider is disabled")
1208 }
1209
1210 // Check if model exists in provider
1211 modelExists := false
1212 for _, model := range providerConfig.Models {
1213 if model.ID == c.Models.Small.ModelID {
1214 modelExists = true
1215 break
1216 }
1217 }
1218 if !modelExists {
1219 errors.Add("models.small.model_id", fmt.Sprintf("small model '%s' not found in provider '%s'", c.Models.Small.ModelID, c.Models.Small.Provider))
1220 }
1221 } else {
1222 errors.Add("models.small.provider", fmt.Sprintf("small model provider '%s' not found", c.Models.Small.Provider))
1223 }
1224 }
1225}
1226
1227// validateAgents validates agent configurations
1228func (c *Config) validateAgents(errors *ValidationErrors) {
1229 if c.Agents == nil {
1230 c.Agents = make(map[AgentID]Agent)
1231 }
1232
1233 validTools := []string{
1234 "bash", "edit", "fetch", "glob", "grep", "ls", "sourcegraph", "view", "write", "agent",
1235 }
1236
1237 for agentID, agent := range c.Agents {
1238 fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1239
1240 // Validate agent ID consistency
1241 if agent.ID != agentID {
1242 errors.Add(fieldPrefix+".id", fmt.Sprintf("agent ID mismatch: expected '%s', got '%s'", agentID, agent.ID))
1243 }
1244
1245 // Validate required fields
1246 if agent.ID == "" {
1247 errors.Add(fieldPrefix+".id", "agent ID is required")
1248 }
1249 if agent.Name == "" {
1250 errors.Add(fieldPrefix+".name", "agent name is required")
1251 }
1252
1253 // Validate model type
1254 if agent.Model != LargeModel && agent.Model != SmallModel {
1255 errors.Add(fieldPrefix+".model", fmt.Sprintf("invalid model type: %s (must be 'large' or 'small')", agent.Model))
1256 }
1257
1258 // Validate allowed tools
1259 if agent.AllowedTools != nil {
1260 for i, tool := range agent.AllowedTools {
1261 validTool := slices.Contains(validTools, tool)
1262 if !validTool {
1263 errors.Add(fmt.Sprintf("%s.allowed_tools[%d]", fieldPrefix, i), fmt.Sprintf("unknown tool: %s", tool))
1264 }
1265 }
1266 }
1267
1268 // Validate MCP references
1269 if agent.AllowedMCP != nil {
1270 for mcpName := range agent.AllowedMCP {
1271 if _, exists := c.MCP[mcpName]; !exists {
1272 errors.Add(fieldPrefix+".allowed_mcp", fmt.Sprintf("referenced MCP '%s' not found", mcpName))
1273 }
1274 }
1275 }
1276
1277 // Validate LSP references
1278 if agent.AllowedLSP != nil {
1279 for _, lspName := range agent.AllowedLSP {
1280 if _, exists := c.LSP[lspName]; !exists {
1281 errors.Add(fieldPrefix+".allowed_lsp", fmt.Sprintf("referenced LSP '%s' not found", lspName))
1282 }
1283 }
1284 }
1285
1286 // Validate context paths (basic path validation)
1287 for i, contextPath := range agent.ContextPaths {
1288 if contextPath == "" {
1289 errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path cannot be empty")
1290 }
1291 // Check for invalid characters in path
1292 if strings.Contains(contextPath, "\x00") {
1293 errors.Add(fmt.Sprintf("%s.context_paths[%d]", fieldPrefix, i), "context path contains invalid characters")
1294 }
1295 }
1296
1297 // Validate known agents maintain their core properties
1298 if agentID == AgentCoder {
1299 if agent.Name != "Coder" {
1300 errors.Add(fieldPrefix+".name", "coder agent name cannot be changed")
1301 }
1302 if agent.Description != "An agent that helps with executing coding tasks." {
1303 errors.Add(fieldPrefix+".description", "coder agent description cannot be changed")
1304 }
1305 } else if agentID == AgentTask {
1306 if agent.Name != "Task" {
1307 errors.Add(fieldPrefix+".name", "task agent name cannot be changed")
1308 }
1309 if agent.Description != "An agent that helps with searching for context and finding implementation details." {
1310 errors.Add(fieldPrefix+".description", "task agent description cannot be changed")
1311 }
1312 expectedTools := []string{"glob", "grep", "ls", "sourcegraph", "view"}
1313 if agent.AllowedTools != nil && !slices.Equal(agent.AllowedTools, expectedTools) {
1314 errors.Add(fieldPrefix+".allowed_tools", "task agent allowed tools cannot be changed")
1315 }
1316 }
1317 }
1318}
1319
1320// validateOptions validates configuration options
1321func (c *Config) validateOptions(errors *ValidationErrors) {
1322 // Validate data directory
1323 if c.Options.DataDirectory == "" {
1324 errors.Add("options.data_directory", "data directory is required")
1325 }
1326
1327 // Validate context paths
1328 for i, contextPath := range c.Options.ContextPaths {
1329 if contextPath == "" {
1330 errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path cannot be empty")
1331 }
1332 if strings.Contains(contextPath, "\x00") {
1333 errors.Add(fmt.Sprintf("options.context_paths[%d]", i), "context path contains invalid characters")
1334 }
1335 }
1336}
1337
1338// validateMCPs validates MCP configurations
1339func (c *Config) validateMCPs(errors *ValidationErrors) {
1340 if c.MCP == nil {
1341 c.MCP = make(map[string]MCP)
1342 }
1343
1344 for mcpName, mcpConfig := range c.MCP {
1345 fieldPrefix := fmt.Sprintf("mcp.%s", mcpName)
1346
1347 // Validate MCP type
1348 if mcpConfig.Type != MCPStdio && mcpConfig.Type != MCPSse {
1349 errors.Add(fieldPrefix+".type", fmt.Sprintf("invalid MCP type: %s (must be 'stdio' or 'sse')", mcpConfig.Type))
1350 }
1351
1352 // Validate based on type
1353 if mcpConfig.Type == MCPStdio {
1354 if mcpConfig.Command == "" {
1355 errors.Add(fieldPrefix+".command", "command is required for stdio MCP")
1356 }
1357 } else if mcpConfig.Type == MCPSse {
1358 if mcpConfig.URL == "" {
1359 errors.Add(fieldPrefix+".url", "URL is required for SSE MCP")
1360 }
1361 }
1362 }
1363}
1364
1365// validateLSPs validates LSP configurations
1366func (c *Config) validateLSPs(errors *ValidationErrors) {
1367 if c.LSP == nil {
1368 c.LSP = make(map[string]LSPConfig)
1369 }
1370
1371 for lspName, lspConfig := range c.LSP {
1372 fieldPrefix := fmt.Sprintf("lsp.%s", lspName)
1373
1374 if lspConfig.Command == "" {
1375 errors.Add(fieldPrefix+".command", "command is required for LSP")
1376 }
1377 }
1378}
1379
1380// validateCrossReferences validates cross-references between different config sections
1381func (c *Config) validateCrossReferences(errors *ValidationErrors) {
1382 // Validate that agents can use their assigned model types
1383 for agentID, agent := range c.Agents {
1384 fieldPrefix := fmt.Sprintf("agents.%s", agentID)
1385
1386 var preferredModel PreferredModel
1387 switch agent.Model {
1388 case LargeModel:
1389 preferredModel = c.Models.Large
1390 case SmallModel:
1391 preferredModel = c.Models.Small
1392 }
1393
1394 if preferredModel.Provider != "" {
1395 if providerConfig, exists := c.Providers[preferredModel.Provider]; exists {
1396 if providerConfig.Disabled {
1397 errors.Add(fieldPrefix+".model", fmt.Sprintf("agent cannot use model type '%s' because provider '%s' is disabled", agent.Model, preferredModel.Provider))
1398 }
1399 }
1400 }
1401 }
1402}
1403
1404// validateCompleteness validates that the configuration is complete and usable
1405func (c *Config) validateCompleteness(errors *ValidationErrors) {
1406 // Check for at least one valid, non-disabled provider
1407 hasValidProvider := false
1408 for _, providerConfig := range c.Providers {
1409 if !providerConfig.Disabled {
1410 hasValidProvider = true
1411 break
1412 }
1413 }
1414 if !hasValidProvider {
1415 errors.Add("providers", "at least one non-disabled provider is required")
1416 }
1417
1418 // Check that default agents exist
1419 if _, exists := c.Agents[AgentCoder]; !exists {
1420 errors.Add("agents", "coder agent is required")
1421 }
1422 if _, exists := c.Agents[AgentTask]; !exists {
1423 errors.Add("agents", "task agent is required")
1424 }
1425
1426 // Check that preferred models are set if providers exist
1427 if hasValidProvider {
1428 if c.Models.Large.ModelID == "" || c.Models.Large.Provider == "" {
1429 errors.Add("models.large", "large preferred model must be configured when providers are available")
1430 }
1431 if c.Models.Small.ModelID == "" || c.Models.Small.Provider == "" {
1432 errors.Add("models.small", "small preferred model must be configured when providers are available")
1433 }
1434 }
1435}