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> {
212 load_env();
214
215 let path = find_config_path(workdir)?;
216
217 let content = std::fs::read_to_string(&path)
218 .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
219
220 let mut config: RobitConfig = toml::from_str(&content)
221 .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
222
223 for provider in config.providers.values_mut() {
225 provider.api_key = resolve_env_var(&provider.api_key);
226 }
227
228 Ok(config)
229}
230
231pub fn load_env() {
233 if let Ok(robit_dir) = robit_home() {
234 let env_path = robit_dir.join(".env");
235 if env_path.exists() {
236 let _ = dotenvy::from_path(&env_path);
237 }
238 }
239}
240
241fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
243 if let Some(workdir) = workdir {
245 let local_path = workdir.join(".robit").join("config.toml");
246 if local_path.exists() {
247 return Ok(local_path);
248 }
249 }
250
251 if let Ok(cwd) = std::env::current_dir() {
253 let local_path = cwd.join(".robit").join("config.toml");
254 if local_path.exists() {
255 return Ok(local_path);
256 }
257 }
258
259 let global_path = robit_home()?.join("config.toml");
261 if global_path.exists() {
262 return Ok(global_path);
263 }
264
265 Err(LlmError::ConfigError(format!(
266 "Configuration file config.toml not found.\n\
267 Please create one of the following:\n\
268 - Project-local: .robit/config.toml\n\
269 - Global: {}",
270 global_path.display()
271 )))
272}
273
274pub fn resolve_profile(
282 config: &RobitConfig,
283 provider_name: Option<&str>,
284) -> Result<ResolvedModel, LlmError> {
285 let (provider_key, model_id) = if let Some(name) = provider_name {
286 let provider = config.providers.get(name).ok_or_else(|| {
288 LlmError::ConfigError(format!(
289 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
290 name,
291 config.providers.keys().collect::<Vec<_>>()
292 ))
293 })?;
294 let first_model = provider.models.first().ok_or_else(|| {
295 LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
296 })?;
297 (name.to_string(), first_model.id.clone())
298 } else if let Some(ref default_model) = config.default_model {
299 parse_default_model(default_model)?
300 } else {
301 let (key, provider) = config.providers.iter().next().ok_or_else(|| {
303 LlmError::ConfigError("No providers defined in config.toml".to_string())
304 })?;
305 let first_model = provider.models.first().ok_or_else(|| {
306 LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
307 })?;
308 (key.clone(), first_model.id.clone())
309 };
310
311 let provider = config.providers.get(&provider_key).ok_or_else(|| {
312 LlmError::ConfigError(format!(
313 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
314 provider_key,
315 config.providers.keys().collect::<Vec<_>>()
316 ))
317 })?;
318
319 let model = provider
321 .models
322 .iter()
323 .find(|m| m.id == model_id)
324 .ok_or_else(|| {
325 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
326 LlmError::ConfigError(format!(
327 "Model '{}' not found in provider '{}'. Available models: {:?}",
328 model_id, provider_key, available
329 ))
330 })?;
331
332 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
334 return Err(LlmError::ConfigError(format!(
335 "Provider '{}' API key is not configured or the environment variable is not set",
336 provider_key
337 )));
338 }
339
340 Ok(ResolvedModel {
341 profile_name: provider_key,
342 model_id: model.id.clone(),
343 base_url: provider.base_url.clone(),
344 api_key: provider.api_key.clone(),
345 max_tokens: model.max_tokens,
346 temperature: model.temperature,
347 context_window: model.context_window,
348 supports_images: model.supports_images.unwrap_or(false),
349 supports_tools: model.supports_tools.unwrap_or(false),
350 })
351}
352
353fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
357 let parts: Vec<&str> = default_model.splitn(2, '/').collect();
358 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
359 return Err(LlmError::ConfigError(format!(
360 "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
361 default_model
362 )));
363 }
364 Ok((parts[0].to_string(), parts[1].to_string()))
365}
366
367#[cfg(test)]
372mod tests {
373 use super::*;
374
375 #[test]
376 fn test_resolve_env_var_with_env_set() {
377 std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
378 assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
379 std::env::remove_var("ROBIT_TEST_KEY");
380 }
381
382 #[test]
383 fn test_resolve_env_var_without_env() {
384 assert_eq!(
385 resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
386 "${ROBIT_NONEXISTENT_KEY}"
387 );
388 }
389
390 #[test]
391 fn test_resolve_env_var_plain_string() {
392 assert_eq!(resolve_env_var("plain-key"), "plain-key");
393 }
394
395 #[test]
396 fn test_parse_robit_config() {
397 let toml_str = r#"
398 default_model = "deepseek/deepseek-chat"
399
400 [providers.deepseek]
401 name = "DeepSeek"
402 base_url = "https://api.deepseek.com"
403 api_key = "sk-test-key"
404
405 [[providers.deepseek.models]]
406 id = "deepseek-chat"
407 name = "DeepSeek Chat"
408 context_window = 65536
409 max_output_tokens = 8192
410 temperature = 0.0
411 max_tokens = 4096
412
413 [[providers.deepseek.models]]
414 id = "deepseek-reasoner"
415 name = "DeepSeek Reasoner"
416 context_window = 65536
417 temperature = 0.6
418
419 [providers.qwen]
420 name = "通义千问"
421 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
422 api_key = "sk-qwen-key"
423
424 [[providers.qwen.models]]
425 id = "qwen-max"
426 name = "Qwen Max"
427 context_window = 32768
428
429 [app]
430 log_level = "DEBUG"
431 max_steps = 10
432 global_storage = true
433
434 [app.context]
435 max_output_lines = 500
436 reserve_ratio = 0.2
437
438 [app.retry]
439 max_retries = 3
440 "#;
441
442 let config: RobitConfig = toml::from_str(toml_str).unwrap();
443
444 assert_eq!(
446 config.default_model.as_deref(),
447 Some("deepseek/deepseek-chat")
448 );
449
450 assert_eq!(config.providers.len(), 2);
452
453 let ds = &config.providers["deepseek"];
455 assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
456 assert_eq!(ds.base_url, "https://api.deepseek.com");
457 assert_eq!(ds.api_key, "sk-test-key");
458 assert_eq!(ds.models.len(), 2);
459 assert_eq!(ds.models[0].id, "deepseek-chat");
460 assert_eq!(ds.models[0].context_window, Some(65536));
461 assert_eq!(ds.models[0].temperature, Some(0.0));
462 assert_eq!(ds.models[0].max_tokens, Some(4096));
463 assert_eq!(ds.models[1].id, "deepseek-reasoner");
464 assert_eq!(ds.models[1].temperature, Some(0.6));
465
466 let qw = &config.providers["qwen"];
468 assert_eq!(qw.name.as_deref(), Some("通义千问"));
469 assert_eq!(qw.models.len(), 1);
470 assert_eq!(qw.models[0].id, "qwen-max");
471
472 let app = config.app.as_ref().unwrap();
474 assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
475 assert_eq!(app.max_steps, Some(10));
476 assert_eq!(app.global_storage, Some(true));
477 assert!(app.context.is_some());
478 assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
479 assert!(app.retry.is_some());
480 assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
481 }
482
483 #[test]
484 fn test_parse_config_minimal() {
485 let toml_str = r#"
486 [providers.default]
487 base_url = "https://api.deepseek.com"
488 api_key = "sk-test"
489
490 [[providers.default.models]]
491 id = "deepseek-chat"
492 "#;
493
494 let config: RobitConfig = toml::from_str(toml_str).unwrap();
495 assert!(config.default_model.is_none());
496 assert!(config.app.is_none());
497 assert_eq!(config.providers.len(), 1);
498 }
499
500 #[test]
501 fn test_resolve_profile_from_default_model() {
502 let config = make_test_config();
503 let resolved = resolve_profile(&config, None).unwrap();
504 assert_eq!(resolved.profile_name, "deepseek");
505 assert_eq!(resolved.model_id, "deepseek-chat");
506 assert_eq!(resolved.base_url, "https://api.deepseek.com");
507 assert_eq!(resolved.api_key, "sk-test");
508 assert_eq!(resolved.context_window, Some(65536));
509 assert_eq!(resolved.temperature, Some(0.0));
510 assert_eq!(resolved.max_tokens, Some(4096));
511 }
512
513 #[test]
514 fn test_resolve_profile_explicit_provider() {
515 let config = make_test_config();
516 let resolved = resolve_profile(&config, Some("qwen")).unwrap();
518 assert_eq!(resolved.profile_name, "qwen");
519 assert_eq!(resolved.model_id, "qwen-max");
520 assert_eq!(
521 resolved.base_url,
522 "https://dashscope.aliyuncs.com/compatible-mode/v1"
523 );
524 }
525
526 #[test]
527 fn test_resolve_profile_first_available() {
528 let toml_str = r#"
530 [providers.deepseek]
531 base_url = "https://api.deepseek.com"
532 api_key = "sk-test"
533
534 [[providers.deepseek.models]]
535 id = "deepseek-chat"
536 "#;
537 let config: RobitConfig = toml::from_str(toml_str).unwrap();
538 let resolved = resolve_profile(&config, None).unwrap();
539 assert_eq!(resolved.profile_name, "deepseek");
540 assert_eq!(resolved.model_id, "deepseek-chat");
541 }
542
543 #[test]
544 fn test_resolve_profile_not_found() {
545 let config = make_test_config();
546 let result = resolve_profile(&config, Some("nonexistent"));
547 assert!(result.is_err());
548 }
549
550 #[test]
551 fn test_resolve_profile_model_not_found() {
552 let toml_str = r#"
553 default_model = "deepseek/nonexistent-model"
554
555 [providers.deepseek]
556 base_url = "https://api.deepseek.com"
557 api_key = "sk-test"
558
559 [[providers.deepseek.models]]
560 id = "deepseek-chat"
561 "#;
562 let config: RobitConfig = toml::from_str(toml_str).unwrap();
563 let result = resolve_profile(&config, None);
564 assert!(result.is_err());
565 }
566
567 #[test]
568 fn test_resolve_profile_invalid_default_model_format() {
569 let toml_str = r#"
570 default_model = "invalid-no-slash"
571
572 [providers.deepseek]
573 base_url = "https://api.deepseek.com"
574 api_key = "sk-test"
575
576 [[providers.deepseek.models]]
577 id = "deepseek-chat"
578 "#;
579 let config: RobitConfig = toml::from_str(toml_str).unwrap();
580 let result = resolve_profile(&config, None);
581 assert!(result.is_err());
582 assert!(result
583 .unwrap_err()
584 .to_string()
585 .contains("Invalid default_model"));
586 }
587
588 #[test]
589 fn test_resolve_profile_empty_api_key() {
590 let toml_str = r#"
591 [providers.deepseek]
592 base_url = "https://api.deepseek.com"
593 api_key = ""
594
595 [[providers.deepseek.models]]
596 id = "deepseek-chat"
597 "#;
598 let config: RobitConfig = toml::from_str(toml_str).unwrap();
599 let result = resolve_profile(&config, None);
600 assert!(result.is_err());
601 }
602
603 #[test]
604 fn test_parse_enabled_skills() {
605 let toml_str = r#"
606 default_model = "deepseek/deepseek-chat"
607
608 [providers.deepseek]
609 base_url = "https://api.deepseek.com"
610 api_key = "sk-test"
611
612 [[providers.deepseek.models]]
613 id = "deepseek-chat"
614
615 [app]
616 enabled_skills = ["code-review", "refactor"]
617 "#;
618
619 let config: RobitConfig = toml::from_str(toml_str).unwrap();
620 let app = config.app.as_ref().unwrap();
621 assert!(app.enabled_skills.is_some());
622 let skills = app.enabled_skills.as_ref().unwrap();
623 assert_eq!(skills.len(), 2);
624 assert_eq!(skills[0], "code-review");
625 assert_eq!(skills[1], "refactor");
626 }
627
628 #[test]
629 fn test_parse_enabled_tools() {
630 let toml_str = r#"
631 default_model = "deepseek/deepseek-chat"
632
633 [providers.deepseek]
634 base_url = "https://api.deepseek.com"
635 api_key = "sk-test"
636
637 [[providers.deepseek.models]]
638 id = "deepseek-chat"
639
640 [app]
641 enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
642 "#;
643
644 let config: RobitConfig = toml::from_str(toml_str).unwrap();
645 let app = config.app.as_ref().unwrap();
646 assert!(app.enabled_tools.is_some());
647 let tools = app.enabled_tools.as_ref().unwrap();
648 assert_eq!(tools.len(), 7);
649 assert_eq!(tools[0], "read");
650 assert_eq!(tools[1], "bash");
651 assert_eq!(tools[2], "edit");
652 assert_eq!(tools[3], "write");
653 assert_eq!(tools[4], "grep");
654 assert_eq!(tools[5], "find");
655 assert_eq!(tools[6], "ls");
656 }
657
658 #[test]
659 fn test_parse_auto_approve() {
660 let toml_str = r#"
661 default_model = "deepseek/deepseek-chat"
662
663 [providers.deepseek]
664 base_url = "https://api.deepseek.com"
665 api_key = "sk-test"
666
667 [[providers.deepseek.models]]
668 id = "deepseek-chat"
669
670 [app]
671 auto_approve = true
672 "#;
673
674 let config: RobitConfig = toml::from_str(toml_str).unwrap();
675 let app = config.app.as_ref().unwrap();
676 assert_eq!(app.auto_approve, Some(true));
677 }
678
679 #[test]
680 fn test_parse_auto_approve_default_none() {
681 let toml_str = r#"
682 default_model = "deepseek/deepseek-chat"
683
684 [providers.deepseek]
685 base_url = "https://api.deepseek.com"
686 api_key = "sk-test"
687
688 [[providers.deepseek.models]]
689 id = "deepseek-chat"
690
691 [app]
692 "#;
693
694 let config: RobitConfig = toml::from_str(toml_str).unwrap();
695 let app = config.app.as_ref().unwrap();
696 assert_eq!(app.auto_approve, None);
697 }
698
699 fn make_test_config() -> RobitConfig {
700 let toml_str = r#"
701 default_model = "deepseek/deepseek-chat"
702
703 [providers.deepseek]
704 base_url = "https://api.deepseek.com"
705 api_key = "sk-test"
706
707 [[providers.deepseek.models]]
708 id = "deepseek-chat"
709 context_window = 65536
710 temperature = 0.0
711 max_tokens = 4096
712
713 [providers.qwen]
714 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
715 api_key = "sk-qwen-test"
716
717 [[providers.qwen.models]]
718 id = "qwen-max"
719 context_window = 32768
720 "#;
721
722 toml::from_str(toml_str).unwrap()
723 }
724
725 #[test]
726 fn test_parse_channels_and_bot_sections() {
727 let toml_str = r#"
728 default_model = "deepseek/deepseek-chat"
729
730 [providers.deepseek]
731 base_url = "https://api.deepseek.com"
732 api_key = "sk-test"
733
734 [[providers.deepseek.models]]
735 id = "deepseek-chat"
736
737 [channels.qq_bot]
738 app_id = "123456789"
739 app_secret = "secret-value"
740
741 [app.bot]
742 confirm_timeout_secs = 60
743 session_timeout_minutes = 30
744
745 [app.bot.confirm_keywords]
746 approve = ["确认", "yes"]
747 reject = ["取消", "no"]
748 "#;
749
750 let config: RobitConfig = toml::from_str(toml_str).unwrap();
751
752 let qq = config
754 .channels
755 .as_ref()
756 .and_then(|c| c.qq_bot.as_ref())
757 .expect("qq_bot config missing");
758 assert_eq!(qq.app_id, "123456789");
759 assert_eq!(qq.app_secret, "secret-value");
760
761 let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
763 assert_eq!(bot.confirm_timeout_secs, Some(60));
764 assert_eq!(bot.session_timeout_minutes, Some(30));
765 let kw = bot.confirm_keywords.as_ref().unwrap();
766 assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
767 assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
768 }
769
770 #[test]
771 fn test_config_without_channels_still_parses() {
772 let toml_str = r#"
773 [providers.deepseek]
774 base_url = "https://api.deepseek.com"
775 api_key = "sk-test"
776
777 [[providers.deepseek.models]]
778 id = "deepseek-chat"
779 "#;
780
781 let config: RobitConfig = toml::from_str(toml_str).unwrap();
782 assert!(config.channels.is_none());
783 assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
784 }
785}