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