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 truncation_ratio: Option<f32>,
109 pub min_keep_rounds: Option<usize>,
112 pub token_safety_margin: Option<f32>,
115 pub compression_token_threshold: Option<usize>,
118 pub compression_enabled: Option<bool>,
120 pub max_tool_calls_per_turn: Option<usize>,
123}
124
125#[derive(Debug, Deserialize)]
126pub struct RetryConfig {
127 pub max_retries: Option<u32>,
128 pub initial_backoff_ms: Option<u64>,
129 pub max_backoff_ms: Option<u64>,
130}
131
132#[derive(Debug, Deserialize, Default)]
138pub struct ChannelsConfig {
139 pub qq_bot: Option<QqBotConfig>,
141}
142
143#[derive(Debug, Deserialize, Clone)]
145pub struct QqBotConfig {
146 pub app_id: String,
147 pub app_secret: String,
148}
149
150#[derive(Debug, Deserialize, Default)]
156pub struct BotConfig {
157 pub confirm_timeout_secs: Option<u64>,
159 pub session_timeout_minutes: Option<u64>,
161 pub confirm_keywords: Option<ConfirmKeywordsConfig>,
163}
164
165#[derive(Debug, Deserialize, Clone, Default)]
167pub struct ConfirmKeywordsConfig {
168 pub approve: Option<Vec<String>>,
169 pub reject: Option<Vec<String>>,
170}
171
172#[derive(Debug, Clone)]
181pub struct ResolvedModel {
182 pub profile_name: String,
183 pub model_id: String,
184 pub base_url: String,
185 pub api_key: String,
186 pub max_tokens: Option<u32>,
187 pub temperature: Option<f32>,
188 pub context_window: Option<u64>,
189 pub supports_images: bool,
191 pub supports_tools: bool,
193}
194
195fn robit_home() -> Result<PathBuf, LlmError> {
201 let home = dirs::home_dir()
202 .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
203 Ok(home.join(".robit"))
204}
205
206fn resolve_env_var(value: &str) -> String {
208 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
209 std::env::var(var_name).unwrap_or_else(|_| value.to_string())
210 } else {
211 value.to_string()
212 }
213}
214
215pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
226 load_env_from(workdir);
228
229 let path = find_config_path(workdir)?;
230
231 let content = std::fs::read_to_string(&path)
232 .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
233
234 let mut config: RobitConfig = toml::from_str(&content)
235 .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
236
237 for provider in config.providers.values_mut() {
239 provider.api_key = resolve_env_var(&provider.api_key);
240 }
241
242 if let Some(ref mut channels) = config.channels {
244 if let Some(ref mut qq_bot) = channels.qq_bot {
245 qq_bot.app_id = resolve_env_var(&qq_bot.app_id);
246 qq_bot.app_secret = resolve_env_var(&qq_bot.app_secret);
247 }
248 }
249
250 Ok(config)
251}
252
253pub fn load_env_from(workdir: Option<&std::path::Path>) {
256 let mut env_paths = Vec::new();
258
259 if let Some(workdir) = workdir {
261 let local_env = workdir.join(".robit").join(".env");
262 if local_env.exists() {
263 env_paths.push(local_env);
264 }
265 } else if let Ok(cwd) = std::env::current_dir() {
266 let local_env = cwd.join(".robit").join(".env");
267 if local_env.exists() {
268 env_paths.push(local_env);
269 }
270 }
271
272 if let Ok(robit_dir) = robit_home() {
274 let env_path = robit_dir.join(".env");
275 if env_path.exists() {
276 env_paths.push(env_path);
277 }
278 }
279
280 for path in env_paths.iter().rev() {
283 if let Ok(iter) = dotenvy::from_path_iter(path) {
284 for item in iter {
285 if let Ok((key, value)) = item {
286 std::env::set_var(key, value);
287 }
288 }
289 }
290 }
291}
292
293pub fn load_env() {
295 if let Ok(robit_dir) = robit_home() {
296 let env_path = robit_dir.join(".env");
297 if env_path.exists() {
298 let _ = dotenvy::from_path(&env_path);
299 }
300 }
301}
302
303fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
305 if let Some(workdir) = workdir {
307 let local_path = workdir.join(".robit").join("config.toml");
308 if local_path.exists() {
309 return Ok(local_path);
310 }
311 }
312
313 if let Ok(cwd) = std::env::current_dir() {
315 let local_path = cwd.join(".robit").join("config.toml");
316 if local_path.exists() {
317 return Ok(local_path);
318 }
319 }
320
321 let global_path = robit_home()?.join("config.toml");
323 if global_path.exists() {
324 return Ok(global_path);
325 }
326
327 Err(LlmError::ConfigError(format!(
328 "Configuration file config.toml not found.\n\
329 Please create one of the following:\n\
330 - Project-local: .robit/config.toml\n\
331 - Global: {}",
332 global_path.display()
333 )))
334}
335
336pub fn resolve_profile(
344 config: &RobitConfig,
345 provider_name: Option<&str>,
346) -> Result<ResolvedModel, LlmError> {
347 let (provider_key, model_id) = if let Some(name) = provider_name {
348 let provider = config.providers.get(name).ok_or_else(|| {
350 LlmError::ConfigError(format!(
351 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
352 name,
353 config.providers.keys().collect::<Vec<_>>()
354 ))
355 })?;
356 let first_model = provider.models.first().ok_or_else(|| {
357 LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
358 })?;
359 (name.to_string(), first_model.id.clone())
360 } else if let Some(ref default_model) = config.default_model {
361 parse_default_model(default_model)?
362 } else {
363 let (key, provider) = config.providers.iter().next().ok_or_else(|| {
365 LlmError::ConfigError("No providers defined in config.toml".to_string())
366 })?;
367 let first_model = provider.models.first().ok_or_else(|| {
368 LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
369 })?;
370 (key.clone(), first_model.id.clone())
371 };
372
373 let provider = config.providers.get(&provider_key).ok_or_else(|| {
374 LlmError::ConfigError(format!(
375 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
376 provider_key,
377 config.providers.keys().collect::<Vec<_>>()
378 ))
379 })?;
380
381 let model = provider
383 .models
384 .iter()
385 .find(|m| m.id == model_id)
386 .ok_or_else(|| {
387 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
388 LlmError::ConfigError(format!(
389 "Model '{}' not found in provider '{}'. Available models: {:?}",
390 model_id, provider_key, available
391 ))
392 })?;
393
394 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
396 return Err(LlmError::ConfigError(format!(
397 "Provider '{}' API key is not configured or the environment variable is not set",
398 provider_key
399 )));
400 }
401
402 Ok(ResolvedModel {
403 profile_name: provider_key,
404 model_id: model.id.clone(),
405 base_url: provider.base_url.clone(),
406 api_key: provider.api_key.clone(),
407 max_tokens: model.max_tokens,
408 temperature: model.temperature,
409 context_window: model.context_window,
410 supports_images: model.supports_images.unwrap_or(false),
411 supports_tools: model.supports_tools.unwrap_or(false),
412 })
413}
414
415fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
419 let parts: Vec<&str> = default_model.splitn(2, '/').collect();
420 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
421 return Err(LlmError::ConfigError(format!(
422 "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
423 default_model
424 )));
425 }
426 Ok((parts[0].to_string(), parts[1].to_string()))
427}
428
429#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn test_resolve_env_var_with_env_set() {
439 std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
440 assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
441 std::env::remove_var("ROBIT_TEST_KEY");
442 }
443
444 #[test]
445 fn test_resolve_env_var_without_env() {
446 assert_eq!(
447 resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
448 "${ROBIT_NONEXISTENT_KEY}"
449 );
450 }
451
452 #[test]
453 fn test_resolve_env_var_plain_string() {
454 assert_eq!(resolve_env_var("plain-key"), "plain-key");
455 }
456
457 #[test]
458 fn test_parse_robit_config() {
459 let toml_str = r#"
460 default_model = "deepseek/deepseek-chat"
461
462 [providers.deepseek]
463 name = "DeepSeek"
464 base_url = "https://api.deepseek.com"
465 api_key = "sk-test-key"
466
467 [[providers.deepseek.models]]
468 id = "deepseek-chat"
469 name = "DeepSeek Chat"
470 context_window = 65536
471 max_output_tokens = 8192
472 temperature = 0.0
473 max_tokens = 4096
474
475 [[providers.deepseek.models]]
476 id = "deepseek-reasoner"
477 name = "DeepSeek Reasoner"
478 context_window = 65536
479 temperature = 0.6
480
481 [providers.qwen]
482 name = "通义千问"
483 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
484 api_key = "sk-qwen-key"
485
486 [[providers.qwen.models]]
487 id = "qwen-max"
488 name = "Qwen Max"
489 context_window = 32768
490
491 [app]
492 log_level = "DEBUG"
493 max_steps = 10
494 global_storage = true
495
496 [app.context]
497 max_output_lines = 500
498 reserve_ratio = 0.2
499
500 [app.retry]
501 max_retries = 3
502 "#;
503
504 let config: RobitConfig = toml::from_str(toml_str).unwrap();
505
506 assert_eq!(
508 config.default_model.as_deref(),
509 Some("deepseek/deepseek-chat")
510 );
511
512 assert_eq!(config.providers.len(), 2);
514
515 let ds = &config.providers["deepseek"];
517 assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
518 assert_eq!(ds.base_url, "https://api.deepseek.com");
519 assert_eq!(ds.api_key, "sk-test-key");
520 assert_eq!(ds.models.len(), 2);
521 assert_eq!(ds.models[0].id, "deepseek-chat");
522 assert_eq!(ds.models[0].context_window, Some(65536));
523 assert_eq!(ds.models[0].temperature, Some(0.0));
524 assert_eq!(ds.models[0].max_tokens, Some(4096));
525 assert_eq!(ds.models[1].id, "deepseek-reasoner");
526 assert_eq!(ds.models[1].temperature, Some(0.6));
527
528 let qw = &config.providers["qwen"];
530 assert_eq!(qw.name.as_deref(), Some("通义千问"));
531 assert_eq!(qw.models.len(), 1);
532 assert_eq!(qw.models[0].id, "qwen-max");
533
534 let app = config.app.as_ref().unwrap();
536 assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
537 assert_eq!(app.max_steps, Some(10));
538 assert_eq!(app.global_storage, Some(true));
539 assert!(app.context.is_some());
540 assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
541 assert!(app.retry.is_some());
542 assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
543 }
544
545 #[test]
546 fn test_parse_config_minimal() {
547 let toml_str = r#"
548 [providers.default]
549 base_url = "https://api.deepseek.com"
550 api_key = "sk-test"
551
552 [[providers.default.models]]
553 id = "deepseek-chat"
554 "#;
555
556 let config: RobitConfig = toml::from_str(toml_str).unwrap();
557 assert!(config.default_model.is_none());
558 assert!(config.app.is_none());
559 assert_eq!(config.providers.len(), 1);
560 }
561
562 #[test]
563 fn test_resolve_profile_from_default_model() {
564 let config = make_test_config();
565 let resolved = resolve_profile(&config, None).unwrap();
566 assert_eq!(resolved.profile_name, "deepseek");
567 assert_eq!(resolved.model_id, "deepseek-chat");
568 assert_eq!(resolved.base_url, "https://api.deepseek.com");
569 assert_eq!(resolved.api_key, "sk-test");
570 assert_eq!(resolved.context_window, Some(65536));
571 assert_eq!(resolved.temperature, Some(0.0));
572 assert_eq!(resolved.max_tokens, Some(4096));
573 }
574
575 #[test]
576 fn test_resolve_profile_explicit_provider() {
577 let config = make_test_config();
578 let resolved = resolve_profile(&config, Some("qwen")).unwrap();
580 assert_eq!(resolved.profile_name, "qwen");
581 assert_eq!(resolved.model_id, "qwen-max");
582 assert_eq!(
583 resolved.base_url,
584 "https://dashscope.aliyuncs.com/compatible-mode/v1"
585 );
586 }
587
588 #[test]
589 fn test_resolve_profile_first_available() {
590 let toml_str = r#"
592 [providers.deepseek]
593 base_url = "https://api.deepseek.com"
594 api_key = "sk-test"
595
596 [[providers.deepseek.models]]
597 id = "deepseek-chat"
598 "#;
599 let config: RobitConfig = toml::from_str(toml_str).unwrap();
600 let resolved = resolve_profile(&config, None).unwrap();
601 assert_eq!(resolved.profile_name, "deepseek");
602 assert_eq!(resolved.model_id, "deepseek-chat");
603 }
604
605 #[test]
606 fn test_resolve_profile_not_found() {
607 let config = make_test_config();
608 let result = resolve_profile(&config, Some("nonexistent"));
609 assert!(result.is_err());
610 }
611
612 #[test]
613 fn test_resolve_profile_model_not_found() {
614 let toml_str = r#"
615 default_model = "deepseek/nonexistent-model"
616
617 [providers.deepseek]
618 base_url = "https://api.deepseek.com"
619 api_key = "sk-test"
620
621 [[providers.deepseek.models]]
622 id = "deepseek-chat"
623 "#;
624 let config: RobitConfig = toml::from_str(toml_str).unwrap();
625 let result = resolve_profile(&config, None);
626 assert!(result.is_err());
627 }
628
629 #[test]
630 fn test_resolve_profile_invalid_default_model_format() {
631 let toml_str = r#"
632 default_model = "invalid-no-slash"
633
634 [providers.deepseek]
635 base_url = "https://api.deepseek.com"
636 api_key = "sk-test"
637
638 [[providers.deepseek.models]]
639 id = "deepseek-chat"
640 "#;
641 let config: RobitConfig = toml::from_str(toml_str).unwrap();
642 let result = resolve_profile(&config, None);
643 assert!(result.is_err());
644 assert!(result
645 .unwrap_err()
646 .to_string()
647 .contains("Invalid default_model"));
648 }
649
650 #[test]
651 fn test_resolve_profile_empty_api_key() {
652 let toml_str = r#"
653 [providers.deepseek]
654 base_url = "https://api.deepseek.com"
655 api_key = ""
656
657 [[providers.deepseek.models]]
658 id = "deepseek-chat"
659 "#;
660 let config: RobitConfig = toml::from_str(toml_str).unwrap();
661 let result = resolve_profile(&config, None);
662 assert!(result.is_err());
663 }
664
665 #[test]
666 fn test_parse_enabled_skills() {
667 let toml_str = r#"
668 default_model = "deepseek/deepseek-chat"
669
670 [providers.deepseek]
671 base_url = "https://api.deepseek.com"
672 api_key = "sk-test"
673
674 [[providers.deepseek.models]]
675 id = "deepseek-chat"
676
677 [app]
678 enabled_skills = ["code-review", "refactor"]
679 "#;
680
681 let config: RobitConfig = toml::from_str(toml_str).unwrap();
682 let app = config.app.as_ref().unwrap();
683 assert!(app.enabled_skills.is_some());
684 let skills = app.enabled_skills.as_ref().unwrap();
685 assert_eq!(skills.len(), 2);
686 assert_eq!(skills[0], "code-review");
687 assert_eq!(skills[1], "refactor");
688 }
689
690 #[test]
691 fn test_parse_enabled_tools() {
692 let toml_str = r#"
693 default_model = "deepseek/deepseek-chat"
694
695 [providers.deepseek]
696 base_url = "https://api.deepseek.com"
697 api_key = "sk-test"
698
699 [[providers.deepseek.models]]
700 id = "deepseek-chat"
701
702 [app]
703 enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
704 "#;
705
706 let config: RobitConfig = toml::from_str(toml_str).unwrap();
707 let app = config.app.as_ref().unwrap();
708 assert!(app.enabled_tools.is_some());
709 let tools = app.enabled_tools.as_ref().unwrap();
710 assert_eq!(tools.len(), 7);
711 assert_eq!(tools[0], "read");
712 assert_eq!(tools[1], "bash");
713 assert_eq!(tools[2], "edit");
714 assert_eq!(tools[3], "write");
715 assert_eq!(tools[4], "grep");
716 assert_eq!(tools[5], "find");
717 assert_eq!(tools[6], "ls");
718 }
719
720 #[test]
721 fn test_parse_auto_approve() {
722 let toml_str = r#"
723 default_model = "deepseek/deepseek-chat"
724
725 [providers.deepseek]
726 base_url = "https://api.deepseek.com"
727 api_key = "sk-test"
728
729 [[providers.deepseek.models]]
730 id = "deepseek-chat"
731
732 [app]
733 auto_approve = true
734 "#;
735
736 let config: RobitConfig = toml::from_str(toml_str).unwrap();
737 let app = config.app.as_ref().unwrap();
738 assert_eq!(app.auto_approve, Some(true));
739 }
740
741 #[test]
742 fn test_parse_auto_approve_default_none() {
743 let toml_str = r#"
744 default_model = "deepseek/deepseek-chat"
745
746 [providers.deepseek]
747 base_url = "https://api.deepseek.com"
748 api_key = "sk-test"
749
750 [[providers.deepseek.models]]
751 id = "deepseek-chat"
752
753 [app]
754 "#;
755
756 let config: RobitConfig = toml::from_str(toml_str).unwrap();
757 let app = config.app.as_ref().unwrap();
758 assert_eq!(app.auto_approve, None);
759 }
760
761 fn make_test_config() -> RobitConfig {
762 let toml_str = r#"
763 default_model = "deepseek/deepseek-chat"
764
765 [providers.deepseek]
766 base_url = "https://api.deepseek.com"
767 api_key = "sk-test"
768
769 [[providers.deepseek.models]]
770 id = "deepseek-chat"
771 context_window = 65536
772 temperature = 0.0
773 max_tokens = 4096
774
775 [providers.qwen]
776 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
777 api_key = "sk-qwen-test"
778
779 [[providers.qwen.models]]
780 id = "qwen-max"
781 context_window = 32768
782 "#;
783
784 toml::from_str(toml_str).unwrap()
785 }
786
787 #[test]
788 fn test_parse_channels_and_bot_sections() {
789 let toml_str = r#"
790 default_model = "deepseek/deepseek-chat"
791
792 [providers.deepseek]
793 base_url = "https://api.deepseek.com"
794 api_key = "sk-test"
795
796 [[providers.deepseek.models]]
797 id = "deepseek-chat"
798
799 [channels.qq_bot]
800 app_id = "123456789"
801 app_secret = "secret-value"
802
803 [app.bot]
804 confirm_timeout_secs = 60
805 session_timeout_minutes = 30
806
807 [app.bot.confirm_keywords]
808 approve = ["确认", "yes"]
809 reject = ["取消", "no"]
810 "#;
811
812 let config: RobitConfig = toml::from_str(toml_str).unwrap();
813
814 let qq = config
816 .channels
817 .as_ref()
818 .and_then(|c| c.qq_bot.as_ref())
819 .expect("qq_bot config missing");
820 assert_eq!(qq.app_id, "123456789");
821 assert_eq!(qq.app_secret, "secret-value");
822
823 let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
825 assert_eq!(bot.confirm_timeout_secs, Some(60));
826 assert_eq!(bot.session_timeout_minutes, Some(30));
827 let kw = bot.confirm_keywords.as_ref().unwrap();
828 assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
829 assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
830 }
831
832 #[test]
833 fn test_config_without_channels_still_parses() {
834 let toml_str = r#"
835 [providers.deepseek]
836 base_url = "https://api.deepseek.com"
837 api_key = "sk-test"
838
839 [[providers.deepseek.models]]
840 id = "deepseek-chat"
841 "#;
842
843 let config: RobitConfig = toml::from_str(toml_str).unwrap();
844 assert!(config.channels.is_none());
845 assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
846 }
847}