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 pub default_image_model: Option<String>,
49 #[serde(default)]
51 pub image_providers: HashMap<String, ImageProviderConfig>,
52}
53
54#[derive(Debug, Deserialize)]
56pub struct ProviderConfig {
57 pub name: Option<String>,
59 pub base_url: String,
61 pub api_key: String,
63 pub models: Vec<ModelConfig>,
65}
66
67#[derive(Debug, Deserialize)]
69pub struct ModelConfig {
70 pub id: String,
72 pub name: Option<String>,
74 pub context_window: Option<u64>,
76 pub max_output_tokens: Option<u64>,
78 pub temperature: Option<f32>,
80 pub max_tokens: Option<u32>,
82 pub supports_images: Option<bool>,
84 pub supports_tools: Option<bool>,
88}
89
90#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
96#[serde(rename_all = "lowercase")]
97pub enum ImageProtocol {
98 Openai,
100 Dashscope,
102}
103
104impl Default for ImageProtocol {
105 fn default() -> Self {
106 Self::Openai
107 }
108}
109
110#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
112#[serde(rename_all = "lowercase")]
113pub enum ImageCallMode {
114 Sync,
116 Async,
118}
119
120impl Default for ImageCallMode {
121 fn default() -> Self {
122 Self::Sync
123 }
124}
125
126#[derive(Debug, Deserialize, Clone)]
128pub struct ImageModelConfig {
129 pub id: String,
131 pub name: Option<String>,
133}
134
135#[derive(Debug, Deserialize, Clone)]
137pub struct ImageProviderConfig {
138 pub name: Option<String>,
140 pub base_url: String,
146 pub api_key: String,
148 #[serde(default)]
150 pub protocol: ImageProtocol,
151 #[serde(default)]
153 pub mode: ImageCallMode,
154 pub models: Vec<ImageModelConfig>,
156 #[serde(default = "default_poll_interval")]
158 pub poll_interval_secs: u64,
159 #[serde(default = "default_poll_timeout")]
161 pub poll_timeout_secs: u64,
162}
163
164fn default_poll_interval() -> u64 {
165 3
166}
167
168fn default_poll_timeout() -> u64 {
169 300
170}
171
172#[derive(Debug, Deserialize, Default)]
177pub struct AppConfig {
178 pub log_level: Option<String>,
179 pub log_file: Option<bool>,
181 pub log_retention_days: Option<u32>,
185 pub max_steps: Option<usize>,
186 pub enabled_tools: Option<Vec<String>>,
187 pub enabled_skills: Option<Vec<String>>,
188 pub context: Option<ContextConfig>,
189 pub retry: Option<RetryConfig>,
190 pub auto_approve: Option<bool>,
191 pub global_storage: Option<bool>,
192 pub bot: Option<BotConfig>,
194}
195
196#[derive(Debug, Clone, Deserialize)]
197pub struct ContextConfig {
198 pub max_output_lines: Option<usize>,
199 pub max_output_bytes: Option<usize>,
200 pub reserve_ratio: Option<f32>,
201 pub truncation_ratio: Option<f32>,
204 pub min_keep_rounds: Option<usize>,
207 pub token_safety_margin: Option<f32>,
210 pub compression_token_threshold: Option<usize>,
213 pub compression_enabled: Option<bool>,
215 pub max_tool_calls_per_turn: Option<usize>,
218 pub progressive_compression: Option<bool>,
221 pub rounds_per_summary: Option<usize>,
224 pub max_summary_segments: Option<usize>,
227 pub merge_count: Option<usize>,
229 pub max_merges_per_segment: Option<usize>,
232}
233
234#[derive(Debug, Deserialize)]
235pub struct RetryConfig {
236 pub max_retries: Option<u32>,
237 pub initial_backoff_ms: Option<u64>,
238 pub max_backoff_ms: Option<u64>,
239}
240
241#[derive(Debug, Deserialize, Default)]
247pub struct ChannelsConfig {
248 pub qq_bot: Option<QqBotConfig>,
250}
251
252#[derive(Debug, Deserialize, Clone)]
254pub struct QqBotConfig {
255 pub app_id: String,
256 pub app_secret: String,
257}
258
259#[derive(Debug, Deserialize, Default)]
265pub struct BotConfig {
266 pub confirm_timeout_secs: Option<u64>,
268 pub session_timeout_minutes: Option<u64>,
270 pub confirm_keywords: Option<ConfirmKeywordsConfig>,
272}
273
274#[derive(Debug, Deserialize, Clone, Default)]
276pub struct ConfirmKeywordsConfig {
277 pub approve: Option<Vec<String>>,
278 pub reject: Option<Vec<String>>,
279}
280
281#[derive(Debug, Clone)]
290pub struct ResolvedModel {
291 pub profile_name: String,
292 pub model_id: String,
293 pub base_url: String,
294 pub api_key: String,
295 pub max_tokens: Option<u32>,
296 pub temperature: Option<f32>,
297 pub context_window: Option<u64>,
298 pub supports_images: bool,
300 pub supports_tools: bool,
302}
303
304fn robit_home() -> Result<PathBuf, LlmError> {
310 let home = dirs::home_dir()
311 .ok_or_else(|| LlmError::ConfigError("Cannot determine home directory".to_string()))?;
312 Ok(home.join(".robit"))
313}
314
315fn resolve_env_var(value: &str) -> String {
317 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}')) {
318 std::env::var(var_name).unwrap_or_else(|_| value.to_string())
319 } else {
320 value.to_string()
321 }
322}
323
324pub fn load_config(workdir: Option<&std::path::Path>) -> Result<RobitConfig, LlmError> {
335 load_env_from(workdir);
337
338 let path = find_config_path(workdir)?;
339
340 let content = std::fs::read_to_string(&path)
341 .map_err(|e| LlmError::ConfigError(format!("Failed to read {}: {}", path.display(), e)))?;
342
343 let mut config: RobitConfig = toml::from_str(&content)
344 .map_err(|e| LlmError::ConfigError(format!("Failed to parse config.toml: {}", e)))?;
345
346 for provider in config.providers.values_mut() {
348 provider.api_key = resolve_env_var(&provider.api_key);
349 }
350
351 for provider in config.image_providers.values_mut() {
353 provider.api_key = resolve_env_var(&provider.api_key);
354 }
355
356 if let Some(ref mut channels) = config.channels {
358 if let Some(ref mut qq_bot) = channels.qq_bot {
359 qq_bot.app_id = resolve_env_var(&qq_bot.app_id);
360 qq_bot.app_secret = resolve_env_var(&qq_bot.app_secret);
361 }
362 }
363
364 Ok(config)
365}
366
367pub fn load_env_from(workdir: Option<&std::path::Path>) {
370 let mut env_paths = Vec::new();
372
373 if let Some(workdir) = workdir {
375 let local_env = workdir.join(".robit").join(".env");
376 if local_env.exists() {
377 env_paths.push(local_env);
378 }
379 } else if let Ok(cwd) = std::env::current_dir() {
380 let local_env = cwd.join(".robit").join(".env");
381 if local_env.exists() {
382 env_paths.push(local_env);
383 }
384 }
385
386 if let Ok(robit_dir) = robit_home() {
388 let env_path = robit_dir.join(".env");
389 if env_path.exists() {
390 env_paths.push(env_path);
391 }
392 }
393
394 for path in env_paths.iter().rev() {
397 if let Ok(iter) = dotenvy::from_path_iter(path) {
398 for item in iter {
399 if let Ok((key, value)) = item {
400 std::env::set_var(key, value);
401 }
402 }
403 }
404 }
405}
406
407pub fn load_env() {
409 if let Ok(robit_dir) = robit_home() {
410 let env_path = robit_dir.join(".env");
411 if env_path.exists() {
412 let _ = dotenvy::from_path(&env_path);
413 }
414 }
415}
416
417fn find_config_path(workdir: Option<&std::path::Path>) -> Result<PathBuf, LlmError> {
419 if let Some(workdir) = workdir {
421 let local_path = workdir.join(".robit").join("config.toml");
422 if local_path.exists() {
423 return Ok(local_path);
424 }
425 }
426
427 if let Ok(cwd) = std::env::current_dir() {
429 let local_path = cwd.join(".robit").join("config.toml");
430 if local_path.exists() {
431 return Ok(local_path);
432 }
433 }
434
435 let global_path = robit_home()?.join("config.toml");
437 if global_path.exists() {
438 return Ok(global_path);
439 }
440
441 Err(LlmError::ConfigError(format!(
442 "Configuration file config.toml not found.\n\
443 Please create one of the following:\n\
444 - Project-local: .robit/config.toml\n\
445 - Global: {}",
446 global_path.display()
447 )))
448}
449
450pub fn resolve_profile(
458 config: &RobitConfig,
459 provider_name: Option<&str>,
460) -> Result<ResolvedModel, LlmError> {
461 let (provider_key, model_id) = if let Some(name) = provider_name {
462 let provider = config.providers.get(name).ok_or_else(|| {
464 LlmError::ConfigError(format!(
465 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
466 name,
467 config.providers.keys().collect::<Vec<_>>()
468 ))
469 })?;
470 let first_model = provider.models.first().ok_or_else(|| {
471 LlmError::ConfigError(format!("Provider '{}' has no models defined", name))
472 })?;
473 (name.to_string(), first_model.id.clone())
474 } else if let Some(ref default_model) = config.default_model {
475 parse_default_model(default_model)?
476 } else {
477 let (key, provider) = config.providers.iter().next().ok_or_else(|| {
479 LlmError::ConfigError("No providers defined in config.toml".to_string())
480 })?;
481 let first_model = provider.models.first().ok_or_else(|| {
482 LlmError::ConfigError(format!("Provider '{}' has no models defined", key))
483 })?;
484 (key.clone(), first_model.id.clone())
485 };
486
487 let provider = config.providers.get(&provider_key).ok_or_else(|| {
488 LlmError::ConfigError(format!(
489 "Provider '{}' is not defined in config.toml. Available providers: {:?}",
490 provider_key,
491 config.providers.keys().collect::<Vec<_>>()
492 ))
493 })?;
494
495 let model = provider
497 .models
498 .iter()
499 .find(|m| m.id == model_id)
500 .ok_or_else(|| {
501 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
502 LlmError::ConfigError(format!(
503 "Model '{}' not found in provider '{}'. Available models: {:?}",
504 model_id, provider_key, available
505 ))
506 })?;
507
508 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
510 return Err(LlmError::ConfigError(format!(
511 "Provider '{}' API key is not configured or the environment variable is not set",
512 provider_key
513 )));
514 }
515
516 Ok(ResolvedModel {
517 profile_name: provider_key,
518 model_id: model.id.clone(),
519 base_url: provider.base_url.clone(),
520 api_key: provider.api_key.clone(),
521 max_tokens: model.max_tokens,
522 temperature: model.temperature,
523 context_window: model.context_window,
524 supports_images: model.supports_images.unwrap_or(false),
525 supports_tools: model.supports_tools.unwrap_or(true),
529 })
530}
531
532fn parse_default_model(default_model: &str) -> Result<(String, String), LlmError> {
536 let parts: Vec<&str> = default_model.splitn(2, '/').collect();
537 if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
538 return Err(LlmError::ConfigError(format!(
539 "Invalid default_model '{}' format, expected 'provider/model' (e.g. 'deepseek/deepseek-chat')",
540 default_model
541 )));
542 }
543 Ok((parts[0].to_string(), parts[1].to_string()))
544}
545
546#[derive(Debug, Clone)]
555pub struct ResolvedImageProvider {
556 pub provider_name: String,
558 pub model_id: String,
560 pub base_url: String,
562 pub api_key: String,
564 pub protocol: ImageProtocol,
566 pub mode: ImageCallMode,
568 pub poll_interval_secs: u64,
570 pub poll_timeout_secs: u64,
572}
573
574pub fn resolve_image_provider(config: &RobitConfig) -> Result<ResolvedImageProvider, LlmError> {
584 if config.image_providers.is_empty() {
585 return Err(LlmError::ConfigError(
586 "No image providers defined in config.toml".to_string(),
587 ));
588 }
589
590 let default = config.default_image_model.as_ref().ok_or_else(|| {
593 LlmError::ConfigError(
594 "default_image_model is not configured. Set it to \"provider/model\" \
595 (e.g. \"wanxiang/wan2.7-image-pro\") to enable image generation."
596 .to_string(),
597 )
598 })?;
599
600 let (provider_key, model_id) = parse_default_model(default)?;
601
602 let provider = config.image_providers.get(&provider_key).ok_or_else(|| {
603 let available: Vec<&str> = config.image_providers.keys().map(|s| s.as_str()).collect();
604 LlmError::ConfigError(format!(
605 "Image provider '{}' is not defined in config.toml. Available image providers: {:?}",
606 provider_key, available
607 ))
608 })?;
609
610 let model_exists = provider.models.iter().any(|m| m.id == model_id);
612 if !model_exists {
613 let available: Vec<&str> = provider.models.iter().map(|m| m.id.as_str()).collect();
614 return Err(LlmError::ConfigError(format!(
615 "Image model '{}' not found in provider '{}'. Available models: {:?}",
616 model_id, provider_key, available
617 )));
618 }
619
620 if provider.api_key.is_empty() || provider.api_key.starts_with("${") {
622 return Err(LlmError::ConfigError(format!(
623 "Image provider '{}' API key is not configured or the environment variable is not set",
624 provider_key
625 )));
626 }
627
628 Ok(ResolvedImageProvider {
629 provider_name: provider_key,
630 model_id,
631 base_url: provider.base_url.clone(),
632 api_key: provider.api_key.clone(),
633 protocol: provider.protocol.clone(),
634 mode: provider.mode.clone(),
635 poll_interval_secs: provider.poll_interval_secs,
636 poll_timeout_secs: provider.poll_timeout_secs,
637 })
638}
639
640#[cfg(test)]
645mod tests {
646 use super::*;
647
648 #[test]
649 fn test_resolve_env_var_with_env_set() {
650 std::env::set_var("ROBIT_TEST_KEY", "test-value-123");
651 assert_eq!(resolve_env_var("${ROBIT_TEST_KEY}"), "test-value-123");
652 std::env::remove_var("ROBIT_TEST_KEY");
653 }
654
655 #[test]
656 fn test_resolve_env_var_without_env() {
657 assert_eq!(
658 resolve_env_var("${ROBIT_NONEXISTENT_KEY}"),
659 "${ROBIT_NONEXISTENT_KEY}"
660 );
661 }
662
663 #[test]
664 fn test_resolve_env_var_plain_string() {
665 assert_eq!(resolve_env_var("plain-key"), "plain-key");
666 }
667
668 #[test]
669 fn test_parse_robit_config() {
670 let toml_str = r#"
671 default_model = "deepseek/deepseek-chat"
672
673 [providers.deepseek]
674 name = "DeepSeek"
675 base_url = "https://api.deepseek.com"
676 api_key = "sk-test-key"
677
678 [[providers.deepseek.models]]
679 id = "deepseek-chat"
680 name = "DeepSeek Chat"
681 context_window = 65536
682 max_output_tokens = 8192
683 temperature = 0.0
684 max_tokens = 4096
685
686 [[providers.deepseek.models]]
687 id = "deepseek-reasoner"
688 name = "DeepSeek Reasoner"
689 context_window = 65536
690 temperature = 0.6
691
692 [providers.qwen]
693 name = "通义千问"
694 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
695 api_key = "sk-qwen-key"
696
697 [[providers.qwen.models]]
698 id = "qwen-max"
699 name = "Qwen Max"
700 context_window = 32768
701
702 [app]
703 log_level = "DEBUG"
704 max_steps = 10
705 global_storage = true
706
707 [app.context]
708 max_output_lines = 500
709 reserve_ratio = 0.2
710
711 [app.retry]
712 max_retries = 3
713 "#;
714
715 let config: RobitConfig = toml::from_str(toml_str).unwrap();
716
717 assert_eq!(
719 config.default_model.as_deref(),
720 Some("deepseek/deepseek-chat")
721 );
722
723 assert_eq!(config.providers.len(), 2);
725
726 let ds = &config.providers["deepseek"];
728 assert_eq!(ds.name.as_deref(), Some("DeepSeek"));
729 assert_eq!(ds.base_url, "https://api.deepseek.com");
730 assert_eq!(ds.api_key, "sk-test-key");
731 assert_eq!(ds.models.len(), 2);
732 assert_eq!(ds.models[0].id, "deepseek-chat");
733 assert_eq!(ds.models[0].context_window, Some(65536));
734 assert_eq!(ds.models[0].temperature, Some(0.0));
735 assert_eq!(ds.models[0].max_tokens, Some(4096));
736 assert_eq!(ds.models[1].id, "deepseek-reasoner");
737 assert_eq!(ds.models[1].temperature, Some(0.6));
738
739 let qw = &config.providers["qwen"];
741 assert_eq!(qw.name.as_deref(), Some("通义千问"));
742 assert_eq!(qw.models.len(), 1);
743 assert_eq!(qw.models[0].id, "qwen-max");
744
745 let app = config.app.as_ref().unwrap();
747 assert_eq!(app.log_level.as_deref(), Some("DEBUG"));
748 assert_eq!(app.max_steps, Some(10));
749 assert_eq!(app.global_storage, Some(true));
750 assert!(app.context.is_some());
751 assert_eq!(app.context.as_ref().unwrap().max_output_lines, Some(500));
752 assert!(app.retry.is_some());
753 assert_eq!(app.retry.as_ref().unwrap().max_retries, Some(3));
754 }
755
756 #[test]
757 fn test_parse_config_minimal() {
758 let toml_str = r#"
759 [providers.default]
760 base_url = "https://api.deepseek.com"
761 api_key = "sk-test"
762
763 [[providers.default.models]]
764 id = "deepseek-chat"
765 "#;
766
767 let config: RobitConfig = toml::from_str(toml_str).unwrap();
768 assert!(config.default_model.is_none());
769 assert!(config.app.is_none());
770 assert_eq!(config.providers.len(), 1);
771 }
772
773 #[test]
774 fn test_resolve_profile_from_default_model() {
775 let config = make_test_config();
776 let resolved = resolve_profile(&config, None).unwrap();
777 assert_eq!(resolved.profile_name, "deepseek");
778 assert_eq!(resolved.model_id, "deepseek-chat");
779 assert_eq!(resolved.base_url, "https://api.deepseek.com");
780 assert_eq!(resolved.api_key, "sk-test");
781 assert_eq!(resolved.context_window, Some(65536));
782 assert_eq!(resolved.temperature, Some(0.0));
783 assert_eq!(resolved.max_tokens, Some(4096));
784 }
785
786 #[test]
787 fn test_resolve_profile_explicit_provider() {
788 let config = make_test_config();
789 let resolved = resolve_profile(&config, Some("qwen")).unwrap();
791 assert_eq!(resolved.profile_name, "qwen");
792 assert_eq!(resolved.model_id, "qwen-max");
793 assert_eq!(
794 resolved.base_url,
795 "https://dashscope.aliyuncs.com/compatible-mode/v1"
796 );
797 }
798
799 #[test]
800 fn test_resolve_profile_first_available() {
801 let toml_str = r#"
803 [providers.deepseek]
804 base_url = "https://api.deepseek.com"
805 api_key = "sk-test"
806
807 [[providers.deepseek.models]]
808 id = "deepseek-chat"
809 "#;
810 let config: RobitConfig = toml::from_str(toml_str).unwrap();
811 let resolved = resolve_profile(&config, None).unwrap();
812 assert_eq!(resolved.profile_name, "deepseek");
813 assert_eq!(resolved.model_id, "deepseek-chat");
814 }
815
816 #[test]
817 fn test_resolve_profile_not_found() {
818 let config = make_test_config();
819 let result = resolve_profile(&config, Some("nonexistent"));
820 assert!(result.is_err());
821 }
822
823 #[test]
824 fn test_resolve_profile_model_not_found() {
825 let toml_str = r#"
826 default_model = "deepseek/nonexistent-model"
827
828 [providers.deepseek]
829 base_url = "https://api.deepseek.com"
830 api_key = "sk-test"
831
832 [[providers.deepseek.models]]
833 id = "deepseek-chat"
834 "#;
835 let config: RobitConfig = toml::from_str(toml_str).unwrap();
836 let result = resolve_profile(&config, None);
837 assert!(result.is_err());
838 }
839
840 #[test]
841 fn test_resolve_profile_invalid_default_model_format() {
842 let toml_str = r#"
843 default_model = "invalid-no-slash"
844
845 [providers.deepseek]
846 base_url = "https://api.deepseek.com"
847 api_key = "sk-test"
848
849 [[providers.deepseek.models]]
850 id = "deepseek-chat"
851 "#;
852 let config: RobitConfig = toml::from_str(toml_str).unwrap();
853 let result = resolve_profile(&config, None);
854 assert!(result.is_err());
855 assert!(result
856 .unwrap_err()
857 .to_string()
858 .contains("Invalid default_model"));
859 }
860
861 #[test]
862 fn test_resolve_profile_empty_api_key() {
863 let toml_str = r#"
864 [providers.deepseek]
865 base_url = "https://api.deepseek.com"
866 api_key = ""
867
868 [[providers.deepseek.models]]
869 id = "deepseek-chat"
870 "#;
871 let config: RobitConfig = toml::from_str(toml_str).unwrap();
872 let result = resolve_profile(&config, None);
873 assert!(result.is_err());
874 }
875
876 #[test]
877 fn test_parse_enabled_skills() {
878 let toml_str = r#"
879 default_model = "deepseek/deepseek-chat"
880
881 [providers.deepseek]
882 base_url = "https://api.deepseek.com"
883 api_key = "sk-test"
884
885 [[providers.deepseek.models]]
886 id = "deepseek-chat"
887
888 [app]
889 enabled_skills = ["code-review", "refactor"]
890 "#;
891
892 let config: RobitConfig = toml::from_str(toml_str).unwrap();
893 let app = config.app.as_ref().unwrap();
894 assert!(app.enabled_skills.is_some());
895 let skills = app.enabled_skills.as_ref().unwrap();
896 assert_eq!(skills.len(), 2);
897 assert_eq!(skills[0], "code-review");
898 assert_eq!(skills[1], "refactor");
899 }
900
901 #[test]
902 fn test_parse_enabled_tools() {
903 let toml_str = r#"
904 default_model = "deepseek/deepseek-chat"
905
906 [providers.deepseek]
907 base_url = "https://api.deepseek.com"
908 api_key = "sk-test"
909
910 [[providers.deepseek.models]]
911 id = "deepseek-chat"
912
913 [app]
914 enabled_tools = ["read", "bash", "edit", "write", "grep", "find", "ls"]
915 "#;
916
917 let config: RobitConfig = toml::from_str(toml_str).unwrap();
918 let app = config.app.as_ref().unwrap();
919 assert!(app.enabled_tools.is_some());
920 let tools = app.enabled_tools.as_ref().unwrap();
921 assert_eq!(tools.len(), 7);
922 assert_eq!(tools[0], "read");
923 assert_eq!(tools[1], "bash");
924 assert_eq!(tools[2], "edit");
925 assert_eq!(tools[3], "write");
926 assert_eq!(tools[4], "grep");
927 assert_eq!(tools[5], "find");
928 assert_eq!(tools[6], "ls");
929 }
930
931 #[test]
932 fn test_parse_auto_approve() {
933 let toml_str = r#"
934 default_model = "deepseek/deepseek-chat"
935
936 [providers.deepseek]
937 base_url = "https://api.deepseek.com"
938 api_key = "sk-test"
939
940 [[providers.deepseek.models]]
941 id = "deepseek-chat"
942
943 [app]
944 auto_approve = true
945 "#;
946
947 let config: RobitConfig = toml::from_str(toml_str).unwrap();
948 let app = config.app.as_ref().unwrap();
949 assert_eq!(app.auto_approve, Some(true));
950 }
951
952 #[test]
953 fn test_parse_auto_approve_default_none() {
954 let toml_str = r#"
955 default_model = "deepseek/deepseek-chat"
956
957 [providers.deepseek]
958 base_url = "https://api.deepseek.com"
959 api_key = "sk-test"
960
961 [[providers.deepseek.models]]
962 id = "deepseek-chat"
963
964 [app]
965 "#;
966
967 let config: RobitConfig = toml::from_str(toml_str).unwrap();
968 let app = config.app.as_ref().unwrap();
969 assert_eq!(app.auto_approve, None);
970 }
971
972 fn make_test_config() -> RobitConfig {
973 let toml_str = r#"
974 default_model = "deepseek/deepseek-chat"
975
976 [providers.deepseek]
977 base_url = "https://api.deepseek.com"
978 api_key = "sk-test"
979
980 [[providers.deepseek.models]]
981 id = "deepseek-chat"
982 context_window = 65536
983 temperature = 0.0
984 max_tokens = 4096
985
986 [providers.qwen]
987 base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1"
988 api_key = "sk-qwen-test"
989
990 [[providers.qwen.models]]
991 id = "qwen-max"
992 context_window = 32768
993 "#;
994
995 toml::from_str(toml_str).unwrap()
996 }
997
998 #[test]
999 fn test_parse_channels_and_bot_sections() {
1000 let toml_str = r#"
1001 default_model = "deepseek/deepseek-chat"
1002
1003 [providers.deepseek]
1004 base_url = "https://api.deepseek.com"
1005 api_key = "sk-test"
1006
1007 [[providers.deepseek.models]]
1008 id = "deepseek-chat"
1009
1010 [channels.qq_bot]
1011 app_id = "123456789"
1012 app_secret = "secret-value"
1013
1014 [app.bot]
1015 confirm_timeout_secs = 60
1016 session_timeout_minutes = 30
1017
1018 [app.bot.confirm_keywords]
1019 approve = ["确认", "yes"]
1020 reject = ["取消", "no"]
1021 "#;
1022
1023 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1024
1025 let qq = config
1027 .channels
1028 .as_ref()
1029 .and_then(|c| c.qq_bot.as_ref())
1030 .expect("qq_bot config missing");
1031 assert_eq!(qq.app_id, "123456789");
1032 assert_eq!(qq.app_secret, "secret-value");
1033
1034 let bot = config.app.as_ref().unwrap().bot.as_ref().unwrap();
1036 assert_eq!(bot.confirm_timeout_secs, Some(60));
1037 assert_eq!(bot.session_timeout_minutes, Some(30));
1038 let kw = bot.confirm_keywords.as_ref().unwrap();
1039 assert_eq!(kw.approve.as_ref().unwrap(), &vec!["确认".to_string(), "yes".to_string()]);
1040 assert_eq!(kw.reject.as_ref().unwrap(), &vec!["取消".to_string(), "no".to_string()]);
1041 }
1042
1043 #[test]
1044 fn test_config_without_channels_still_parses() {
1045 let toml_str = r#"
1046 [providers.deepseek]
1047 base_url = "https://api.deepseek.com"
1048 api_key = "sk-test"
1049
1050 [[providers.deepseek.models]]
1051 id = "deepseek-chat"
1052 "#;
1053
1054 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1055 assert!(config.channels.is_none());
1056 assert!(config.app.is_none() || config.app.as_ref().unwrap().bot.is_none());
1057 }
1058
1059 fn make_image_test_config() -> RobitConfig {
1064 let toml_str = r#"
1065 default_image_model = "wanxiang/wan2.7-image-pro"
1066
1067 [providers.test]
1068 base_url = "https://api.test.com"
1069 api_key = "sk-test"
1070
1071 [[providers.test.models]]
1072 id = "test-model"
1073
1074 [image_providers.wanxiang]
1075 name = "通义万相"
1076 base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1077 api_key = "sk-test"
1078 protocol = "dashscope"
1079 mode = "async"
1080
1081 [[image_providers.wanxiang.models]]
1082 id = "wan2.7-image-pro"
1083 name = "万相2.7 Pro"
1084
1085 [[image_providers.wanxiang.models]]
1086 id = "wan2.7-image"
1087
1088 [image_providers.dalle]
1089 base_url = "https://api.openai.com/v1"
1090 api_key = "sk-openai"
1091
1092 [[image_providers.dalle.models]]
1093 id = "dall-e-3"
1094 "#;
1095 toml::from_str(toml_str).unwrap()
1096 }
1097
1098 #[test]
1099 fn test_parse_image_providers() {
1100 let config = make_image_test_config();
1101
1102 assert_eq!(
1103 config.default_image_model.as_deref(),
1104 Some("wanxiang/wan2.7-image-pro")
1105 );
1106 assert_eq!(config.image_providers.len(), 2);
1107
1108 let wx = &config.image_providers["wanxiang"];
1109 assert_eq!(wx.name.as_deref(), Some("通义万相"));
1110 assert_eq!(wx.base_url, "https://ws.cn-beijing.maas.aliyuncs.com");
1111 assert_eq!(wx.api_key, "sk-test");
1112 assert_eq!(wx.protocol, ImageProtocol::Dashscope);
1113 assert_eq!(wx.mode, ImageCallMode::Async);
1114 assert_eq!(wx.poll_interval_secs, 3);
1115 assert_eq!(wx.poll_timeout_secs, 300);
1116 assert_eq!(wx.models.len(), 2);
1117 assert_eq!(wx.models[0].id, "wan2.7-image-pro");
1118
1119 let dalle = &config.image_providers["dalle"];
1121 assert_eq!(dalle.protocol, ImageProtocol::Openai);
1122 assert_eq!(dalle.mode, ImageCallMode::Sync);
1123 }
1124
1125 #[test]
1126 fn test_resolve_image_provider_from_default() {
1127 let config = make_image_test_config();
1128 let resolved = resolve_image_provider(&config).unwrap();
1129 assert_eq!(resolved.provider_name, "wanxiang");
1130 assert_eq!(resolved.model_id, "wan2.7-image-pro");
1131 assert_eq!(resolved.base_url, "https://ws.cn-beijing.maas.aliyuncs.com");
1132 assert_eq!(resolved.protocol, ImageProtocol::Dashscope);
1133 assert_eq!(resolved.mode, ImageCallMode::Async);
1134 }
1135
1136 #[test]
1137 fn test_resolve_image_provider_no_default_model() {
1138 let toml_str = r#"
1141 [providers.test]
1142 base_url = "https://api.test.com"
1143 api_key = "sk-test"
1144
1145 [[providers.test.models]]
1146 id = "test-model"
1147
1148 [image_providers.wanxiang]
1149 base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1150 api_key = "sk-test"
1151
1152 [[image_providers.wanxiang.models]]
1153 id = "wan2.7-image-pro"
1154 "#;
1155 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1156 assert!(resolve_image_provider(&config).is_err());
1157 }
1158
1159 #[test]
1160 fn test_resolve_image_provider_none_configured() {
1161 let toml_str = r#"
1162 [providers.deepseek]
1163 base_url = "https://api.deepseek.com"
1164 api_key = "sk-test"
1165
1166 [[providers.deepseek.models]]
1167 id = "deepseek-chat"
1168 "#;
1169 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1170 assert!(resolve_image_provider(&config).is_err());
1171 }
1172
1173 #[test]
1174 fn test_resolve_image_provider_empty_api_key() {
1175 let toml_str = r#"
1176 default_image_model = "wanxiang/wan2.7-image-pro"
1177
1178 [providers.test]
1179 base_url = "https://api.test.com"
1180 api_key = "sk-test"
1181
1182 [[providers.test.models]]
1183 id = "test-model"
1184
1185 [image_providers.wanxiang]
1186 base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1187 api_key = ""
1188
1189 [[image_providers.wanxiang.models]]
1190 id = "wan2.7-image-pro"
1191 "#;
1192 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1193 assert!(resolve_image_provider(&config).is_err());
1194 }
1195
1196 #[test]
1197 fn test_resolve_image_provider_model_not_found() {
1198 let toml_str = r#"
1199 default_image_model = "wanxiang/nonexistent-model"
1200
1201 [providers.test]
1202 base_url = "https://api.test.com"
1203 api_key = "sk-test"
1204
1205 [[providers.test.models]]
1206 id = "test-model"
1207
1208 [image_providers.wanxiang]
1209 base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1210 api_key = "sk-test"
1211
1212 [[image_providers.wanxiang.models]]
1213 id = "wan2.7-image-pro"
1214 "#;
1215 let config: RobitConfig = toml::from_str(toml_str).unwrap();
1216 assert!(resolve_image_provider(&config).is_err());
1217 }
1218
1219 #[test]
1220 fn test_resolve_image_provider_env_var_substitution() {
1221 std::env::set_var("ROBIT_IMG_TEST_KEY", "sk-from-env");
1222 let toml_str = r#"
1223 default_image_model = "wanxiang/wan2.7-image-pro"
1224
1225 [providers.test]
1226 base_url = "https://api.test.com"
1227 api_key = "sk-test"
1228
1229 [[providers.test.models]]
1230 id = "test-model"
1231
1232 [image_providers.wanxiang]
1233 base_url = "https://ws.cn-beijing.maas.aliyuncs.com"
1234 api_key = "${ROBIT_IMG_TEST_KEY}"
1235
1236 [[image_providers.wanxiang.models]]
1237 id = "wan2.7-image-pro"
1238 "#;
1239 let mut config: RobitConfig = toml::from_str(toml_str).unwrap();
1242 for provider in config.image_providers.values_mut() {
1243 provider.api_key = resolve_env_var(&provider.api_key);
1244 }
1245 let resolved = resolve_image_provider(&config).unwrap();
1246 assert_eq!(resolved.api_key, "sk-from-env");
1247 std::env::remove_var("ROBIT_IMG_TEST_KEY");
1248 }
1249}