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