config.go

  1// Package config manages application configuration from various sources.
  2package config
  3
  4import (
  5	"fmt"
  6	"log/slog"
  7	"os"
  8	"strings"
  9
 10	"github.com/opencode-ai/opencode/internal/llm/models"
 11	"github.com/opencode-ai/opencode/internal/logging"
 12	"github.com/spf13/viper"
 13)
 14
 15// MCPType defines the type of MCP (Model Control Protocol) server.
 16type MCPType string
 17
 18// Supported MCP types
 19const (
 20	MCPStdio MCPType = "stdio"
 21	MCPSse   MCPType = "sse"
 22)
 23
 24// MCPServer defines the configuration for a Model Control Protocol server.
 25type MCPServer struct {
 26	Command string            `json:"command"`
 27	Env     []string          `json:"env"`
 28	Args    []string          `json:"args"`
 29	Type    MCPType           `json:"type"`
 30	URL     string            `json:"url"`
 31	Headers map[string]string `json:"headers"`
 32}
 33
 34type AgentName string
 35
 36const (
 37	AgentCoder AgentName = "coder"
 38	AgentTask  AgentName = "task"
 39	AgentTitle AgentName = "title"
 40)
 41
 42// Agent defines configuration for different LLM models and their token limits.
 43type Agent struct {
 44	Model           models.ModelID `json:"model"`
 45	MaxTokens       int64          `json:"maxTokens"`
 46	ReasoningEffort string         `json:"reasoningEffort"` // For openai models low,medium,heigh
 47}
 48
 49// Provider defines configuration for an LLM provider.
 50type Provider struct {
 51	APIKey   string `json:"apiKey"`
 52	Disabled bool   `json:"disabled"`
 53}
 54
 55// Data defines storage configuration.
 56type Data struct {
 57	Directory string `json:"directory"`
 58}
 59
 60// LSPConfig defines configuration for Language Server Protocol integration.
 61type LSPConfig struct {
 62	Disabled bool     `json:"enabled"`
 63	Command  string   `json:"command"`
 64	Args     []string `json:"args"`
 65	Options  any      `json:"options"`
 66}
 67
 68// Config is the main configuration structure for the application.
 69type Config struct {
 70	Data       Data                              `json:"data"`
 71	WorkingDir string                            `json:"wd,omitempty"`
 72	MCPServers map[string]MCPServer              `json:"mcpServers,omitempty"`
 73	Providers  map[models.ModelProvider]Provider `json:"providers,omitempty"`
 74	LSP        map[string]LSPConfig              `json:"lsp,omitempty"`
 75	Agents     map[AgentName]Agent               `json:"agents"`
 76	Debug      bool                              `json:"debug,omitempty"`
 77	DebugLSP   bool                              `json:"debugLSP,omitempty"`
 78}
 79
 80// Application constants
 81const (
 82	defaultDataDirectory = ".opencode"
 83	defaultLogLevel      = "info"
 84	appName              = "opencode"
 85)
 86
 87// Global configuration instance
 88var cfg *Config
 89
 90// Load initializes the configuration from environment variables and config files.
 91// If debug is true, debug mode is enabled and log level is set to debug.
 92// It returns an error if configuration loading fails.
 93func Load(workingDir string, debug bool) (*Config, error) {
 94	if cfg != nil {
 95		return cfg, nil
 96	}
 97
 98	cfg = &Config{
 99		WorkingDir: workingDir,
100		MCPServers: make(map[string]MCPServer),
101		Providers:  make(map[models.ModelProvider]Provider),
102		LSP:        make(map[string]LSPConfig),
103	}
104
105	configureViper()
106	setDefaults(debug)
107	setProviderDefaults()
108
109	// Read global config
110	if err := readConfig(viper.ReadInConfig()); err != nil {
111		return cfg, err
112	}
113
114	// Load and merge local config
115	mergeLocalConfig(workingDir)
116
117	// Apply configuration to the struct
118	if err := viper.Unmarshal(cfg); err != nil {
119		return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
120	}
121
122	applyDefaultValues()
123	defaultLevel := slog.LevelInfo
124	if cfg.Debug {
125		defaultLevel = slog.LevelDebug
126	}
127	if os.Getenv("OPENCODE_DEV_DEBUG") == "true" {
128		loggingFile := fmt.Sprintf("%s/%s", cfg.Data.Directory, "debug.log")
129
130		// if file does not exist create it
131		if _, err := os.Stat(loggingFile); os.IsNotExist(err) {
132			if err := os.MkdirAll(cfg.Data.Directory, 0o755); err != nil {
133				return cfg, fmt.Errorf("failed to create directory: %w", err)
134			}
135			if _, err := os.Create(loggingFile); err != nil {
136				return cfg, fmt.Errorf("failed to create log file: %w", err)
137			}
138		}
139
140		sloggingFileWriter, err := os.OpenFile(loggingFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666)
141		if err != nil {
142			return cfg, fmt.Errorf("failed to open log file: %w", err)
143		}
144		// Configure logger
145		logger := slog.New(slog.NewTextHandler(sloggingFileWriter, &slog.HandlerOptions{
146			Level: defaultLevel,
147		}))
148		slog.SetDefault(logger)
149	} else {
150		// Configure logger
151		logger := slog.New(slog.NewTextHandler(logging.NewWriter(), &slog.HandlerOptions{
152			Level: defaultLevel,
153		}))
154		slog.SetDefault(logger)
155	}
156
157	// Validate configuration
158	if err := Validate(); err != nil {
159		return cfg, fmt.Errorf("config validation failed: %w", err)
160	}
161
162	if cfg.Agents == nil {
163		cfg.Agents = make(map[AgentName]Agent)
164	}
165
166	// Override the max tokens for title agent
167	cfg.Agents[AgentTitle] = Agent{
168		Model:     cfg.Agents[AgentTitle].Model,
169		MaxTokens: 80,
170	}
171	return cfg, nil
172}
173
174// configureViper sets up viper's configuration paths and environment variables.
175func configureViper() {
176	viper.SetConfigName(fmt.Sprintf(".%s", appName))
177	viper.SetConfigType("json")
178	viper.AddConfigPath("$HOME")
179	viper.AddConfigPath(fmt.Sprintf("$XDG_CONFIG_HOME/%s", appName))
180	viper.AddConfigPath(fmt.Sprintf("$HOME/.config/%s", appName))
181	viper.SetEnvPrefix(strings.ToUpper(appName))
182	viper.AutomaticEnv()
183}
184
185// setDefaults configures default values for configuration options.
186func setDefaults(debug bool) {
187	viper.SetDefault("data.directory", defaultDataDirectory)
188
189	if debug {
190		viper.SetDefault("debug", true)
191		viper.Set("log.level", "debug")
192	} else {
193		viper.SetDefault("debug", false)
194		viper.SetDefault("log.level", defaultLogLevel)
195	}
196}
197
198// setProviderDefaults configures LLM provider defaults based on environment variables.
199func setProviderDefaults() {
200	// Set all API keys we can find in the environment
201	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
202		viper.SetDefault("providers.anthropic.apiKey", apiKey)
203	}
204	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
205		viper.SetDefault("providers.openai.apiKey", apiKey)
206	}
207	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
208		viper.SetDefault("providers.gemini.apiKey", apiKey)
209	}
210	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
211		viper.SetDefault("providers.groq.apiKey", apiKey)
212	}
213
214	// Use this order to set the default models
215	// 1. Anthropic
216	// 2. OpenAI
217	// 3. Google Gemini
218	// 4. Groq
219	// 5. AWS Bedrock
220	// Anthropic configuration
221	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
222		viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
223		viper.SetDefault("agents.task.model", models.Claude37Sonnet)
224		viper.SetDefault("agents.title.model", models.Claude37Sonnet)
225		return
226	}
227
228	// OpenAI configuration
229	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
230		viper.SetDefault("agents.coder.model", models.GPT41)
231		viper.SetDefault("agents.task.model", models.GPT41Mini)
232		viper.SetDefault("agents.title.model", models.GPT41Mini)
233		return
234	}
235
236	// Google Gemini configuration
237	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
238		viper.SetDefault("agents.coder.model", models.Gemini25)
239		viper.SetDefault("agents.task.model", models.Gemini25Flash)
240		viper.SetDefault("agents.title.model", models.Gemini25Flash)
241		return
242	}
243
244	// Groq configuration
245	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
246		viper.SetDefault("agents.coder.model", models.QWENQwq)
247		viper.SetDefault("agents.task.model", models.QWENQwq)
248		viper.SetDefault("agents.title.model", models.QWENQwq)
249		return
250	}
251
252	// AWS Bedrock configuration
253	if hasAWSCredentials() {
254		viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
255		viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
256		viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
257		return
258	}
259}
260
261// hasAWSCredentials checks if AWS credentials are available in the environment.
262func hasAWSCredentials() bool {
263	// Check for explicit AWS credentials
264	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
265		return true
266	}
267
268	// Check for AWS profile
269	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
270		return true
271	}
272
273	// Check for AWS region
274	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
275		return true
276	}
277
278	// Check if running on EC2 with instance profile
279	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
280		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
281		return true
282	}
283
284	return false
285}
286
287// readConfig handles the result of reading a configuration file.
288func readConfig(err error) error {
289	if err == nil {
290		return nil
291	}
292
293	// It's okay if the config file doesn't exist
294	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
295		return nil
296	}
297
298	return fmt.Errorf("failed to read config: %w", err)
299}
300
301// mergeLocalConfig loads and merges configuration from the local directory.
302func mergeLocalConfig(workingDir string) {
303	local := viper.New()
304	local.SetConfigName(fmt.Sprintf(".%s", appName))
305	local.SetConfigType("json")
306	local.AddConfigPath(workingDir)
307
308	// Merge local config if it exists
309	if err := local.ReadInConfig(); err == nil {
310		viper.MergeConfigMap(local.AllSettings())
311	}
312}
313
314// applyDefaultValues sets default values for configuration fields that need processing.
315func applyDefaultValues() {
316	// Set default MCP type if not specified
317	for k, v := range cfg.MCPServers {
318		if v.Type == "" {
319			v.Type = MCPStdio
320			cfg.MCPServers[k] = v
321		}
322	}
323}
324
325// Validate checks if the configuration is valid and applies defaults where needed.
326// It validates model IDs and providers, ensuring they are supported.
327func Validate() error {
328	if cfg == nil {
329		return fmt.Errorf("config not loaded")
330	}
331
332	// Validate agent models
333	for name, agent := range cfg.Agents {
334		// Check if model exists
335		model, modelExists := models.SupportedModels[agent.Model]
336		if !modelExists {
337			logging.Warn("unsupported model configured, reverting to default",
338				"agent", name,
339				"configured_model", agent.Model)
340
341			// Set default model based on available providers
342			if setDefaultModelForAgent(name) {
343				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
344			} else {
345				return fmt.Errorf("no valid provider available for agent %s", name)
346			}
347			continue
348		}
349
350		// Check if provider for the model is configured
351		provider := model.Provider
352		providerCfg, providerExists := cfg.Providers[provider]
353
354		if !providerExists {
355			// Provider not configured, check if we have environment variables
356			apiKey := getProviderAPIKey(provider)
357			if apiKey == "" {
358				logging.Warn("provider not configured for model, reverting to default",
359					"agent", name,
360					"model", agent.Model,
361					"provider", provider)
362
363				// Set default model based on available providers
364				if setDefaultModelForAgent(name) {
365					logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
366				} else {
367					return fmt.Errorf("no valid provider available for agent %s", name)
368				}
369			} else {
370				// Add provider with API key from environment
371				cfg.Providers[provider] = Provider{
372					APIKey: apiKey,
373				}
374				logging.Info("added provider from environment", "provider", provider)
375			}
376		} else if providerCfg.Disabled || providerCfg.APIKey == "" {
377			// Provider is disabled or has no API key
378			logging.Warn("provider is disabled or has no API key, reverting to default",
379				"agent", name,
380				"model", agent.Model,
381				"provider", provider)
382
383			// Set default model based on available providers
384			if setDefaultModelForAgent(name) {
385				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
386			} else {
387				return fmt.Errorf("no valid provider available for agent %s", name)
388			}
389		}
390
391		// Validate max tokens
392		if agent.MaxTokens <= 0 {
393			logging.Warn("invalid max tokens, setting to default",
394				"agent", name,
395				"model", agent.Model,
396				"max_tokens", agent.MaxTokens)
397
398			// Update the agent with default max tokens
399			updatedAgent := cfg.Agents[name]
400			if model.DefaultMaxTokens > 0 {
401				updatedAgent.MaxTokens = model.DefaultMaxTokens
402			} else {
403				updatedAgent.MaxTokens = 4096 // Fallback default
404			}
405			cfg.Agents[name] = updatedAgent
406		} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
407			// Ensure max tokens doesn't exceed half the context window (reasonable limit)
408			logging.Warn("max tokens exceeds half the context window, adjusting",
409				"agent", name,
410				"model", agent.Model,
411				"max_tokens", agent.MaxTokens,
412				"context_window", model.ContextWindow)
413
414			// Update the agent with adjusted max tokens
415			updatedAgent := cfg.Agents[name]
416			updatedAgent.MaxTokens = model.ContextWindow / 2
417			cfg.Agents[name] = updatedAgent
418		}
419
420		// Validate reasoning effort for models that support reasoning
421		if model.CanReason && provider == models.ProviderOpenAI {
422			if agent.ReasoningEffort == "" {
423				// Set default reasoning effort for models that support it
424				logging.Info("setting default reasoning effort for model that supports reasoning",
425					"agent", name,
426					"model", agent.Model)
427
428				// Update the agent with default reasoning effort
429				updatedAgent := cfg.Agents[name]
430				updatedAgent.ReasoningEffort = "medium"
431				cfg.Agents[name] = updatedAgent
432			} else {
433				// Check if reasoning effort is valid (low, medium, high)
434				effort := strings.ToLower(agent.ReasoningEffort)
435				if effort != "low" && effort != "medium" && effort != "high" {
436					logging.Warn("invalid reasoning effort, setting to medium",
437						"agent", name,
438						"model", agent.Model,
439						"reasoning_effort", agent.ReasoningEffort)
440
441					// Update the agent with valid reasoning effort
442					updatedAgent := cfg.Agents[name]
443					updatedAgent.ReasoningEffort = "medium"
444					cfg.Agents[name] = updatedAgent
445				}
446			}
447		} else if !model.CanReason && agent.ReasoningEffort != "" {
448			// Model doesn't support reasoning but reasoning effort is set
449			logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
450				"agent", name,
451				"model", agent.Model,
452				"reasoning_effort", agent.ReasoningEffort)
453
454			// Update the agent to remove reasoning effort
455			updatedAgent := cfg.Agents[name]
456			updatedAgent.ReasoningEffort = ""
457			cfg.Agents[name] = updatedAgent
458		}
459	}
460
461	// Validate providers
462	for provider, providerCfg := range cfg.Providers {
463		if providerCfg.APIKey == "" && !providerCfg.Disabled {
464			logging.Warn("provider has no API key, marking as disabled", "provider", provider)
465			providerCfg.Disabled = true
466			cfg.Providers[provider] = providerCfg
467		}
468	}
469
470	// Validate LSP configurations
471	for language, lspConfig := range cfg.LSP {
472		if lspConfig.Command == "" && !lspConfig.Disabled {
473			logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
474			lspConfig.Disabled = true
475			cfg.LSP[language] = lspConfig
476		}
477	}
478
479	return nil
480}
481
482// getProviderAPIKey gets the API key for a provider from environment variables
483func getProviderAPIKey(provider models.ModelProvider) string {
484	switch provider {
485	case models.ProviderAnthropic:
486		return os.Getenv("ANTHROPIC_API_KEY")
487	case models.ProviderOpenAI:
488		return os.Getenv("OPENAI_API_KEY")
489	case models.ProviderGemini:
490		return os.Getenv("GEMINI_API_KEY")
491	case models.ProviderGROQ:
492		return os.Getenv("GROQ_API_KEY")
493	case models.ProviderBedrock:
494		if hasAWSCredentials() {
495			return "aws-credentials-available"
496		}
497	}
498	return ""
499}
500
501// setDefaultModelForAgent sets a default model for an agent based on available providers
502func setDefaultModelForAgent(agent AgentName) bool {
503	// Check providers in order of preference
504	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
505		maxTokens := int64(5000)
506		if agent == AgentTitle {
507			maxTokens = 80
508		}
509		cfg.Agents[agent] = Agent{
510			Model:     models.Claude37Sonnet,
511			MaxTokens: maxTokens,
512		}
513		return true
514	}
515
516	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
517		var model models.ModelID
518		maxTokens := int64(5000)
519		reasoningEffort := ""
520
521		switch agent {
522		case AgentTitle:
523			model = models.GPT41Mini
524			maxTokens = 80
525		case AgentTask:
526			model = models.GPT41Mini
527		default:
528			model = models.GPT41
529		}
530
531		// Check if model supports reasoning
532		if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
533			reasoningEffort = "medium"
534		}
535
536		cfg.Agents[agent] = Agent{
537			Model:           model,
538			MaxTokens:       maxTokens,
539			ReasoningEffort: reasoningEffort,
540		}
541		return true
542	}
543
544	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
545		var model models.ModelID
546		maxTokens := int64(5000)
547
548		if agent == AgentTitle {
549			model = models.Gemini25Flash
550			maxTokens = 80
551		} else {
552			model = models.Gemini25
553		}
554
555		cfg.Agents[agent] = Agent{
556			Model:     model,
557			MaxTokens: maxTokens,
558		}
559		return true
560	}
561
562	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
563		maxTokens := int64(5000)
564		if agent == AgentTitle {
565			maxTokens = 80
566		}
567
568		cfg.Agents[agent] = Agent{
569			Model:     models.QWENQwq,
570			MaxTokens: maxTokens,
571		}
572		return true
573	}
574
575	if hasAWSCredentials() {
576		maxTokens := int64(5000)
577		if agent == AgentTitle {
578			maxTokens = 80
579		}
580
581		cfg.Agents[agent] = Agent{
582			Model:           models.BedrockClaude37Sonnet,
583			MaxTokens:       maxTokens,
584			ReasoningEffort: "medium", // Claude models support reasoning
585		}
586		return true
587	}
588
589	return false
590}
591
592// Get returns the current configuration.
593// It's safe to call this function multiple times.
594func Get() *Config {
595	return cfg
596}
597
598// WorkingDirectory returns the current working directory from the configuration.
599func WorkingDirectory() string {
600	if cfg == nil {
601		panic("config not loaded")
602	}
603	return cfg.WorkingDir
604}