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}