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