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
26use crate::constants::{
27    ANTHROPIC_DEFAULT_BASE_URL, DEFAULT_MAX_TOKENS, MATRIX_DIR, OPENAI_DEFAULT_BASE_URL,
28};
29use crate::mcp::McpServerConfig;
30use crate::models::DEFAULT_MAIN_MODEL;
31
32/// Matrixcode configuration file structure.
33/// Uses universal naming (no ANTHROPIC_ prefix).
34#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct MatrixConfig {
36    /// LLM provider: "anthropic" or "openai"
37    #[serde(default)]
38    pub provider: Option<String>,
39
40    /// API key (universal naming, also supports ANTHROPIC_AUTH_TOKEN alias)
41    #[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
42    pub api_key: Option<String>,
43
44    /// Base URL for API endpoint
45    #[serde(default, alias = "ANTHROPIC_BASE_URL")]
46    pub base_url: Option<String>,
47
48    /// Main model name
49    #[serde(default, alias = "ANTHROPIC_MODEL")]
50    pub model: Option<String>,
51
52    /// Enable extended thinking
53    #[serde(default = "default_true")]
54    pub think: bool,
55
56    /// Enable markdown rendering
57    #[serde(default = "default_true")]
58    pub markdown: bool,
59
60    /// Maximum output tokens
61    #[serde(default = "default_max_tokens")]
62    pub max_tokens: u32,
63
64    /// Context size
65    #[serde(default)]
66    pub context_size: Option<u32>,
67
68    /// Multi-model configuration
69    #[serde(default)]
70    pub multi_model: Option<bool>,
71
72    /// Plan/reasoning model
73    #[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
74    pub plan_model: Option<String>,
75
76    /// Compress/haiku model
77    #[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
78    pub compress_model: Option<String>,
79
80    /// Fast model
81    #[serde(default)]
82    pub fast_model: Option<String>,
83
84    /// Approve mode: "ask", "auto", "strict"
85    #[serde(default = "default_approve_mode")]
86    pub approve_mode: Option<String>,
87
88    /// Extra HTTP headers to add to API requests
89    /// Format: {"Header-Name": "header-value"}
90    #[serde(default)]
91    pub extra_headers: Option<HashMap<String, String>>,
92
93    /// MCP servers configuration
94    /// Format: {"server_name": McpServerConfig}
95    #[serde(default)]
96    pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
97
98    /// LSP servers configuration
99    /// Format: {"server_name": LspServerConfig}
100    #[serde(default)]
101    pub lsp_servers: Option<HashMap<String, crate::lsp::LspServerConfig>>,
102
103    /// Enable LSP tools globally
104    /// If false, LSP servers won't be loaded and LSP tools won't be available
105    #[serde(default = "default_true")]
106    pub enable_lsp: bool,
107
108    /// Code verification strategy for write operations
109    /// Options: "none" (no verification), "post" (verify after write, default),
110    ///          "pre" (verify before write, block if errors),
111    ///          "pre-quick" (quick syntax check before, full check after)
112    /// When "pre" is enabled, invalid code will be blocked and errors returned to AI
113    /// for automatic correction.
114    #[serde(default = "default_verify_strategy")]
115    pub verify_strategy: Option<String>,
116}
117
118fn default_true() -> bool {
119    true
120}
121fn default_max_tokens() -> u32 {
122    DEFAULT_MAX_TOKENS
123}
124fn default_approve_mode() -> Option<String> {
125    Some("auto".to_string())
126}
127fn default_verify_strategy() -> Option<String> {
128    Some("post".to_string())
129}
130
131/// Type alias for compatibility
132pub type Config = MatrixConfig;
133
134/// Claude Code settings.json structure (for fallback).
135#[derive(Debug, Clone, Deserialize)]
136struct ClaudeSettings {
137    #[serde(default)]
138    env: Option<ClaudeEnv>,
139}
140
141/// Environment variables from Claude Code settings.
142#[derive(Debug, Clone, Deserialize)]
143#[allow(non_snake_case)]
144struct ClaudeEnv {
145    #[serde(default)]
146    ANTHROPIC_AUTH_TOKEN: Option<String>,
147    #[serde(default)]
148    ANTHROPIC_BASE_URL: Option<String>,
149    #[serde(default)]
150    ANTHROPIC_MODEL: Option<String>,
151    #[serde(default)]
152    ANTHROPIC_DEFAULT_HAIKU_MODEL: Option<String>,
153    #[serde(default)]
154    ANTHROPIC_REASONING_MODEL: Option<String>,
155}
156
157impl MatrixConfig {
158    /// Get the home directory.
159    fn home_dir() -> Option<PathBuf> {
160        env::var_os("HOME")
161            .or_else(|| env::var_os("USERPROFILE"))
162            .map(PathBuf::from)
163    }
164
165    /// Path to matrixcode config file.
166    pub fn matrix_config_path() -> Option<PathBuf> {
167        Self::home_dir().map(|h| h.join(MATRIX_DIR).join("config.json"))
168    }
169
170    /// Path to Claude Code settings file.
171    pub fn claude_settings_path() -> Option<PathBuf> {
172        Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
173    }
174
175    /// Load matrixcode's own config file.
176    fn load_matrix_config() -> Option<Self> {
177        let path = Self::matrix_config_path()?;
178        if !path.exists() {
179            return None;
180        }
181
182        let content = match std::fs::read_to_string(&path) {
183            Ok(c) => c,
184            Err(e) => {
185                log::warn!("Failed to read ~/.matrix/config.json: {}", e);
186                return None;
187            }
188        };
189        let config: Self = match serde_json::from_str(&content) {
190            Ok(c) => c,
191            Err(e) => {
192                log::warn!("Failed to parse ~/.matrix/config.json: {}", e);
193                return None;
194            }
195        };
196
197        Some(config)
198    }
199
200    /// Load Claude Code settings as fallback.
201    fn load_claude_settings() -> Option<Self> {
202        let path = Self::claude_settings_path()?;
203        if !path.exists() {
204            return None;
205        }
206
207        let content = match std::fs::read_to_string(&path) {
208            Ok(c) => c,
209            Err(e) => {
210                log::warn!("Failed to read ~/.claude/settings.json: {}", e);
211                return None;
212            }
213        };
214        let settings: ClaudeSettings = match serde_json::from_str(&content) {
215            Ok(s) => s,
216            Err(e) => {
217                log::warn!("Failed to parse ~/.claude/settings.json: {}", e);
218                return None;
219            }
220        };
221
222        let env = settings.env?;
223        Some(Self {
224            provider: Some("anthropic".to_string()),
225            api_key: env.ANTHROPIC_AUTH_TOKEN,
226            base_url: env.ANTHROPIC_BASE_URL,
227            model: env.ANTHROPIC_MODEL,
228            think: true,
229            markdown: true,
230            max_tokens: DEFAULT_MAX_TOKENS,
231            context_size: None,
232            multi_model: None,
233            plan_model: env.ANTHROPIC_REASONING_MODEL,
234            compress_model: env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
235            fast_model: None,
236            approve_mode: Some("ask".to_string()),
237            extra_headers: None,
238            mcp_servers: None,
239            lsp_servers: None,
240            enable_lsp: true,
241            verify_strategy: None,
242        })
243    }
244
245    /// Load configuration from environment variables.
246    /// Universal env vars: API_KEY, BASE_URL, MODEL
247    /// Also supports legacy: ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_MODEL
248    fn load_from_env() -> Self {
249        // Parse EXTRA_HEADERS from env if available (JSON format)
250        let extra_headers = env::var("EXTRA_HEADERS")
251            .ok()
252            .and_then(|json_str| serde_json::from_str::<HashMap<String, String>>(&json_str).ok());
253
254        Self {
255            provider: env::var("PROVIDER").ok(),
256            api_key: env::var("API_KEY")
257                .ok()
258                .or_else(|| env::var("ANTHROPIC_AUTH_TOKEN").ok())
259                .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
260            base_url: env::var("BASE_URL")
261                .ok()
262                .or_else(|| env::var("ANTHROPIC_BASE_URL").ok()),
263            model: env::var("MODEL")
264                .ok()
265                .or_else(|| env::var("ANTHROPIC_MODEL").ok())
266                .or_else(|| env::var("MODEL_NAME").ok()),
267            think: env::var("THINK").ok().map(|v| v != "false").unwrap_or(true),
268            markdown: env::var("MARKDOWN")
269                .ok()
270                .map(|v| v != "false")
271                .unwrap_or(true),
272            max_tokens: env::var("MAX_TOKENS")
273                .ok()
274                .and_then(|v| v.parse().ok())
275                .unwrap_or(DEFAULT_MAX_TOKENS),
276            context_size: env::var("CONTEXT_SIZE").ok().and_then(|v| v.parse().ok()),
277            multi_model: env::var("MULTI_MODEL").ok().map(|v| v == "true"),
278            plan_model: env::var("ANTHROPIC_REASONING_MODEL").ok(),
279            compress_model: env::var("ANTHROPIC_DEFAULT_HAIKU_MODEL").ok(),
280            fast_model: None,
281            approve_mode: env::var("APPROVE_MODE").ok(), // 移除硬编码兜底,让配置文件优先
282            extra_headers,
283            mcp_servers: None,
284            lsp_servers: None,
285            enable_lsp: true,
286            verify_strategy: env::var("VERIFY_STRATEGY").ok(),
287        }
288    }
289
290    /// Load configuration with fallback chain.
291    /// Priority: env vars > ~/.matrix/config.json > ~/.claude/settings.json > defaults
292    pub fn load() -> Self {
293        // Load all sources
294        let env_config = Self::load_from_env();
295        let matrix_config = Self::load_matrix_config();
296        let claude_config = Self::load_claude_settings();
297
298        // Auto-create example config if neither config file exists
299        if matrix_config.is_none() && claude_config.is_none() && env_config.api_key.is_none() {
300            let _ = create_example_config();
301            println!("[config: No config found. Example created at ~/.matrix/config.example.json]");
302            println!("\nTo configure, create ~/.matrix/config.json with:");
303            println!("  {{");
304            println!("    \"provider\": \"anthropic\",");
305            println!("    \"api_key\": \"your-api-key\",");
306            println!("    \"model\": \"claude-sonnet-4-20250514\"");
307            println!("  }}\n");
308        }
309
310        // Determine which sources are active
311        let has_env = env_config.api_key.is_some() || env_config.model.is_some();
312        let has_matrix = matrix_config.is_some();
313        let has_claude = claude_config.is_some();
314
315        // Build source description
316        let sources: Vec<&str> = [
317            has_env.then_some("env"),
318            has_matrix.then_some("~/.matrix/config.json"),
319            has_claude.then_some("~/.claude/settings.json"),
320        ]
321        .iter()
322        .flatten()
323        .copied()
324        .collect();
325        println!("[config: {}]", sources.join(" + "));
326
327        // Merge with correct priority: env > matrix > claude > defaults
328        // Start with defaults, then layer on configs in reverse priority order
329        let mut merged = Self::default();
330
331        // Claude config (lowest priority, fills in missing fields)
332        if let Some(cc) = claude_config {
333            merged.provider = merged.provider.or(cc.provider);
334            merged.api_key = merged.api_key.or(cc.api_key);
335            merged.base_url = merged.base_url.or(cc.base_url);
336            merged.model = merged.model.or(cc.model);
337            merged.think = cc.think; // Default from claude
338            merged.markdown = cc.markdown;
339            merged.max_tokens = cc.max_tokens;
340            merged.context_size = merged.context_size.or(cc.context_size);
341            merged.multi_model = merged.multi_model.or(cc.multi_model);
342            merged.plan_model = merged.plan_model.or(cc.plan_model);
343            merged.compress_model = merged.compress_model.or(cc.compress_model);
344            merged.fast_model = merged.fast_model.or(cc.fast_model);
345            merged.approve_mode = merged.approve_mode.or(cc.approve_mode);
346            merged.extra_headers = merged.extra_headers.or(cc.extra_headers);
347        }
348
349        // Matrix config (medium priority, overrides claude)
350        if let Some(mx) = matrix_config {
351            merged.provider = merged.provider.or(mx.provider);
352            merged.api_key = merged.api_key.or(mx.api_key);
353            merged.base_url = merged.base_url.or(mx.base_url);
354            merged.model = merged.model.or(mx.model);
355            merged.think = mx.think;
356            merged.markdown = mx.markdown;
357            merged.max_tokens = mx.max_tokens;
358            merged.context_size = merged.context_size.or(mx.context_size);
359            merged.multi_model = merged.multi_model.or(mx.multi_model);
360            merged.plan_model = merged.plan_model.or(mx.plan_model);
361            merged.compress_model = merged.compress_model.or(mx.compress_model);
362            merged.fast_model = merged.fast_model.or(mx.fast_model);
363            merged.approve_mode = merged.approve_mode.or(mx.approve_mode);
364            merged.extra_headers = merged.extra_headers.or(mx.extra_headers);
365            // MCP servers from matrix config
366            merged.mcp_servers = mx.mcp_servers;
367            // LSP servers from matrix config
368            merged.lsp_servers = mx.lsp_servers;
369            // enable_lsp from matrix config
370            merged.enable_lsp = mx.enable_lsp;
371        }
372
373        // Env config (highest priority, overrides everything)
374        merged.provider = env_config.provider.or(merged.provider);
375        merged.api_key = env_config.api_key.or(merged.api_key);
376        merged.base_url = env_config.base_url.or(merged.base_url);
377        merged.model = env_config.model.or(merged.model);
378        merged.think = env_config.think;
379        merged.markdown = env_config.markdown;
380        merged.max_tokens = env_config.max_tokens;
381        merged.context_size = env_config.context_size.or(merged.context_size);
382        merged.multi_model = env_config.multi_model.or(merged.multi_model);
383        merged.plan_model = env_config.plan_model.or(merged.plan_model);
384        merged.compress_model = env_config.compress_model.or(merged.compress_model);
385        merged.fast_model = env_config.fast_model.or(merged.fast_model);
386        merged.approve_mode = env_config.approve_mode.or(merged.approve_mode);
387        merged.extra_headers = env_config.extra_headers.or(merged.extra_headers);
388
389        // Final fallback: if approve_mode is truly unset everywhere (env, config files), use "ask"
390        if merged.approve_mode.is_none() {
391            merged.approve_mode = Some("ask".to_string());
392        }
393
394        merged
395    }
396
397    /// Get API key, with fallback to environment variable.
398    /// Universal env var: API_KEY (also supports ANTHROPIC_AUTH_TOKEN for compatibility)
399    pub fn get_api_key(&self, provider: &str) -> Option<String> {
400        // Try universal env var first
401        let env_key = env::var("API_KEY")
402            .ok()
403            // Then provider-specific env vars
404            .or_else(|| match provider {
405                "openai" => env::var("OPENAI_API_KEY").ok(),
406                _ => env::var("ANTHROPIC_AUTH_TOKEN")
407                    .ok()
408                    .or_else(|| env::var("ANTHROPIC_API_KEY").ok()),
409            });
410        // Finally config file
411        env_key.or(self.api_key.clone())
412    }
413
414    /// Get model name, with fallback to environment variable.
415    /// Universal env var: MODEL (also supports ANTHROPIC_MODEL for compatibility)
416    pub fn get_model(&self, provider: &str) -> String {
417        env::var("MODEL")
418            .ok()
419            .or_else(|| env::var("ANTHROPIC_MODEL").ok())
420            .or_else(|| env::var("MODEL_NAME").ok())
421            .or(self.model.clone())
422            .unwrap_or_else(|| match provider {
423                "openai" => "gpt-4o".to_string(),
424                _ => DEFAULT_MAIN_MODEL.to_string(),
425            })
426    }
427
428    /// Get base URL, with fallback to environment variable.
429    /// Universal env var: BASE_URL (also supports ANTHROPIC_BASE_URL for compatibility)
430    pub fn get_base_url(&self, provider: &str) -> String {
431        env::var("BASE_URL")
432            .ok()
433            .or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
434            .or(self.base_url.clone())
435            .unwrap_or_else(|| match provider {
436                "openai" => OPENAI_DEFAULT_BASE_URL.to_string(),
437                _ => ANTHROPIC_DEFAULT_BASE_URL.to_string(),
438            })
439    }
440
441    /// Save configuration to ~/.matrix/config.json.
442    pub fn save(&self) -> anyhow::Result<()> {
443        let path = Self::matrix_config_path()
444            .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
445
446        // Create directory if needed
447        let dir = path
448            .parent()
449            .ok_or_else(|| anyhow::anyhow!("Invalid path"))?;
450        if !dir.exists() {
451            std::fs::create_dir_all(dir)?;
452        }
453
454        let content = serde_json::to_string_pretty(self)?;
455        std::fs::write(&path, content)?;
456
457        println!("[config saved to ~/.matrix/config.json]");
458        Ok(())
459    }
460
461    /// Check if API is configured.
462    pub fn is_api_configured(&self) -> bool {
463        self.api_key.is_some()
464            || env::var("API_KEY").ok().is_some()
465            || env::var("ANTHROPIC_AUTH_TOKEN").ok().is_some()
466    }
467
468    /// Get API key with fallback chain (public for subagent use)
469    pub fn resolve_api_key(&self) -> Option<String> {
470        self.api_key
471            .clone()
472            .or_else(|| env::var("ANTHROPIC_AUTH_TOKEN").ok())
473            .or_else(|| env::var("API_KEY").ok())
474    }
475
476    /// Get model with fallback chain
477    fn resolve_model(&self) -> String {
478        self.model
479            .clone()
480            .or_else(|| env::var("MODEL").ok())
481            .or_else(|| env::var("ANTHROPIC_MODEL").ok())
482            .unwrap_or_else(|| DEFAULT_MAIN_MODEL.to_string())
483    }
484
485    /// Get base URL with fallback chain (public for subagent use)
486    pub fn resolve_base_url(&self) -> Option<String> {
487        self.base_url
488            .clone()
489            .or_else(|| env::var("BASE_URL").ok())
490            .or_else(|| env::var("ANTHROPIC_BASE_URL").ok())
491    }
492
493    /// Infer provider type from model name
494    fn infer_provider_type(model: &str) -> crate::providers::ProviderType {
495        if model.starts_with("gpt") || model.starts_with("o1") {
496            crate::providers::ProviderType::OpenAI
497        } else {
498            crate::providers::ProviderType::Anthropic
499        }
500    }
501
502    /// Resolve provider type from config or infer from model (public for subagent use)
503    pub fn resolve_provider_type(&self, model: &str) -> crate::providers::ProviderType {
504        use crate::providers::ProviderType;
505
506        self.provider
507            .clone()
508            .or_else(|| env::var("PROVIDER").ok())
509            .map(|p| match p.to_lowercase().as_str() {
510                "openai" => ProviderType::OpenAI,
511                _ => ProviderType::Anthropic,
512            })
513            .unwrap_or_else(|| Self::infer_provider_type(model))
514    }
515
516    /// Create a Provider instance from configuration.
517    /// Useful for tools that need AI capabilities but don't have an injected provider.
518    pub fn create_provider_from_env()
519    -> anyhow::Result<std::sync::Arc<dyn crate::providers::Provider>> {
520        let config = Self::load();
521
522        let api_key = config
523            .resolve_api_key()
524            .ok_or_else(|| anyhow::anyhow!("未配置 API key,无法执行 AI 任务"))?;
525
526        let model = config.resolve_model();
527        let provider_type = config.resolve_provider_type(&model);
528        let base_url = config.resolve_base_url();
529
530        crate::providers::create_provider_with_headers(
531            provider_type,
532            api_key,
533            model,
534            base_url,
535            config.extra_headers.clone(),
536        )
537        .map(std::sync::Arc::from)
538    }
539}
540
541/// Create a default config file for new users.
542pub fn create_default_config() -> anyhow::Result<()> {
543    let config = MatrixConfig {
544        provider: Some("anthropic".to_string()),
545        api_key: None,
546        base_url: None,
547        model: None,
548        think: true,
549        markdown: true,
550        max_tokens: DEFAULT_MAX_TOKENS,
551        context_size: None,
552        multi_model: Some(false),
553        plan_model: None,
554        compress_model: None,
555        fast_model: None,
556        approve_mode: Some("ask".to_string()),
557        extra_headers: None,
558        mcp_servers: None,
559        lsp_servers: None,
560        enable_lsp: true,
561        verify_strategy: Some("post".to_string()),
562    };
563
564    config.save()?;
565
566    // Also create example config with documentation
567    create_example_config()?;
568
569    println!("\nConfig file created at ~/.matrix/config.json");
570    println!("Example config with documentation: ~/.matrix/config.example.json");
571    println!("\nRequired fields to fill:");
572    println!("  api_key  - Your API key");
573    println!("  model    - Model name (e.g. claude-sonnet-4-20250514, gpt-4o, glm-5)");
574    println!("\nOptional fields:");
575    println!("  provider   - 'anthropic' or 'openai' (auto-detected from model if not set)");
576    println!("  base_url   - API endpoint (uses default if not set)");
577    println!("  extra_headers - Custom HTTP headers for API requests");
578    Ok(())
579}
580
581/// Create example config file with field documentation.
582pub fn create_example_config() -> anyhow::Result<()> {
583    let home = MatrixConfig::home_dir()
584        .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
585    let path = home.join(MATRIX_DIR).join("config.example.json");
586
587    let example = r#"{
588  "_comment": "MatrixCode Configuration Example - Copy this to config.json and fill in your values",
589
590  "provider": "anthropic",
591  "_provider_comment": "API provider: 'anthropic' or 'openai'. Auto-detected from model name if not set.",
592
593  "api_key": "your-api-key-here",
594  "_api_key_comment": "Your API key. Also supports env vars: API_KEY, ANTHROPIC_AUTH_TOKEN, OPENAI_API_KEY",
595
596  "model": "claude-sonnet-4-20250514",
597  "_model_comment": "Model name. Examples: claude-sonnet-4, claude-opus-4, gpt-4o, glm-5",
598
599  "base_url": null,
600  "_base_url_comment": "API endpoint. Defaults: anthropic=https://api.anthropic.com, openai=https://api.openai.com/v1",
601  "_base_url_examples": ["https://dashscope.aliyuncs.com/compatible-mode/v1 for DashScope"],
602
603  "think": true,
604  "_think_comment": "Enable extended thinking (Anthropic only). Set false for non-Anthropic endpoints.",
605
606  "markdown": true,
607  "_markdown_comment": "Enable markdown rendering in TUI",
608
609  "max_tokens": 16384,
610  "_max_tokens_comment": "Maximum output tokens per request",
611
612  "approve_mode": "ask",
613  "_approve_mode_comment": "Tool approval: 'ask'=prompt each, 'auto'=approve safe, 'strict'=reject dangerous",
614
615  "verify_strategy": "post",
616  "_verify_strategy_comment": "Code verification strategy for write operations",
617  "_verify_strategy_options": {
618    "none": "No verification",
619    "post": "Verify after write (default, current behavior)",
620    "pre": "Verify before write, block if errors - returns errors to AI for correction",
621    "pre-quick": "Quick syntax check before write, full check after"
622  },
623
624  "multi_model": false,
625  "_multi_model_comment": "Enable multi-model configuration",
626
627  "plan_model": null,
628  "_plan_model_comment": "Planning/reasoning model for complex tasks",
629
630  "compress_model": null,
631  "_compress_model_comment": "Fast model for context compression",
632
633  "fast_model": null,
634  "_fast_model_comment": "Fast model for quick operations",
635
636  "extra_headers": {},
637  "_extra_headers_comment": "Custom HTTP headers for API requests (useful for proxy services)",
638  "_extra_headers_example": {"X-DashScope-SSE": "enable"},
639
640  "enable_lsp": true,
641  "_enable_lsp_comment": "Enable LSP tools. Set false to disable LSP loading (faster startup, less memory)"
642}"#;
643
644    std::fs::write(&path, example)?;
645    Ok(())
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn test_default_config_values() {
654        let config = MatrixConfig {
655            provider: None,
656            api_key: None,
657            base_url: None,
658            model: None,
659            think: true,
660            markdown: true,
661            max_tokens: DEFAULT_MAX_TOKENS,
662            context_size: None,
663            multi_model: None,
664            plan_model: None,
665            compress_model: None,
666            fast_model: None,
667            approve_mode: None,
668            extra_headers: None,
669            mcp_servers: None,
670            lsp_servers: None,
671            enable_lsp: true,
672            verify_strategy: None,
673        };
674        assert!(config.api_key.is_none());
675        assert!(config.model.is_none());
676        assert!(config.think);
677        assert!(config.markdown);
678        assert_eq!(config.max_tokens, 16384);
679        assert!(config.enable_lsp);
680    }
681
682    #[test]
683    fn test_universal_field_names() {
684        // Universal naming
685        let json = r#"{
686            "api_key": "test-key",
687            "base_url": "https://test.com",
688            "model": "test-model",
689            "plan_model": "reasoning-model",
690            "compress_model": "haiku-model"
691        }"#;
692
693        let config: MatrixConfig = serde_json::from_str(json).unwrap();
694        assert_eq!(config.api_key, Some("test-key".to_string()));
695        assert_eq!(config.base_url, Some("https://test.com".to_string()));
696        assert_eq!(config.model, Some("test-model".to_string()));
697        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
698        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
699    }
700
701    #[test]
702    fn test_legacy_alias_names() {
703        // Legacy ANTHROPIC_ prefixed names (still supported via alias)
704        let json = r#"{
705            "ANTHROPIC_AUTH_TOKEN": "test-key",
706            "ANTHROPIC_BASE_URL": "https://test.com",
707            "ANTHROPIC_MODEL": "test-model",
708            "ANTHROPIC_REASONING_MODEL": "reasoning-model",
709            "ANTHROPIC_DEFAULT_HAIKU_MODEL": "haiku-model"
710        }"#;
711
712        let config: MatrixConfig = serde_json::from_str(json).unwrap();
713        assert_eq!(config.api_key, Some("test-key".to_string()));
714        assert_eq!(config.base_url, Some("https://test.com".to_string()));
715        assert_eq!(config.model, Some("test-model".to_string()));
716        assert_eq!(config.plan_model, Some("reasoning-model".to_string()));
717        assert_eq!(config.compress_model, Some("haiku-model".to_string()));
718    }
719
720    #[test]
721    fn test_serialization_uses_universal_names() {
722        let config = MatrixConfig {
723            api_key: Some("key".to_string()),
724            model: Some("model".to_string()),
725            extra_headers: None,
726            ..Default::default()
727        };
728
729        let json = serde_json::to_string(&config).unwrap();
730        // Should use universal field names
731        assert!(json.contains("api_key"));
732        assert!(json.contains("model"));
733    }
734}