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