1use serde::Deserialize;
24use std::collections::HashMap;
25use std::path::PathBuf;
26
27use crate::error::LlmError;
28
29#[derive(Debug, Deserialize)]
35pub struct RobitConfig {
36 pub default_model: Option<String>,
38 pub providers: HashMap<String, ProviderConfig>,
40 pub app: Option<AppConfig>,
42 #[serde(default)]
44 pub channels: Option<ChannelsConfig>,
45}
46
47#[derive(Debug, Deserialize)]
49pub struct ProviderConfig {
50 pub name: Option<String>,
52 pub base_url: String,
54 pub api_key: String,
56 pub models: Vec<ModelConfig>,
58}
59
60#[derive(Debug, Deserialize)]
62pub struct ModelConfig {
63 pub id: String,
65 pub name: Option<String>,
67 pub context_window: Option<u64>,
69 pub max_output_tokens: Option<u64>,
71 pub temperature: Option<f32>,
73 pub max_tokens: Option<u32>,
75 pub supports_images: Option<bool>,
77 pub supports_tools: Option<bool>,
79}
80
81#[derive(Debug, Deserialize, Default)]
86pub struct AppConfig {
87 pub log_level: Option<String>,
88 pub log_file: Option<bool>,
90 pub max_steps: Option<usize>,
91 pub enabled_tools: Option<Vec<String>>,
92 pub enabled_skills: Option<Vec<String>>,
93 pub context: Option<ContextConfig>,
94 pub retry: Option<RetryConfig>,
95 pub auto_approve: Option<bool>,
96 pub global_storage: Option<bool>,
97 pub bot: Option<BotConfig>,
99}
100
101#[derive(Debug, Deserialize)]
102pub struct ContextConfig {
103 pub max_output_lines: Option<usize>,
104 pub max_output_bytes: Option<usize>,
105 pub reserve_ratio: Option<f32>,
106 pub compression_token_threshold: Option<usize>,
109 pub compression_enabled: Option<bool>,
111}
112
113#[derive(Debug, Deserialize)]
114pub struct RetryConfig {
115 pub max_retries: Option<u32>,
116 pub initial_backoff_ms: Option<u64>,
117 pub max_backoff_ms: Option<u64>,
118}
119
120#[derive(Debug, Deserialize, Default)]
126pub struct ChannelsConfig {
127 pub qq_bot: Option<QqBotConfig>,
129}
130
131#[derive(Debug, Deserialize, Clone)]
133pub struct QqBotConfig {
134 pub app_id: String,
135 pub app_secret: String,
136}
137
138#[derive(Debug, Deserialize, Default)]
144pub struct BotConfig {
145 pub confirm_timeout_secs: Option<u64>,
147 pub session_timeout_minutes: Option<u64>,
149 pub confirm_keywords: Option<ConfirmKeywordsConfig>,
151}
152
153#[derive(Debug, Deserialize, Clone, Default)]
155pub struct ConfirmKeywordsConfig {
156 pub approve: Option<Vec<String>>,
157 pub reject: Option<Vec<String>>,
158}
159
160#[derive(Debug, Clone)]
169pub struct ResolvedModel {
170 pub profile_name: String,
171 pub model_id: String,
172 pub base_url: String,
173 pub api_key: String,
174 pub max_tokens: Option<u32>,
175 pub temperature: Option<f32>,
176 pub context_window: Option<u64>,
177 pub supports_images: bool,
179 pub supports_tools: bool,
181}
182
183fn robit_home() -> Result<PathBuf, LlmError> {
189 let home = dirs::home_dir()
190 .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
191 Ok(home.join(".robit"))
192}
193
194fn resolve_env_var(value: &str) -> String {
196 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
197 std::env::var(var_name).unwrap_or_else(|_| value.to_string())
198 } else {
199 value.to_string()
200 }
201}
202
203pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
214 load_env_from(workdir);
216
217 let path = find_config_path(workdir)?;
218
219 let content = std::fs::read_to_string(&path)
220 .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
221
222 let mut config: RobitConfig = toml::from_str(&content)
223 .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
224
225 for provider in config.providers.values_mut() {
227 provider.api_key = resolve_env_var(&provider.api_key);
228 }
229
230 if let Some(ref mut channels) = config.channels {
232 if let Some(ref mut qq_bot) = channels.qq_bot {
233 qq_bot.app_id = resolve_env_var(&qq_bot.app_id);
234 qq_bot.app_secret = resolve_env_var(&qq_bot.app_secret);
235 }
236 }
237
238 Ok(config)
239}
240
241pub fn load_env_from(workdir: Option<&std::path::Path>) {
244 let mut env_paths = Vec::new();
246
247 if let Some(workdir) = workdir {
249 let local_env = workdir.join(".robit").join(".env");
250 if local_env.exists() {
251 env_paths.push(local_env);
252 }
253 } else if let Ok(cwd) = std::env::current_dir() {
254 let local_env = cwd.join(".robit").join(".env");
255 if local_env.exists() {
256 env_paths.push(local_env);
257 }
258 }
259
260 if let Ok(robit_dir) = robit_home() {
262 let env_path = robit_dir.join(".env");
263 if env_path.exists() {
264 env_paths.push(env_path);
265 }
266 }
267
268 for path in env_paths.iter().rev() {
271 if let Ok(iter) = dotenvy::from_path_iter(path) {
272 for item in iter {
273 if let Ok((key, value)) = item {
274 std::env::set_var(key, value);
275 }
276 }
277 }
278 }
279}
280
281pub fn load_env() {
283 if let Ok(robit_dir) = robit_home() {
284 let env_path = robit_dir.join(".env");
285 if env_path.exists() {
286 let _ = dotenvy::from_path(&env_path);
287 }
288 }
289}
290
291fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
293 if let Some(workdir) = workdir {
295 let local_path = workdir.join(".robit").join("config.toml");
296 if local_path.exists() {
297 return Ok(local_path);
298 }
299 }
300
301 if let Ok(cwd) = std::env::current_dir() {
303 let local_path = cwd.join(".robit").join("config.toml");
304 if local_path.exists() {
305 return Ok(local_path);
306 }
307 }
308
309 let global_path = robit_home()?.join("config.toml");
311 if global_path.exists() {
312 return Ok(global_path);
313 }
314
315 Err(LlmError::ConfigError(format!(
316 "Configuration file config.toml not found.\n\
317 Please create one of the following:\n\
318 - Project-local: .robit/config.toml\n\
319 - Global: {}",
320 global_path.display()
321 )))
322}
323
324pub fn resolve_profile(
332 config: &RobitConfig,
333 provider_name: Option<&str>,
334) -> Result<ResolvedModel, LlmError> {
335 let (provider_key, model_id) = if let Some(name) = provider_name {
336 let provider = config.providers.get(name).ok_or_else(|| {
338 LlmError::ConfigError(format!(
339 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
340 name,
341 config.providers.keys().collect::<Vec<_>>()
342 ))
343 })?;
344 let first_model = provider.models.first().ok_or_else(|| {
345 LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
346 })?;
347 (name.to_string(), first_model.id.clone())
348 } else if let Some(ref default_model) = config.default_model {
349 parse_default_model(default_model)?
350 } else {
351 let (key, provider) = config.providers.iter().next().ok_or_else(|| {
353 LlmError::ConfigError("No providers defined in config.toml".to_string())
354 })?;
355 let first_model = provider.models.first().ok_or_else(|| {
356 LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
357 })?;
358 (key.clone(), first_model.id.clone())
359 };
360
361 let provider = config.providers.get(&provider_key).ok_or_else(|| {
362 LlmError::ConfigError(format!(
363 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
364 provider_key,
365 config.providers.keys().collect::<Vec<_>>()
366 ))
367 })?;
368
369 let model = provider
371 .models
372 .iter()
373 .find(|m| m.id == model_id)
374 .ok_or_else(|| {
375 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
376 LlmError::ConfigError(format!(
377 "Model '{}' not found in provider '{}'. Available models: {:?}",
378 model_id, provider_key, available
379 ))
380 })?;
381
382 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
384 return Err(LlmError::ConfigError(format!(
385 "Provider '{}' API key is not configured or the environment variable is not set",
386 provider_key
387 )));
388 }
389
390 Ok(ResolvedModel {
391 profile_name: provider_key,
392 model_id: model.id.clone(),
393 base_url: provider.base_url.clone(),
394 api_key: provider.api_key.clone(),
395 max_tokens: model.max_tokens,
396 temperature: model.temperature,
397 context_window: model.context_window,
398 supports_images: model.supports_images.unwrap_or(false),
399 supports_tools: model.supports_tools.unwrap_or(false),
400 })
401}
402
403fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
407 let parts: Vec<&str> = default_model.splitn(2, '/').collect();
408 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
409 return Err(LlmError::ConfigError(format!(
410 "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
411 default_model
412 )));
413 }
414 Ok((parts[0].to_string(), parts[1].to_string()))
415}
416
417#[cfg(test)]
422mod tests {
423 use super::*;
424
425 #[test]
426 fn test_resolve_env_var_with_env_set() {
427 std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
428 assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
429 std::env::remove_var("ROBIT_TEST_KEY");
430 }
431
432 #[test]
433 fn test_resolve_env_var_without_env() {
434 assert_eq!(
435 resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
436 "${ROBIT_NONEXISTENT_KEY}"
437 );
438 }
439
440 #[test]
441 fn test_resolve_env_var_plain_string() {
442 assert_eq!(resolve_env_var("plain-key"), "plain-key");
443 }
444
445 #[test]
446 fn test_parse_robit_config() {
447 let toml_str = r#"
448 default_model = "deepseek/deepseek-chat"
449
450 [providers.deepseek]
451 name = "DeepSeek"
452 base_url = "https://api.deepseek.com"
453 api_key = "sk-test-key"
454
455 [[providers.deepseek.models]]
456 id = "deepseek-chat"
457 name = "DeepSeek Chat"
458 context_window = 65536
459 max_output_tokens = 8192
460 temperature = 0.0
461 max_tokens = 4096
462
463 [[providers.deepseek.models]]
464 id = "deepseek-reasoner"
465 name = "DeepSeek Reasoner"
466 context_window = 65536
467 temperature = 0.6
468
469 [providers.qwen]
470 name = "通义千问"
471 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
472 api_key = "sk-qwen-key"
473
474 [[providers.qwen.models]]
475 id = "qwen-max"
476 name = "Qwen Max"
477 context_window = 32768
478
479 [app]
480 log_level = "DEBUG"
481 max_steps = 10
482 global_storage = true
483
484 [app.context]
485 max_output_lines = 500
486 reserve_ratio = 0.2
487
488 [app.retry]
489 max_retries = 3
490 "#;
491
492 let config: RobitConfig = toml::from_str(toml_str).unwrap();
493
494 assert_eq!(
496 config.default_model.as_deref(),
497 Some("deepseek/deepseek-chat")
498 );
499
500 assert_eq!(config.providers.len(), 2);
502
503 let ds = &config.providers["deepseek"];
505 assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
506 assert_eq!(ds.base_url, "https://api.deepseek.com");
507 assert_eq!(ds.api_key, "sk-test-key");
508 assert_eq!(ds.models.len(), 2);
509 assert_eq!(ds.models[0].id, "deepseek-chat");
510 assert_eq!(ds.models[0].context_window, Some(65536));
511 assert_eq!(ds.models[0].temperature, Some(0.0));
512 assert_eq!(ds.models[0].max_tokens, Some(4096));
513 assert_eq!(ds.models[1].id, "deepseek-reasoner");
514 assert_eq!(ds.models[1].temperature, Some(0.6));
515
516 let qw = &config.providers["qwen"];
518 assert_eq!(qw.name.as_deref(), Some("通义千问"));
519 assert_eq!(qw.models.len(), 1);
520 assert_eq!(qw.models[0].id, "qwen-max");
521
522 let app = config.app.as_ref().unwrap();
524 assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
525 assert_eq!(app.max_steps, Some(10));
526 assert_eq!(app.global_storage, Some(true));
527 assert!(app.context.is_some());
528 assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
529 assert!(app.retry.is_some());
530 assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
531 }
532
533 #[test]
534 fn test_parse_config_minimal() {
535 let toml_str = r#"
536 [providers.default]
537 base_url = "https://api.deepseek.com"
538 api_key = "sk-test"
539
540 [[providers.default.models]]
541 id = "deepseek-chat"
542 "#;
543
544 let config: RobitConfig = toml::from_str(toml_str).unwrap();
545 assert!(config.default_model.is_none());
546 assert!(config.app.is_none());
547 assert_eq!(config.providers.len(), 1);
548 }
549
550 #[test]
551 fn test_resolve_profile_from_default_model() {
552 let config = make_test_config();
553 let resolved = resolve_profile(&config, None).unwrap();
554 assert_eq!(resolved.profile_name, "deepseek");
555 assert_eq!(resolved.model_id, "deepseek-chat");
556 assert_eq!(resolved.base_url, "https://api.deepseek.com");
557 assert_eq!(resolved.api_key, "sk-test");
558 assert_eq!(resolved.context_window, Some(65536));
559 assert_eq!(resolved.temperature, Some(0.0));
560 assert_eq!(resolved.max_tokens, Some(4096));
561 }
562
563 #[test]
564 fn test_resolve_profile_explicit_provider() {
565 let config = make_test_config();
566 let resolved = resolve_profile(&config, Some("qwen")).unwrap();
568 assert_eq!(resolved.profile_name, "qwen");
569 assert_eq!(resolved.model_id, "qwen-max");
570 assert_eq!(
571 resolved.base_url,
572 "https://dashscope.aliyuncs.com/compatible-mode/v1"
573 );
574 }
575
576 #[test]
577 fn test_resolve_profile_first_available() {
578 let toml_str = r#"
580 [providers.deepseek]
581 base_url = "https://api.deepseek.com"
582 api_key = "sk-test"
583
584 [[providers.deepseek.models]]
585 id = "deepseek-chat"
586 "#;
587 let config: RobitConfig = toml::from_str(toml_str).unwrap();
588 let resolved = resolve_profile(&config, None).unwrap();
589 assert_eq!(resolved.profile_name, "deepseek");
590 assert_eq!(resolved.model_id, "deepseek-chat");
591 }
592
593 #[test]
594 fn test_resolve_profile_not_found() {
595 let config = make_test_config();
596 let result = resolve_profile(&config, Some("nonexistent"));
597 assert!(result.is_err());
598 }
599
600 #[test]
601 fn test_resolve_profile_model_not_found() {
602 let toml_str = r#"
603 default_model = "deepseek/nonexistent-model"
604
605 [providers.deepseek]
606 base_url = "https://api.deepseek.com"
607 api_key = "sk-test"
608
609 [[providers.deepseek.models]]
610 id = "deepseek-chat"
611 "#;
612 let config: RobitConfig = toml::from_str(toml_str).unwrap();
613 let result = resolve_profile(&config, None);
614 assert!(result.is_err());
615 }
616
617 #[test]
618 fn test_resolve_profile_invalid_default_model_format() {
619 let toml_str = r#"
620 default_model = "invalid-no-slash"
621
622 [providers.deepseek]
623 base_url = "https://api.deepseek.com"
624 api_key = "sk-test"
625
626 [[providers.deepseek.models]]
627 id = "deepseek-chat"
628 "#;
629 let config: RobitConfig = toml::from_str(toml_str).unwrap();
630 let result = resolve_profile(&config, None);
631 assert!(result.is_err());
632 assert!(result
633 .unwrap_err()
634 .to_string()
635 .contains("Invalid default_model"));
636 }
637
638 #[test]
639 fn test_resolve_profile_empty_api_key() {
640 let toml_str = r#"
641 [providers.deepseek]
642 base_url = "https://api.deepseek.com"
643 api_key = ""
644
645 [[providers.deepseek.models]]
646 id = "deepseek-chat"
647 "#;
648 let config: RobitConfig = toml::from_str(toml_str).unwrap();
649 let result = resolve_profile(&config, None);
650 assert!(result.is_err());
651 }
652
653 #[test]
654 fn test_parse_enabled_skills() {
655 let toml_str = r#"
656 default_model = "deepseek/deepseek-chat"
657
658 [providers.deepseek]
659 base_url = "https://api.deepseek.com"
660 api_key = "sk-test"
661
662 [[providers.deepseek.models]]
663 id = "deepseek-chat"
664
665 [app]
666 enabled_skills = ["code-review", "refactor"]
667 "#;
668
669 let config: RobitConfig = toml::from_str(toml_str).unwrap();
670 let app = config.app.as_ref().unwrap();
671 assert!(app.enabled_skills.is_some());
672 let skills = app.enabled_skills.as_ref().unwrap();
673 assert_eq!(skills.len(), 2);
674 assert_eq!(skills[0], "code-review");
675 assert_eq!(skills[1], "refactor");
676 }
677
678 #[test]
679 fn test_parse_enabled_tools() {
680 let toml_str = r#"
681 default_model = "deepseek/deepseek-chat"
682
683 [providers.deepseek]
684 base_url = "https://api.deepseek.com"
685 api_key = "sk-test"
686
687 [[providers.deepseek.models]]
688 id = "deepseek-chat"
689
690 [app]
691 enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
692 "#;
693
694 let config: RobitConfig = toml::from_str(toml_str).unwrap();
695 let app = config.app.as_ref().unwrap();
696 assert!(app.enabled_tools.is_some());
697 let tools = app.enabled_tools.as_ref().unwrap();
698 assert_eq!(tools.len(), 7);
699 assert_eq!(tools[0], "read");
700 assert_eq!(tools[1], "bash");
701 assert_eq!(tools[2], "edit");
702 assert_eq!(tools[3], "write");
703 assert_eq!(tools[4], "grep");
704 assert_eq!(tools[5], "find");
705 assert_eq!(tools[6], "ls");
706 }
707
708 #[test]
709 fn test_parse_auto_approve() {
710 let toml_str = r#"
711 default_model = "deepseek/deepseek-chat"
712
713 [providers.deepseek]
714 base_url = "https://api.deepseek.com"
715 api_key = "sk-test"
716
717 [[providers.deepseek.models]]
718 id = "deepseek-chat"
719
720 [app]
721 auto_approve = true
722 "#;
723
724 let config: RobitConfig = toml::from_str(toml_str).unwrap();
725 let app = config.app.as_ref().unwrap();
726 assert_eq!(app.auto_approve, Some(true));
727 }
728
729 #[test]
730 fn test_parse_auto_approve_default_none() {
731 let toml_str = r#"
732 default_model = "deepseek/deepseek-chat"
733
734 [providers.deepseek]
735 base_url = "https://api.deepseek.com"
736 api_key = "sk-test"
737
738 [[providers.deepseek.models]]
739 id = "deepseek-chat"
740
741 [app]
742 "#;
743
744 let config: RobitConfig = toml::from_str(toml_str).unwrap();
745 let app = config.app.as_ref().unwrap();
746 assert_eq!(app.auto_approve, None);
747 }
748
749 fn make_test_config() -> RobitConfig {
750 let toml_str = r#"
751 default_model = "deepseek/deepseek-chat"
752
753 [providers.deepseek]
754 base_url = "https://api.deepseek.com"
755 api_key = "sk-test"
756
757 [[providers.deepseek.models]]
758 id = "deepseek-chat"
759 context_window = 65536
760 temperature = 0.0
761 max_tokens = 4096
762
763 [providers.qwen]
764 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
765 api_key = "sk-qwen-test"
766
767 [[providers.qwen.models]]
768 id = "qwen-max"
769 context_window = 32768
770 "#;
771
772 toml::from_str(toml_str).unwrap()
773 }
774
775 #[test]
776 fn test_parse_channels_and_bot_sections() {
777 let toml_str = r#"
778 default_model = "deepseek/deepseek-chat"
779
780 [providers.deepseek]
781 base_url = "https://api.deepseek.com"
782 api_key = "sk-test"
783
784 [[providers.deepseek.models]]
785 id = "deepseek-chat"
786
787 [channels.qq_bot]
788 app_id = "123456789"
789 app_secret = "secret-value"
790
791 [app.bot]
792 confirm_timeout_secs = 60
793 session_timeout_minutes = 30
794
795 [app.bot.confirm_keywords]
796 approve = ["确认", "yes"]
797 reject = ["取消", "no"]
798 "#;
799
800 let config: RobitConfig = toml::from_str(toml_str).unwrap();
801
802 let qq = config
804 .channels
805 .as_ref()
806 .and_then(|c| c.qq_bot.as_ref())
807 .expect("qq_bot config missing");
808 assert_eq!(qq.app_id, "123456789");
809 assert_eq!(qq.app_secret, "secret-value");
810
811 let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
813 assert_eq!(bot.confirm_timeout_secs, Some(60));
814 assert_eq!(bot.session_timeout_minutes, Some(30));
815 let kw = bot.confirm_keywords.as_ref().unwrap();
816 assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
817 assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
818 }
819
820 #[test]
821 fn test_config_without_channels_still_parses() {
822 let toml_str = r#"
823 [providers.deepseek]
824 base_url = "https://api.deepseek.com"
825 api_key = "sk-test"
826
827 [[providers.deepseek.models]]
828 id = "deepseek-chat"
829 "#;
830
831 let config: RobitConfig = toml::from_str(toml_str).unwrap();
832 assert!(config.channels.is_none());
833 assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
834 }
835}