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
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
114pub type Config = MatrixConfig;
116
117#[derive(Debug, Clone, Deserialize)]
119struct ClaudeSettings {
120 #[serde(default)]
121 env: Option<ClaudeEnv>,
122}
123
124#[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 fn home_dir() -> Option<PathBuf> {
143 env::var_os("HOME")
144 .or_else(|| env::var_os("USERPROFILE"))
145 .map(PathBuf::from)
146 }
147
148 pub fn matrix_config_path() -> Option<PathBuf> {
150 Self::home_dir().map(|h| h.join(MATRIX_DIR).join("config.json"))
151 }
152
153 pub fn claude_settings_path() -> Option<PathBuf> {
155 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
156 }
157
158 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 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 fn load_from_env() -> Self {
230 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 pub fn load() -> Self {
272 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 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 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 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 let mut merged = Self::default();
309
310 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; 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 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 merged.mcp_servers = mx.mcp_servers;
346 }
347
348 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 merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
366
367 merged
368 }
369
370 pub fn get_api_key(&self, provider: &str) -> Option<String> {
373 let env_key = env::var("API_KEY")
375 .ok()
376 .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 env_key.or(self.api_key.clone())
385 }
386
387 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 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 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 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 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 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 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 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 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 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 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
514pub 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 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
552pub 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 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 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 assert!(json.contains("api_key"));
688 assert!(json.contains("model"));
689 }
690}