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/kujtimiihoxha/opencode/internal/llm/models"
 11	"github.com/kujtimiihoxha/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.
199// the default model priority is:
200// 1. Anthropic
201// 2. OpenAI
202// 3. Google Gemini
203// 4. Groq
204// 5. AWS Bedrock
205func setProviderDefaults() {
206	// Anthropic configuration
207	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
208		viper.SetDefault("providers.anthropic.apiKey", apiKey)
209		viper.SetDefault("agents.coder.model", models.Claude37Sonnet)
210		viper.SetDefault("agents.task.model", models.Claude37Sonnet)
211		viper.SetDefault("agents.title.model", models.Claude37Sonnet)
212		return
213	}
214
215	// OpenAI configuration
216	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
217		viper.SetDefault("providers.openai.apiKey", apiKey)
218		viper.SetDefault("agents.coder.model", models.GPT41)
219		viper.SetDefault("agents.task.model", models.GPT41Mini)
220		viper.SetDefault("agents.title.model", models.GPT41Mini)
221		return
222	}
223
224	// Google Gemini configuration
225	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
226		viper.SetDefault("providers.gemini.apiKey", apiKey)
227		viper.SetDefault("agents.coder.model", models.Gemini25)
228		viper.SetDefault("agents.task.model", models.Gemini25Flash)
229		viper.SetDefault("agents.title.model", models.Gemini25Flash)
230		return
231	}
232
233	// Groq configuration
234	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
235		viper.SetDefault("providers.groq.apiKey", apiKey)
236		viper.SetDefault("agents.coder.model", models.QWENQwq)
237		viper.SetDefault("agents.task.model", models.QWENQwq)
238		viper.SetDefault("agents.title.model", models.QWENQwq)
239		return
240	}
241
242	// AWS Bedrock configuration
243	if hasAWSCredentials() {
244		viper.SetDefault("agents.coder.model", models.BedrockClaude37Sonnet)
245		viper.SetDefault("agents.task.model", models.BedrockClaude37Sonnet)
246		viper.SetDefault("agents.title.model", models.BedrockClaude37Sonnet)
247		return
248	}
249}
250
251// hasAWSCredentials checks if AWS credentials are available in the environment.
252func hasAWSCredentials() bool {
253	// Check for explicit AWS credentials
254	if os.Getenv("AWS_ACCESS_KEY_ID") != "" && os.Getenv("AWS_SECRET_ACCESS_KEY") != "" {
255		return true
256	}
257
258	// Check for AWS profile
259	if os.Getenv("AWS_PROFILE") != "" || os.Getenv("AWS_DEFAULT_PROFILE") != "" {
260		return true
261	}
262
263	// Check for AWS region
264	if os.Getenv("AWS_REGION") != "" || os.Getenv("AWS_DEFAULT_REGION") != "" {
265		return true
266	}
267
268	// Check if running on EC2 with instance profile
269	if os.Getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != "" ||
270		os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
271		return true
272	}
273
274	return false
275}
276
277// readConfig handles the result of reading a configuration file.
278func readConfig(err error) error {
279	if err == nil {
280		return nil
281	}
282
283	// It's okay if the config file doesn't exist
284	if _, ok := err.(viper.ConfigFileNotFoundError); ok {
285		return nil
286	}
287
288	return fmt.Errorf("failed to read config: %w", err)
289}
290
291// mergeLocalConfig loads and merges configuration from the local directory.
292func mergeLocalConfig(workingDir string) {
293	local := viper.New()
294	local.SetConfigName(fmt.Sprintf(".%s", appName))
295	local.SetConfigType("json")
296	local.AddConfigPath(workingDir)
297
298	// Merge local config if it exists
299	if err := local.ReadInConfig(); err == nil {
300		viper.MergeConfigMap(local.AllSettings())
301	}
302}
303
304// applyDefaultValues sets default values for configuration fields that need processing.
305func applyDefaultValues() {
306	// Set default MCP type if not specified
307	for k, v := range cfg.MCPServers {
308		if v.Type == "" {
309			v.Type = MCPStdio
310			cfg.MCPServers[k] = v
311		}
312	}
313}
314
315// Validate checks if the configuration is valid and applies defaults where needed.
316// It validates model IDs and providers, ensuring they are supported.
317func Validate() error {
318	if cfg == nil {
319		return fmt.Errorf("config not loaded")
320	}
321
322	// Validate agent models
323	for name, agent := range cfg.Agents {
324		// Check if model exists
325		model, modelExists := models.SupportedModels[agent.Model]
326		if !modelExists {
327			logging.Warn("unsupported model configured, reverting to default",
328				"agent", name,
329				"configured_model", agent.Model)
330
331			// Set default model based on available providers
332			if setDefaultModelForAgent(name) {
333				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
334			} else {
335				return fmt.Errorf("no valid provider available for agent %s", name)
336			}
337			continue
338		}
339
340		// Check if provider for the model is configured
341		provider := model.Provider
342		providerCfg, providerExists := cfg.Providers[provider]
343
344		if !providerExists {
345			// Provider not configured, check if we have environment variables
346			apiKey := getProviderAPIKey(provider)
347			if apiKey == "" {
348				logging.Warn("provider not configured for model, reverting to default",
349					"agent", name,
350					"model", agent.Model,
351					"provider", provider)
352
353				// Set default model based on available providers
354				if setDefaultModelForAgent(name) {
355					logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
356				} else {
357					return fmt.Errorf("no valid provider available for agent %s", name)
358				}
359			} else {
360				// Add provider with API key from environment
361				cfg.Providers[provider] = Provider{
362					APIKey: apiKey,
363				}
364				logging.Info("added provider from environment", "provider", provider)
365			}
366		} else if providerCfg.Disabled || providerCfg.APIKey == "" {
367			// Provider is disabled or has no API key
368			logging.Warn("provider is disabled or has no API key, reverting to default",
369				"agent", name,
370				"model", agent.Model,
371				"provider", provider)
372
373			// Set default model based on available providers
374			if setDefaultModelForAgent(name) {
375				logging.Info("set default model for agent", "agent", name, "model", cfg.Agents[name].Model)
376			} else {
377				return fmt.Errorf("no valid provider available for agent %s", name)
378			}
379		}
380
381		// Validate max tokens
382		if agent.MaxTokens <= 0 {
383			logging.Warn("invalid max tokens, setting to default",
384				"agent", name,
385				"model", agent.Model,
386				"max_tokens", agent.MaxTokens)
387
388			// Update the agent with default max tokens
389			updatedAgent := cfg.Agents[name]
390			if model.DefaultMaxTokens > 0 {
391				updatedAgent.MaxTokens = model.DefaultMaxTokens
392			} else {
393				updatedAgent.MaxTokens = 4096 // Fallback default
394			}
395			cfg.Agents[name] = updatedAgent
396		} else if model.ContextWindow > 0 && agent.MaxTokens > model.ContextWindow/2 {
397			// Ensure max tokens doesn't exceed half the context window (reasonable limit)
398			logging.Warn("max tokens exceeds half the context window, adjusting",
399				"agent", name,
400				"model", agent.Model,
401				"max_tokens", agent.MaxTokens,
402				"context_window", model.ContextWindow)
403
404			// Update the agent with adjusted max tokens
405			updatedAgent := cfg.Agents[name]
406			updatedAgent.MaxTokens = model.ContextWindow / 2
407			cfg.Agents[name] = updatedAgent
408		}
409
410		// Validate reasoning effort for models that support reasoning
411		if model.CanReason && provider == models.ProviderOpenAI {
412			if agent.ReasoningEffort == "" {
413				// Set default reasoning effort for models that support it
414				logging.Info("setting default reasoning effort for model that supports reasoning",
415					"agent", name,
416					"model", agent.Model)
417
418				// Update the agent with default reasoning effort
419				updatedAgent := cfg.Agents[name]
420				updatedAgent.ReasoningEffort = "medium"
421				cfg.Agents[name] = updatedAgent
422			} else {
423				// Check if reasoning effort is valid (low, medium, high)
424				effort := strings.ToLower(agent.ReasoningEffort)
425				if effort != "low" && effort != "medium" && effort != "high" {
426					logging.Warn("invalid reasoning effort, setting to medium",
427						"agent", name,
428						"model", agent.Model,
429						"reasoning_effort", agent.ReasoningEffort)
430
431					// Update the agent with valid reasoning effort
432					updatedAgent := cfg.Agents[name]
433					updatedAgent.ReasoningEffort = "medium"
434					cfg.Agents[name] = updatedAgent
435				}
436			}
437		} else if !model.CanReason && agent.ReasoningEffort != "" {
438			// Model doesn't support reasoning but reasoning effort is set
439			logging.Warn("model doesn't support reasoning but reasoning effort is set, ignoring",
440				"agent", name,
441				"model", agent.Model,
442				"reasoning_effort", agent.ReasoningEffort)
443
444			// Update the agent to remove reasoning effort
445			updatedAgent := cfg.Agents[name]
446			updatedAgent.ReasoningEffort = ""
447			cfg.Agents[name] = updatedAgent
448		}
449	}
450
451	// Validate providers
452	for provider, providerCfg := range cfg.Providers {
453		if providerCfg.APIKey == "" && !providerCfg.Disabled {
454			logging.Warn("provider has no API key, marking as disabled", "provider", provider)
455			providerCfg.Disabled = true
456			cfg.Providers[provider] = providerCfg
457		}
458	}
459
460	// Validate LSP configurations
461	for language, lspConfig := range cfg.LSP {
462		if lspConfig.Command == "" && !lspConfig.Disabled {
463			logging.Warn("LSP configuration has no command, marking as disabled", "language", language)
464			lspConfig.Disabled = true
465			cfg.LSP[language] = lspConfig
466		}
467	}
468
469	return nil
470}
471
472// getProviderAPIKey gets the API key for a provider from environment variables
473func getProviderAPIKey(provider models.ModelProvider) string {
474	switch provider {
475	case models.ProviderAnthropic:
476		return os.Getenv("ANTHROPIC_API_KEY")
477	case models.ProviderOpenAI:
478		return os.Getenv("OPENAI_API_KEY")
479	case models.ProviderGemini:
480		return os.Getenv("GEMINI_API_KEY")
481	case models.ProviderGROQ:
482		return os.Getenv("GROQ_API_KEY")
483	case models.ProviderBedrock:
484		if hasAWSCredentials() {
485			return "aws-credentials-available"
486		}
487	}
488	return ""
489}
490
491// setDefaultModelForAgent sets a default model for an agent based on available providers
492func setDefaultModelForAgent(agent AgentName) bool {
493	// Check providers in order of preference
494	if apiKey := os.Getenv("ANTHROPIC_API_KEY"); apiKey != "" {
495		maxTokens := int64(5000)
496		if agent == AgentTitle {
497			maxTokens = 80
498		}
499		cfg.Agents[agent] = Agent{
500			Model:     models.Claude37Sonnet,
501			MaxTokens: maxTokens,
502		}
503		return true
504	}
505
506	if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
507		var model models.ModelID
508		maxTokens := int64(5000)
509		reasoningEffort := ""
510
511		switch agent {
512		case AgentTitle:
513			model = models.GPT41Mini
514			maxTokens = 80
515		case AgentTask:
516			model = models.GPT41Mini
517		default:
518			model = models.GPT41
519		}
520
521		// Check if model supports reasoning
522		if modelInfo, ok := models.SupportedModels[model]; ok && modelInfo.CanReason {
523			reasoningEffort = "medium"
524		}
525
526		cfg.Agents[agent] = Agent{
527			Model:           model,
528			MaxTokens:       maxTokens,
529			ReasoningEffort: reasoningEffort,
530		}
531		return true
532	}
533
534	if apiKey := os.Getenv("GEMINI_API_KEY"); apiKey != "" {
535		var model models.ModelID
536		maxTokens := int64(5000)
537
538		if agent == AgentTitle {
539			model = models.Gemini25Flash
540			maxTokens = 80
541		} else {
542			model = models.Gemini25
543		}
544
545		cfg.Agents[agent] = Agent{
546			Model:     model,
547			MaxTokens: maxTokens,
548		}
549		return true
550	}
551
552	if apiKey := os.Getenv("GROQ_API_KEY"); apiKey != "" {
553		maxTokens := int64(5000)
554		if agent == AgentTitle {
555			maxTokens = 80
556		}
557
558		cfg.Agents[agent] = Agent{
559			Model:     models.QWENQwq,
560			MaxTokens: maxTokens,
561		}
562		return true
563	}
564
565	if hasAWSCredentials() {
566		maxTokens := int64(5000)
567		if agent == AgentTitle {
568			maxTokens = 80
569		}
570
571		cfg.Agents[agent] = Agent{
572			Model:           models.BedrockClaude37Sonnet,
573			MaxTokens:       maxTokens,
574			ReasoningEffort: "medium", // Claude models support reasoning
575		}
576		return true
577	}
578
579	return false
580}
581
582// Get returns the current configuration.
583// It's safe to call this function multiple times.
584func Get() *Config {
585	return cfg
586}
587
588// WorkingDirectory returns the current working directory from the configuration.
589func WorkingDirectory() string {
590	if cfg == nil {
591		panic("config not loaded")
592	}
593	return cfg.WorkingDir
594}