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