Skip to main content

matrixcode_core/
config.rs

1//! Configuration loading for matrixcode.
2//!
3//! Universal naming style (no provider-specific prefixes):
4//! - api_key
5//! - base_url
6//! - model
7//! - plan_model
8//! - compress_model
9//!
10//! Also supports Claude Code style aliases for compatibility:
11//! - ANTHROPIC_AUTH_TOKEN (alias for api_key)
12//! - ANTHROPIC_BASE_URL (alias for base_url)
13//! - ANTHROPIC_MODEL (alias for model)
14//!
15//! Priority (highest to lowest):
16//! 1. Environment variables (API_KEY, BASE_URL, MODEL, etc.)
17//! 2. ~/.matrix/config.json (matrixcode's own config)
18//! 3. ~/.claude/settings.json (Claude Code fallback)
19//! 4. Defaults
20
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::env;
24use std::path::PathBuf;
25
26/// Matrixcode configuration file structure.
27/// Uses universal naming (no ANTHROPIC_ prefix).
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct MatrixConfig {
30    /// LLM provider: "anthropic" or "openai"
31    #[serde(default)]
32    pub provider: Option<String>,
33
34    /// API key (universal naming, also supports ANTHROPIC_AUTH_TOKEN alias)
35    #[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
36    pub api_key: Option<String>,
37
38    /// Base URL for API endpoint
39    #[serde(default, alias = "ANTHROPIC_BASE_URL")]
40    pub base_url: Option<String>,
41
42    /// Main model name
43    #[serde(default, alias = "ANTHROPIC_MODEL")]
44    pub model: Option<String>,
45
46    /// Enable extended thinking
47    #[serde(default = "default_true")]
48    pub think: bool,
49
50    /// Enable markdown rendering
51    #[serde(default = "default_true")]
52    pub markdown: bool,
53
54    /// Maximum output tokens
55    #[serde(default = "default_max_tokens")]
56    pub max_tokens: u32,
57
58    /// Context size
59    #[serde(default)]
60    pub context_size: Option<u32>,
61
62    /// Multi-model configuration
63    #[serde(default)]
64    pub multi_model: Option<bool>,
65
66    /// Plan/reasoning model
67    #[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
68    pub plan_model: Option<String>,
69
70    /// Compress/haiku model
71    #[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
72    pub compress_model: Option<String>,
73
74    /// Fast model
75    #[serde(default)]
76    pub fast_model: Option<String>,
77
78    /// Approve mode: "ask", "auto", "strict"
79    #[serde(default = "default_approve_mode")]
80    pub approve_mode: Option<String>,
81
82    /// Extra HTTP headers to add to API requests
83    /// Format: {"Header-Name": "header-value"}
84    #[serde(default)]
85    pub extra_headers: Option<HashMap<String, String>>,
86}
87
88fn default_true() -> bool {
89    true
90}
91fn default_max_tokens() -> u32 {
92    16384
93}
94fn default_approve_mode() -> Option<String> {
95    Some("ask".to_string())
96}
97
98/// Type alias for compatibility
99pub type Config = MatrixConfig;
100
101/// Claude Code settings.json structure (for fallback).
102#[derive(Debug, Clone, Deserialize)]
103struct ClaudeSettings {
104    #[serde(default)]
105    env: Option<ClaudeEnv>,
106}
107
108/// Environment variables from Claude Code settings.
109#[derive(Debug, Clone, Deserialize)]
110#[allow(non_snake_case)]
111struct ClaudeEnv {
112    #[serde(default)]
113    ANTHROPIC_AUTH_TOKEN: Option<String>,
114    #[serde(default)]
115    ANTHROPIC_BASE_URL: Option<String>,
116    #[serde(default)]
117    ANTHROPIC_MODEL: Option<String>,
118    #[serde(default)]
119    ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
120    #[serde(default)]
121    ANTHROPIC_REASONING_MODEL: Option<String>,
122}
123
124impl MatrixConfig {
125    /// Get the home directory.
126    fn home_dir() -> Option<PathBuf> {
127        env::var_os("HOME")
128            .or_else(|| env::var_os("USERPROFILE"))
129            .map(PathBuf::from)
130    }
131
132    /// Path to matrixcode config file.
133    pub fn matrix_config_path() -> Option<PathBuf> {
134        Self::home_dir().map(|h| h.join(".matrix").join("config.json"))
135    }
136
137    /// Path to Claude Code settings file.
138    pub fn claude_settings_path() -> Option<PathBuf> {
139        Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
140    }
141
142    /// Load matrixcode's own config file.
143    fn load_matrix_config() -> Option<Self> {
144        let path = Self::matrix_config_path()?;
145        if !path.exists() {
146            return None;
147        }
148
149        let content = match std::fs::read_to_string(&path) {
150            Ok(c) => c,
151            Err(e) => {
152                log::warn!("Failed to read ~/.matrix/config.json: {}", e);
153                return None;
154            }
155        };
156        let config: Self = match serde_json::from_str(&content) {
157            Ok(c) => c,
158            Err(e) => {
159                log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
160                return None;
161            }
162        };
163
164        Some(config)
165    }
166
167    /// Load Claude Code settings as fallback.
168    fn load_claude_settings() -> Option<Self> {
169        let path = Self::claude_settings_path()?;
170        if !path.exists() {
171            return None;
172        }
173
174        let content = match std::fs::read_to_string(&path) {
175            Ok(c) => c,
176            Err(e) => {
177                log::warn!("Failed to read ~/.claude/settings.json: {}", e);
178                return None;
179            }
180        };
181        let settings: ClaudeSettings = match serde_json::from_str(&content) {
182            Ok(s) => s,
183            Err(e) => {
184                log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
185                return None;
186            }
187        };
188
189        let env = settings.env?;
190        Some(Self {
191            provider: Some("anthropic".to_string()),
192            api_key: env.ANTHROPIC_AUTH_TOKEN,
193            base_url: env.ANTHROPIC_BASE_URL,
194            model: env.ANTHROPIC_MODEL,
195            think: true,
196            markdown: true,
197            max_tokens: 16384,
198            context_size: None,
199            multi_model: None,
200            plan_model: env.ANTHROPIC_REASONING_MODEL,
201            compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
202            fast_model: None,
203            approve_mode: Some("ask".to_string()),
204            extra_headers: None,
205        })
206    }
207
208    /// Load configuration from environment variables.
209    /// Universal env vars: API_KEY, BASE_URL, MODEL
210    /// Also supports legacy: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL
211    fn load_from_env() -> Self {
212        // Parse EXTRA_HEADERS from env if available (JSON format)
213        let extra_headers = env::var("EXTRA_HEADERS").ok()
214            .and_then(|json_str| serde_json::from_str::<HashMap<String, String>>(&json_str).ok());
215
216        Self {
217            provider: env::var("PROVIDER").ok(),
218            api_key: env::var("API_KEY").ok()
219                .or_else(|| env::var("ANTHROPIC_AUTH_TOKEN").ok())
220                .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
221            base_url: env::var("BASE_URL").ok()
222                .or_else(|| env::var("ANTHROPIC_BASE_URL").ok()),
223            model: env::var("MODEL").ok()
224                .or_else(|| env::var("ANTHROPIC_MODEL").ok())
225                .or_else(|| env::var("MODEL_NAME").ok()),
226            think: env::var("THINK").ok()
227                .map(|v| v != "false")
228                .unwrap_or(true),
229            markdown: env::var("MARKDOWN").ok()
230                .map(|v| v != "false")
231                .unwrap_or(true),
232            max_tokens: env::var("MAX_TOKENS").ok()
233                .and_then(|v| v.parse().ok())
234                .unwrap_or(16384),
235            context_size: env::var("CONTEXT_SIZE").ok()
236                .and_then(|v| v.parse().ok()),
237            multi_model: env::var("MULTI_MODEL").ok()
238                .map(|v| v == "true"),
239            plan_model: env::var("ANTHROPIC_REASONING_MODEL").ok(),
240            compress_model: env::var("ANTHROPIC_DEFAULT_HAIKU_MODEL").ok(),
241            fast_model: None,
242            approve_mode: env::var("APPROVE_MODE").ok()
243                .or(Some("ask".to_string())),
244            extra_headers,
245        }
246    }
247
248    /// Load configuration with fallback chain.
249    /// Priority: env vars > ~/.matrix/config.json > ~/.claude/settings.json > defaults
250    pub fn load() -> Self {
251        // Load all sources
252        let env_config = Self::load_from_env();
253        let matrix_config = Self::load_matrix_config();
254        let claude_config = Self::load_claude_settings();
255
256        // Auto-create example config if neither config file exists
257        if matrix_config.is_none() && claude_config.is_none() && env_config.api_key.is_none() {
258            let _ = create_example_config();
259            println!("[config: No config found. Example created at ~/.matrix/config.example.json]");
260            println!("\nTo configure, create ~/.matrix/config.json with:");
261            println!("  {{");
262            println!("    \"provider\": \"anthropic\",");
263            println!("    \"api_key\": \"your-api-key\",");
264            println!("    \"model\": \"claude-sonnet-4-20250514\"");
265            println!("  }}\n");
266        }
267
268        // Determine which sources are active
269        let has_env = env_config.api_key.is_some() || env_config.model.is_some();
270        let has_matrix = matrix_config.is_some();
271        let has_claude = claude_config.is_some();
272
273        // Build source description
274        let sources: Vec<&str> = [
275            has_env.then_some("env"),
276            has_matrix.then_some("~/.matrix/config.json"),
277            has_claude.then_some("~/.claude/settings.json"),
278        ].iter().flatten().copied().collect();
279        println!("[config: {}]", sources.join(" + "));
280
281        // Merge with correct priority: env > matrix > claude > defaults
282        // Start with defaults, then layer on configs in reverse priority order
283        let mut merged = Self::default();
284
285        // Claude config (lowest priority, fills in missing fields)
286        if let Some(cc) = claude_config {
287            merged.provider = merged.provider.or(cc.provider);
288            merged.api_key = merged.api_key.or(cc.api_key);
289            merged.base_url = merged.base_url.or(cc.base_url);
290            merged.model = merged.model.or(cc.model);
291            merged.think = cc.think; // Default from claude
292            merged.markdown = cc.markdown;
293            merged.max_tokens = cc.max_tokens;
294            merged.context_size = merged.context_size.or(cc.context_size);
295            merged.multi_model = merged.multi_model.or(cc.multi_model);
296            merged.plan_model = merged.plan_model.or(cc.plan_model);
297            merged.compress_model = merged.compress_model.or(cc.compress_model);
298            merged.fast_model = merged.fast_model.or(cc.fast_model);
299            merged.approve_mode = merged.approve_mode.or(cc.approve_mode);
300            merged.extra_headers = merged.extra_headers.or(cc.extra_headers);
301        }
302
303        // Matrix config (medium priority, overrides claude)
304        if let Some(mx) = matrix_config {
305            merged.provider = merged.provider.or(mx.provider);
306            merged.api_key = merged.api_key.or(mx.api_key);
307            merged.base_url = merged.base_url.or(mx.base_url);
308            merged.model = merged.model.or(mx.model);
309            merged.think = mx.think;
310            merged.markdown = mx.markdown;
311            merged.max_tokens = mx.max_tokens;
312            merged.context_size = merged.context_size.or(mx.context_size);
313            merged.multi_model = merged.multi_model.or(mx.multi_model);
314            merged.plan_model = merged.plan_model.or(mx.plan_model);
315            merged.compress_model = merged.compress_model.or(mx.compress_model);
316            merged.fast_model = merged.fast_model.or(mx.fast_model);
317            merged.approve_mode = merged.approve_mode.or(mx.approve_mode);
318            merged.extra_headers = merged.extra_headers.or(mx.extra_headers);
319        }
320
321        // Env config (highest priority, overrides everything)
322        merged.provider = env_config.provider.or(merged.provider);
323        merged.api_key = env_config.api_key.or(merged.api_key);
324        merged.base_url = env_config.base_url.or(merged.base_url);
325        merged.model = env_config.model.or(merged.model);
326        merged.think = env_config.think;
327        merged.markdown = env_config.markdown;
328        merged.max_tokens = env_config.max_tokens;
329        merged.context_size = env_config.context_size.or(merged.context_size);
330        merged.multi_model = env_config.multi_model.or(merged.multi_model);
331        merged.plan_model = env_config.plan_model.or(merged.plan_model);
332        merged.compress_model = env_config.compress_model.or(merged.compress_model);
333        merged.fast_model = env_config.fast_model.or(merged.fast_model);
334        merged.approve_mode = env_config.approve_mode.or(merged.approve_mode);
335        merged.extra_headers = env_config.extra_headers.or(merged.extra_headers);
336
337        // Ensure approve_mode has a default
338        merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
339
340        merged
341    }
342
343    /// Get API key, with fallback to environment variable.
344    /// Universal env var: API_KEY (also supports ANTHROPIC_AUTH_TOKEN for compatibility)
345    pub fn get_api_key(&self, provider: &str) -> Option<String> {
346        // Try universal env var first
347        let env_key = env::var("API_KEY").ok()
348            // Then provider-specific env vars
349            .or_else(|| match provider {
350                "openai" => env::var("OPENAI_API_KEY").ok(),
351                _ => env::var("ANTHROPIC_AUTH_TOKEN").ok()
352                    .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
353            });
354        // Finally config file
355        env_key.or(self.api_key.clone())
356    }
357
358    /// Get model name, with fallback to environment variable.
359    /// Universal env var: MODEL (also supports ANTHROPIC_MODEL for compatibility)
360    pub fn get_model(&self, provider: &str) -> String {
361        env::var("MODEL").ok()
362            .or_else(|| env::var("ANTHROPIC_MODEL").ok())
363            .or_else(|| env::var("MODEL_NAME").ok())
364            .or(self.model.clone())
365            .unwrap_or_else(|| match provider {
366                "openai" => "gpt-4o".to_string(),
367                _ => "claude-sonnet-4-20250514".to_string(),
368            })
369    }
370
371    /// Get base URL, with fallback to environment variable.
372    /// Universal env var: BASE_URL (also supports ANTHROPIC_BASE_URL for compatibility)
373    pub fn get_base_url(&self, provider: &str) -> String {
374        env::var("BASE_URL").ok()
375            .or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
376            .or(self.base_url.clone())
377            .unwrap_or_else(|| match provider {
378                "openai" => "https://api.openai.com/v1".to_string(),
379                _ => "https://api.anthropic.com".to_string(),
380            })
381    }
382
383    /// Save configuration to ~/.matrix/config.json.
384    pub fn save(&self) -> anyhow::Result<()> {
385        let path = Self::matrix_config_path()
386            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
387
388        // Create directory if needed
389        let dir = path
390            .parent()
391            .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
392        if !dir.exists() {
393            std::fs::create_dir_all(dir)?;
394        }
395
396        let content = serde_json::to_string_pretty(self)?;
397        std::fs::write(&path, content)?;
398
399        println!("[config saved to ~/.matrix/config.json]");
400        Ok(())
401    }
402
403    /// Check if API is configured.
404    pub fn is_api_configured(&self) -> bool {
405        self.api_key.is_some()
406            || env::var("API_KEY").ok().is_some()
407            || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
408    }
409}
410
411/// Create a default config file for new users.
412pub fn create_default_config() -> anyhow::Result<()> {
413    let config = MatrixConfig {
414        provider: Some("anthropic".to_string()),
415        api_key: None,
416        base_url: None,
417        model: None,
418        think: true,
419        markdown: true,
420        max_tokens: 16384,
421        context_size: None,
422        multi_model: Some(false),
423        plan_model: None,
424        compress_model: None,
425        fast_model: None,
426        approve_mode: Some("ask".to_string()),
427        extra_headers: None,
428    };
429
430    config.save()?;
431
432    // Also create example config with documentation
433    create_example_config()?;
434
435    println!("\nConfig file created at ~/.matrix/config.json");
436    println!("Example config with documentation: ~/.matrix/config.example.json");
437    println!("\nRequired fields to fill:");
438    println!("  api_key  - Your API key");
439    println!("  model    - Model name (e.g. claude-sonnet-4-20250514, gpt-4o, glm-5)");
440    println!("\nOptional fields:");
441    println!("  provider   - 'anthropic' or 'openai' (auto-detected from model if not set)");
442    println!("  base_url   - API endpoint (uses default if not set)");
443    println!("  extra_headers - Custom HTTP headers for API requests");
444    Ok(())
445}
446
447/// Create example config file with field documentation.
448pub fn create_example_config() -> anyhow::Result<()> {
449    let home = MatrixConfig::home_dir()
450        .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
451    let path = home.join(".matrix").join("config.example.json");
452
453    let example = r#"{
454  "_comment": "MatrixCode Configuration Example - Copy this to config.json and fill in your values",
455
456  "provider": "anthropic",
457  "_provider_comment": "API provider: 'anthropic' or 'openai'. Auto-detected from model name if not set.",
458
459  "api_key": "your-api-key-here",
460  "_api_key_comment": "Your API key. Also supports env vars: API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY",
461
462  "model": "claude-sonnet-4-20250514",
463  "_model_comment": "Model name. Examples: claude-sonnet-4, claude-opus-4, gpt-4o, glm-5",
464
465  "base_url": null,
466  "_base_url_comment": "API endpoint. Defaults: anthropic=https://api.anthropic.com, openai=https://api.openai.com/v1",
467  "_base_url_examples": ["https://dashscope.aliyuncs.com/compatible-mode/v1 for DashScope"],
468
469  "think": true,
470  "_think_comment": "Enable extended thinking (Anthropic only). Set false for non-Anthropic endpoints.",
471
472  "markdown": true,
473  "_markdown_comment": "Enable markdown rendering in TUI",
474
475  "max_tokens": 16384,
476  "_max_tokens_comment": "Maximum output tokens per request",
477
478  "approve_mode": "ask",
479  "_approve_mode_comment": "Tool approval: 'ask'=prompt each, 'auto'=approve safe, 'strict'=reject dangerous",
480
481  "multi_model": false,
482  "_multi_model_comment": "Enable multi-model configuration",
483
484  "plan_model": null,
485  "_plan_model_comment": "Planning/reasoning model for complex tasks",
486
487  "compress_model": null,
488  "_compress_model_comment": "Fast model for context compression",
489
490  "fast_model": null,
491  "_fast_model_comment": "Fast model for quick operations",
492
493  "extra_headers": {},
494  "_extra_headers_comment": "Custom HTTP headers for API requests (useful for proxy services)",
495  "_extra_headers_example": {"X-DashScope-SSE": "enable"}
496}"#;
497
498    std::fs::write(&path, example)?;
499    Ok(())
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn test_default_config_values() {
508        let config = MatrixConfig {
509            provider: None,
510            api_key: None,
511            base_url: None,
512            model: None,
513            think: true,
514            markdown: true,
515            max_tokens: 16384,
516            context_size: None,
517            multi_model: None,
518            plan_model: None,
519            compress_model: None,
520            fast_model: None,
521            approve_mode: None,
522        };
523        assert!(config.api_key.is_none());
524        assert!(config.model.is_none());
525        assert!(config.think);
526        assert!(config.markdown);
527        assert_eq!(config.max_tokens, 16384);
528    }
529
530    #[test]
531    fn test_universal_field_names() {
532        // Universal naming
533        let json = r#"{
534            "api_key": "test-key",
535            "base_url": "https://test.com",
536            "model": "test-model",
537            "plan_model": "reasoning-model",
538            "compress_model": "haiku-model"
539        }"#;
540
541        let config: MatrixConfig = serde_json::from_str(json).unwrap();
542        assert_eq!(config.api_key, Some("test-key".to_string()));
543        assert_eq!(config.base_url, Some("https://test.com".to_string()));
544        assert_eq!(config.model, Some("test-model".to_string()));
545        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
546        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
547    }
548
549    #[test]
550    fn test_legacy_alias_names() {
551        // Legacy ANTHROPIC_ prefixed names (still supported via alias)
552        let json = r#"{
553            "ANTHROPIC_AUTH_TOKEN": "test-key",
554            "ANTHROPIC_BASE_URL": "https://test.com",
555            "ANTHROPIC_MODEL": "test-model",
556            "ANTHROPIC_REASONING_MODEL": "reasoning-model",
557            "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
558        }"#;
559
560        let config: MatrixConfig = serde_json::from_str(json).unwrap();
561        assert_eq!(config.api_key, Some("test-key".to_string()));
562        assert_eq!(config.base_url, Some("https://test.com".to_string()));
563        assert_eq!(config.model, Some("test-model".to_string()));
564        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
565        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
566    }
567
568    #[test]
569    fn test_serialization_uses_universal_names() {
570        let config = MatrixConfig {
571            api_key: Some("key".to_string()),
572            model: Some("model".to_string()),
573            ..Default::default()
574        };
575
576        let json = serde_json::to_string(&config).unwrap();
577        // Should use universal field names
578        assert!(json.contains("api_key"));
579        assert!(json.contains("model"));
580    }
581}