1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
35pub struct MatrixConfig {
36 #[serde(default)]
38 pub provider: Option<String>,
39
40 #[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
42 pub api_key: Option<String>,
43
44 #[serde(default, alias = "ANTHROPIC_BASE_URL")]
46 pub base_url: Option<String>,
47
48 #[serde(default, alias = "ANTHROPIC_MODEL")]
50 pub model: Option<String>,
51
52 #[serde(default = "default_true")]
54 pub think: bool,
55
56 #[serde(default = "default_true")]
58 pub markdown: bool,
59
60 #[serde(default = "default_max_tokens")]
62 pub max_tokens: u32,
63
64 #[serde(default)]
66 pub context_size: Option<u32>,
67
68 #[serde(default)]
70 pub multi_model: Option<bool>,
71
72 #[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
74 pub plan_model: Option<String>,
75
76 #[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
78 pub compress_model: Option<String>,
79
80 #[serde(default)]
82 pub fast_model: Option<String>,
83
84 #[serde(default = "default_approve_mode")]
86 pub approve_mode: Option<String>,
87
88 #[serde(default)]
91 pub extra_headers: Option<HashMap<String, String>>,
92
93 #[serde(default)]
96 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
97
98 #[serde(default)]
101 pub lsp_servers: Option<HashMap<String, crate::lsp::LspServerConfig>>,
102
103 #[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
119pub type Config = MatrixConfig;
121
122#[derive(Debug, Clone, Deserialize)]
124struct ClaudeSettings {
125 #[serde(default)]
126 env: Option<ClaudeEnv>,
127}
128
129#[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 fn home_dir() -> Option<PathBuf> {
148 env::var_os("HOME")
149 .or_else(|| env::var_os("USERPROFILE"))
150 .map(PathBuf::from)
151 }
152
153 pub fn matrix_config_path() -> Option<PathBuf> {
155 Self::home_dir().map(|h| h.join(MATRIX_DIR).join("config.json"))
156 }
157
158 pub fn claude_settings_path() -> Option<PathBuf> {
160 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
161 }
162
163 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 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 fn load_from_env() -> Self {
236 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 pub fn load() -> Self {
279 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 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 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 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 let mut merged = Self::default();
316
317 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; 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 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 merged.mcp_servers = mx.mcp_servers;
353 merged.lsp_servers = mx.lsp_servers;
355 merged.enable_lsp = mx.enable_lsp;
357 }
358
359 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 merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
377
378 merged
379 }
380
381 pub fn get_api_key(&self, provider: &str) -> Option<String> {
384 let env_key = env::var("API_KEY")
386 .ok()
387 .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 env_key.or(self.api_key.clone())
396 }
397
398 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 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 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 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 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 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 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 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 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 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 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
525pub 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 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
564pub 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 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 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 assert!(json.contains("api_key"));
705 assert!(json.contains("model"));
706 }
707}