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