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