1use 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
33pub struct MatrixConfig {
34 #[serde(default)]
36 pub provider: Option<String>,
37
38 #[serde(default, alias = "ANTHROPIC_AUTH_TOKEN")]
40 pub api_key: Option<String>,
41
42 #[serde(default, alias = "ANTHROPIC_BASE_URL")]
44 pub base_url: Option<String>,
45
46 #[serde(default, alias = "ANTHROPIC_MODEL")]
48 pub model: Option<String>,
49
50 #[serde(default = "default_true")]
52 pub think: bool,
53
54 #[serde(default = "default_true")]
56 pub markdown: bool,
57
58 #[serde(default = "default_max_tokens")]
60 pub max_tokens: u32,
61
62 #[serde(default)]
64 pub context_size: Option<u32>,
65
66 #[serde(default)]
68 pub multi_model: Option<bool>,
69
70 #[serde(default, alias = "ANTHROPIC_REASONING_MODEL")]
72 pub plan_model: Option<String>,
73
74 #[serde(default, alias = "ANTHROPIC_DEFAULT_HAIKU_MODEL")]
76 pub compress_model: Option<String>,
77
78 #[serde(default)]
80 pub fast_model: Option<String>,
81
82 #[serde(default = "default_approve_mode")]
84 pub approve_mode: Option<String>,
85
86 #[serde(default)]
89 pub extra_headers: Option<HashMap<String, String>>,
90
91 #[serde(default)]
94 pub mcp_servers: Option<HashMap<String, McpServerConfig>>,
95
96 #[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
112pub type Config = MatrixConfig;
114
115#[derive(Debug, Clone, Deserialize)]
117struct ClaudeSettings {
118 #[serde(default)]
119 env: Option<ClaudeEnv>,
120}
121
122#[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 fn home_dir() -> Option<PathBuf> {
141 env::var_os("HOME")
142 .or_else(|| env::var_os("USERPROFILE"))
143 .map(PathBuf::from)
144 }
145
146 pub fn matrix_config_path() -> Option<PathBuf> {
148 Self::home_dir().map(|h| h.join(MATRIX_DIR).join("config.json"))
149 }
150
151 pub fn claude_settings_path() -> Option<PathBuf> {
153 Self::home_dir().map(|h| h.join(".claude").join("settings.json"))
154 }
155
156 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 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 fn load_from_env() -> Self {
228 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 pub fn load() -> Self {
269 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 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 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 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 let mut merged = Self::default();
302
303 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; 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 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 merged.mcp_servers = mx.mcp_servers;
339 }
340
341 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 merged.approve_mode = merged.approve_mode.or(Some("ask".to_string()));
359
360 merged
361 }
362
363 pub fn get_api_key(&self, provider: &str) -> Option<String> {
366 let env_key = env::var("API_KEY").ok()
368 .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 env_key.or(self.api_key.clone())
376 }
377
378 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 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 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 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 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 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 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 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 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 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 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
496pub 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 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
534pub 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 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 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 assert!(json.contains("api_key"));
670 assert!(json.contains("model"));
671 }
672}