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