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