1use serde::{Deserialize, Serialize};
7use thiserror::Error;
8use zeroize::Zeroize;
9
10#[derive(Error, Debug)]
15#[non_exhaustive]
16pub enum ConfigError {
17 #[error("Failed to load config: {0}")]
18 LoadError(String),
19 #[error("Invalid configuration: {0}")]
20 ValidationError(String),
21 #[error("Missing required environment variable: {0}")]
22 #[allow(dead_code)]
23 MissingEnvVar(String),
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
32#[serde(rename_all = "lowercase")]
33#[non_exhaustive]
34pub enum LLMProvider {
35 #[default]
36 LiteLLM,
37 OpenRouter,
38 Ollama,
39 OpenAI,
40 Anthropic,
41 #[serde(rename = "openai-compatible")]
43 OpenAICompatible,
44 #[serde(rename = "azure")]
46 Azure,
47}
48
49#[derive(Debug, Clone, Deserialize, Default)]
55#[non_exhaustive]
56pub struct Config {
57 #[serde(default)]
59 pub llm: LLMConfig,
60
61 #[serde(default)]
63 pub llms: Vec<LLMConfig>,
64
65 #[serde(default)]
67 pub ravenfabric: RavenFabricConfig,
68
69 #[serde(default)]
71 pub security: SecurityConfig,
72
73 #[serde(default)]
75 pub runtime: RuntimeConfig,
76
77 #[serde(default)]
79 pub telemetry: TelemetryConfig,
80
81 #[serde(default)]
83 pub scheduler: SchedulerConfig,
84
85 #[serde(default)]
87 pub web_search: WebSearchConfig,
88
89 #[serde(default)]
91 pub heartbeat: crate::heartbeat::HeartbeatConfig,
92
93 #[serde(default)]
95 pub swarm: crate::swarm::SwarmConfig,
96
97 #[serde(default)]
99 pub mcp: McpConfig,
100
101 #[serde(default)]
103 pub browser: BrowserConfig,
104
105 #[serde(default)]
107 pub load: crate::load::LoadConfig,
108}
109
110#[derive(Debug, Clone, Deserialize, Default)]
128#[non_exhaustive]
129pub struct McpConfig {
130 #[serde(default)]
132 pub servers: Vec<McpServerConfig>,
133}
134
135#[derive(Debug, Clone, Deserialize)]
140#[non_exhaustive]
141pub struct McpServerConfig {
142 pub name: String,
144 #[serde(default)]
147 pub command: String,
148 #[serde(default)]
150 pub args: Vec<String>,
151 #[serde(default)]
153 pub env: std::collections::HashMap<String, String>,
154 #[serde(default)]
157 pub url: String,
158}
159
160#[derive(Debug, Clone, Deserialize)]
165#[non_exhaustive]
166pub struct WebSearchConfig {
167 #[serde(default = "default_search_endpoint")]
169 pub endpoint: String,
170
171 #[serde(default = "default_search_engine")]
173 pub engine: String,
174
175 #[serde(default = "default_search_max_results")]
177 pub max_results: usize,
178
179 #[serde(default = "default_true")]
181 pub fetch_content: bool,
182}
183
184impl Default for WebSearchConfig {
185 fn default() -> Self {
186 Self {
187 endpoint: default_search_endpoint(),
188 engine: default_search_engine(),
189 max_results: default_search_max_results(),
190 fetch_content: default_true(),
191 }
192 }
193}
194
195#[derive(Debug, Clone, Deserialize)]
203#[non_exhaustive]
204pub struct BrowserConfig {
205 #[serde(default = "default_browser_cdp_url")]
207 pub cdp_url: String,
208
209 #[serde(default = "default_browser_timeout")]
211 pub request_timeout: u64,
212}
213
214impl Default for BrowserConfig {
215 fn default() -> Self {
216 Self {
217 cdp_url: default_browser_cdp_url(),
218 request_timeout: default_browser_timeout(),
219 }
220 }
221}
222
223fn default_browser_cdp_url() -> String {
224 "http://127.0.0.1:9222".to_string()
225}
226
227fn default_browser_timeout() -> u64 {
228 30000
229}
230
231fn default_search_endpoint() -> String {
232 "https://searx.be".to_string()
233}
234
235fn default_search_engine() -> String {
236 "duckduckgo".to_string()
237}
238
239fn default_search_max_results() -> usize {
240 5
241}
242
243fn default_otel_disabled() -> bool {
244 true
245}
246
247#[derive(Debug, Clone, Deserialize, Default)]
252#[non_exhaustive]
253pub struct SchedulerConfig {
254 #[serde(default)]
256 pub triggers: Vec<crate::scheduler::TriggerConfig>,
257}
258
259#[derive(Debug, Clone, Deserialize, Default)]
264#[non_exhaustive]
265pub struct TelemetryConfig {
266 #[serde(default)]
268 pub otel_endpoint: Option<String>,
269
270 #[serde(default)]
272 pub otel_service_name: Option<String>,
273
274 #[serde(default = "default_otel_disabled")]
276 pub otel_disabled: bool,
277}
278
279#[derive(Debug, Clone, Deserialize)]
285#[non_exhaustive]
286pub struct LLMConfig {
287 #[serde(default)]
289 pub provider: LLMProvider,
290
291 #[serde(default)]
293 pub endpoint: String,
294
295 #[serde(default = "default_model")]
297 pub model: String,
298
299 #[serde(default)]
301 pub api_key: Option<String>,
302
303 #[serde(default = "default_timeout")]
305 pub timeout_secs: u64,
306
307 #[serde(default = "default_system_prompt")]
309 pub system_prompt: String,
310
311 #[serde(default)]
313 pub token_budget: Option<u32>,
314
315 #[serde(default = "default_retry_max")]
317 pub retry_max: u32,
318
319 #[serde(default = "default_retry_base_delay")]
321 pub retry_base_delay_ms: u64,
322
323 #[serde(default = "default_retry_max_delay")]
325 pub retry_max_delay_ms: u64,
326}
327
328pub fn default_retry_max() -> u32 {
329 3
330}
331pub fn default_retry_base_delay() -> u64 {
332 100
333}
334pub fn default_retry_max_delay() -> u64 {
335 10000
336}
337
338pub fn default_system_prompt() -> String {
339 "You are RavenClaws, a lightweight autonomous agent. \
340 Be concise, efficient, and secure. Always validate inputs and outputs. \
341 When you have completed the task, prefix your final answer with FINAL: \
342 so the system knows the task is done."
343 .to_string()
344}
345
346#[derive(Debug, Clone, Deserialize)]
351#[non_exhaustive]
352pub struct RavenFabricConfig {
353 #[serde(default)]
355 pub endpoint: Option<String>,
356
357 #[serde(default)]
359 pub agent_id: Option<String>,
360
361 #[serde(default = "default_true")]
363 pub remote_exec: bool,
364
365 #[serde(default)]
367 #[allow(dead_code)]
368 pub allowed_hosts: Vec<String>,
369}
370
371impl Default for RavenFabricConfig {
372 fn default() -> Self {
373 Self {
374 endpoint: None,
375 agent_id: None,
376 remote_exec: default_true(),
377 allowed_hosts: Vec::new(),
378 }
379 }
380}
381
382#[derive(Debug, Clone, Deserialize)]
387#[non_exhaustive]
388pub struct SecurityConfig {
389 #[serde(default = "default_true")]
391 pub require_tls: bool,
392
393 #[serde(default = "default_token_lifetime")]
397 pub token_lifetime_secs: u64,
398
399 #[serde(default = "default_true")]
401 #[allow(dead_code)]
402 pub audit_log: bool,
403
404 #[serde(default = "default_true")]
408 #[allow(dead_code)]
409 pub prompt_injection_protection: bool,
410}
411
412impl Default for SecurityConfig {
413 fn default() -> Self {
414 Self {
415 require_tls: default_true(),
416 token_lifetime_secs: default_token_lifetime(),
417 audit_log: default_true(),
418 prompt_injection_protection: default_true(),
419 }
420 }
421}
422
423#[derive(Debug, Clone, Deserialize)]
428#[non_exhaustive]
429pub struct RuntimeConfig {
430 #[serde(default = "default_workdir")]
432 #[allow(dead_code)]
433 pub workdir: String,
434
435 #[serde(default = "default_max_agents")]
437 #[allow(dead_code)]
438 pub max_agents: usize,
439
440 #[serde(default = "default_health_interval")]
442 #[allow(dead_code)]
443 pub health_interval_secs: u64,
444
445 #[serde(default)]
447 pub host: Option<String>,
448
449 #[serde(default = "default_server_port")]
451 pub port: u16,
452
453 #[serde(default)]
458 #[allow(dead_code)]
459 pub checkpoint_dir: Option<String>,
460
461 #[serde(default = "default_checkpoint_interval")]
465 #[allow(dead_code)]
466 pub checkpoint_interval: usize,
467}
468
469fn default_checkpoint_interval() -> usize {
470 1
471}
472
473impl Default for RuntimeConfig {
474 fn default() -> Self {
475 Self {
476 workdir: default_workdir(),
477 max_agents: default_max_agents(),
478 health_interval_secs: default_health_interval(),
479 host: None,
480 port: default_server_port(),
481 checkpoint_dir: None,
482 checkpoint_interval: 1,
483 }
484 }
485}
486
487fn default_model() -> String {
488 "gpt-4o-mini".to_string()
489}
490
491fn default_timeout() -> u64 {
492 30
493}
494
495fn default_true() -> bool {
496 true
497}
498
499fn default_token_lifetime() -> u64 {
500 3600
501}
502
503fn default_workdir() -> String {
504 "/tmp/ravenclaws-workdir".to_string()
505}
506
507fn default_max_agents() -> usize {
508 10
509}
510
511fn default_health_interval() -> u64 {
512 60
513}
514
515fn default_server_port() -> u16 {
516 8080
517}
518
519impl Default for LLMConfig {
520 fn default() -> Self {
521 Self {
522 provider: LLMProvider::LiteLLM,
523 endpoint: String::new(),
524 model: default_model(),
525 api_key: None,
526 timeout_secs: default_timeout(),
527 system_prompt: default_system_prompt(),
528 token_budget: None,
529 retry_max: default_retry_max(),
530 retry_base_delay_ms: default_retry_base_delay(),
531 retry_max_delay_ms: default_retry_max_delay(),
532 }
533 }
534}
535
536impl Drop for LLMConfig {
538 fn drop(&mut self) {
539 if let Some(ref mut key) = self.api_key {
540 key.zeroize();
541 }
542 }
543}
544
545impl Config {
546 pub fn load(config_path: Option<&str>) -> Result<Self, ConfigError> {
548 dotenvy::dotenv().ok();
550
551 let mut config_builder = config::Config::builder();
552
553 if let Some(path) = config_path {
555 config_builder =
556 config_builder.add_source(config::File::with_name(path).required(false));
557 }
558
559 let ravenclaws_llms = std::env::var("RAVENCLAWS__LLMS").ok();
564 if ravenclaws_llms.is_some() {
565 std::env::remove_var("RAVENCLAWS__LLMS");
566 }
567
568 config_builder = config_builder
569 .add_source(config::Environment::with_prefix("RAVENCLAW").separator("__"));
570
571 let config = config_builder
572 .build()
573 .map_err(|e| ConfigError::LoadError(e.to_string()))?;
574
575 let mut cfg: Config = config
576 .try_deserialize()
577 .map_err(|e| ConfigError::LoadError(e.to_string()))?;
578
579 if let Some(ref val) = ravenclaws_llms {
581 std::env::set_var("RAVENCLAWS__LLMS", val);
582 }
583
584 if let Ok(key) = std::env::var("LITELLM_API_KEY") {
587 cfg.llm.api_key = Some(key);
588 }
589 if let Ok(provider) = std::env::var("RAVENCLAWS__LLM__PROVIDER") {
590 cfg.llm.provider = match provider.to_lowercase().as_str() {
591 "openrouter" => LLMProvider::OpenRouter,
592 "ollama" => LLMProvider::Ollama,
593 "openai" => LLMProvider::OpenAI,
594 "anthropic" => LLMProvider::Anthropic,
595 _ => LLMProvider::LiteLLM,
596 };
597 }
598 if let Ok(endpoint) = std::env::var("RAVENCLAWS__LLM__ENDPOINT") {
599 cfg.llm.endpoint = endpoint;
600 }
601 if let Ok(model) = std::env::var("RAVENCLAWS__LLM__MODEL") {
602 cfg.llm.model = model;
603 }
604
605 if let Ok(keys) = std::env::var("RAVENCLAWS__LLMS") {
609 if let Ok(llms) = serde_json::from_str::<Vec<LLMConfig>>(&keys) {
611 cfg.llms = llms;
612 }
613 }
614
615 if let Ok(endpoint) = std::env::var("RAVENFABRIC_ENDPOINT") {
616 cfg.ravenfabric.endpoint = Some(endpoint);
617 }
618
619 cfg.validate()?;
621
622 Ok(cfg)
623 }
624
625 fn validate(&self) -> Result<(), ConfigError> {
627 if !self.llm.endpoint.is_empty() {
629 self.validate_llm_config(&self.llm)?;
630 }
631
632 for (i, llm) in self.llms.iter().enumerate() {
634 self.validate_llm_config(llm)
635 .map_err(|e| ConfigError::ValidationError(format!("LLM[{}]: {}", i, e)))?;
636 }
637
638 if self.llm.endpoint.is_empty() && self.llms.is_empty() {
640 return Err(ConfigError::ValidationError(
641 "At least one LLM provider must be configured (llm or llms)".to_string(),
642 ));
643 }
644
645 Ok(())
646 }
647
648 fn validate_llm_config(&self, llm: &LLMConfig) -> Result<(), ConfigError> {
649 if llm.endpoint.is_empty()
650 && llm.provider != LLMProvider::OpenAI
651 && llm.provider != LLMProvider::OpenRouter
652 && llm.provider != LLMProvider::Anthropic
653 {
654 return Err(ConfigError::ValidationError(
656 "LLM endpoint is required for this provider".to_string(),
657 ));
658 }
659
660 if self.security.require_tls
661 && !llm.endpoint.is_empty()
662 && !llm.endpoint.starts_with("https://")
663 && !llm.endpoint.contains("localhost")
664 && !llm.endpoint.contains("127.0.0.1")
665 && !llm.endpoint.contains("0.0.0.0")
666 {
667 return Err(ConfigError::ValidationError(
668 "TLS required but endpoint is not HTTPS".to_string(),
669 ));
670 }
671
672 Ok(())
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679 use serial_test::serial;
680
681 #[test]
682 #[serial(env_test)]
683 fn test_default_config() {
684 std::env::set_var("LITELLM_API_KEY", "test-key");
685 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
686
687 let config = Config::load(None).unwrap();
688 assert_eq!(config.llm.model, "gpt-4o-mini");
689 assert_eq!(config.llm.timeout_secs, 30);
690 assert!(config.security.require_tls);
694
695 std::env::remove_var("LITELLM_API_KEY");
697 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
698 }
699
700 #[test]
701 fn test_llm_provider_default() {
702 assert_eq!(LLMProvider::default(), LLMProvider::LiteLLM);
703 }
704
705 #[test]
706 fn test_llm_provider_serde() {
707 let json = r#""litellm""#;
708 let provider: LLMProvider = serde_json::from_str(json).unwrap();
709 assert_eq!(provider, LLMProvider::LiteLLM);
710
711 let json = r#""openai""#;
712 let provider: LLMProvider = serde_json::from_str(json).unwrap();
713 assert_eq!(provider, LLMProvider::OpenAI);
714
715 let json = r#""ollama""#;
716 let provider: LLMProvider = serde_json::from_str(json).unwrap();
717 assert_eq!(provider, LLMProvider::Ollama);
718
719 let json = r#""openrouter""#;
720 let provider: LLMProvider = serde_json::from_str(json).unwrap();
721 assert_eq!(provider, LLMProvider::OpenRouter);
722 }
723
724 #[test]
725 fn test_llm_config_default() {
726 let config = LLMConfig::default();
727 assert_eq!(config.provider, LLMProvider::LiteLLM);
728 assert_eq!(config.model, "gpt-4o-mini");
729 assert_eq!(config.timeout_secs, 30);
730 assert!(config.api_key.is_none());
731 assert!(config.endpoint.is_empty());
732 assert!(config.system_prompt.contains("RavenClaws"));
733 }
734
735 #[test]
736 fn test_system_prompt_custom() {
737 let mut config = LLMConfig::default();
738 config.system_prompt = "You are a helpful coding assistant.".to_string();
739 assert_eq!(config.system_prompt, "You are a helpful coding assistant.");
740 }
741
742 #[test]
743 fn test_validate_missing_endpoint() {
744 let config = Config {
745 llm: LLMConfig {
746 provider: LLMProvider::LiteLLM,
747 endpoint: String::new(),
748 model: "gpt-4o-mini".to_string(),
749 api_key: None,
750 timeout_secs: 30,
751 system_prompt: default_system_prompt(),
752 token_budget: None,
753 retry_max: 3,
754 retry_base_delay_ms: 100,
755 retry_max_delay_ms: 10000,
756 },
757 llms: vec![],
758 ravenfabric: RavenFabricConfig::default(),
759 security: SecurityConfig {
760 require_tls: false,
761 token_lifetime_secs: 3600,
762 audit_log: false,
763 prompt_injection_protection: false,
764 },
765 runtime: RuntimeConfig::default(),
766 telemetry: TelemetryConfig::default(),
767 scheduler: SchedulerConfig::default(),
768 web_search: WebSearchConfig::default(),
769 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
770 mcp: McpConfig::default(),
771 swarm: crate::swarm::SwarmConfig::default(),
772 browser: BrowserConfig::default(),
773 load: crate::load::LoadConfig::default(),
774 };
775
776 let result = config.validate();
777 assert!(result.is_err());
778 assert!(result
779 .unwrap_err()
780 .to_string()
781 .contains("At least one LLM provider"));
782 }
783
784 #[test]
785 fn test_validate_tls_required() {
786 let config = Config {
787 llm: LLMConfig {
788 provider: LLMProvider::LiteLLM,
789 endpoint: "http://example.com:4000".to_string(),
790 model: "gpt-4o-mini".to_string(),
791 api_key: Some("key".to_string()),
792 timeout_secs: 30,
793 system_prompt: default_system_prompt(),
794 token_budget: None,
795 retry_max: 3,
796 retry_base_delay_ms: 100,
797 retry_max_delay_ms: 10000,
798 },
799 llms: vec![],
800 ravenfabric: RavenFabricConfig::default(),
801 security: SecurityConfig {
802 require_tls: true,
803 token_lifetime_secs: 3600,
804 audit_log: false,
805 prompt_injection_protection: false,
806 },
807 runtime: RuntimeConfig::default(),
808 telemetry: TelemetryConfig::default(),
809 scheduler: SchedulerConfig::default(),
810 web_search: WebSearchConfig::default(),
811 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
812 mcp: McpConfig::default(),
813 swarm: crate::swarm::SwarmConfig::default(),
814 browser: BrowserConfig::default(),
815 load: crate::load::LoadConfig::default(),
816 };
817
818 let result = config.validate();
819 assert!(result.is_err());
820 let err = result.unwrap_err().to_string();
821 assert!(err.contains("TLS required"));
822 }
823
824 #[test]
825 fn test_validate_tls_localhost_allowed() {
826 let config = Config {
827 llm: LLMConfig {
828 provider: LLMProvider::LiteLLM,
829 endpoint: "http://localhost:4000".to_string(),
830 model: "gpt-4o-mini".to_string(),
831 api_key: Some("key".to_string()),
832 timeout_secs: 30,
833 system_prompt: default_system_prompt(),
834 token_budget: None,
835 retry_max: 3,
836 retry_base_delay_ms: 100,
837 retry_max_delay_ms: 10000,
838 },
839 llms: vec![],
840 ravenfabric: RavenFabricConfig::default(),
841 security: SecurityConfig {
842 require_tls: true,
843 token_lifetime_secs: 3600,
844 audit_log: false,
845 prompt_injection_protection: false,
846 },
847 runtime: RuntimeConfig::default(),
848 telemetry: TelemetryConfig::default(),
849 scheduler: SchedulerConfig::default(),
850 web_search: WebSearchConfig::default(),
851 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
852 mcp: McpConfig::default(),
853 swarm: crate::swarm::SwarmConfig::default(),
854 browser: BrowserConfig::default(),
855 load: crate::load::LoadConfig::default(),
856 };
857
858 let result = config.validate();
859 assert!(result.is_ok());
860 }
861
862 #[test]
863 fn test_validate_openai_no_endpoint_needed() {
864 let config = Config {
865 llm: LLMConfig {
866 provider: LLMProvider::OpenAI,
867 endpoint: String::new(),
868 model: "gpt-4o".to_string(),
869 api_key: Some("sk-key".to_string()),
870 timeout_secs: 30,
871 system_prompt: default_system_prompt(),
872 token_budget: None,
873 retry_max: 3,
874 retry_base_delay_ms: 100,
875 retry_max_delay_ms: 10000,
876 },
877 llms: vec![],
878 ravenfabric: RavenFabricConfig::default(),
879 security: SecurityConfig {
880 require_tls: false,
881 token_lifetime_secs: 3600,
882 audit_log: false,
883 prompt_injection_protection: false,
884 },
885 runtime: RuntimeConfig::default(),
886 telemetry: TelemetryConfig::default(),
887 scheduler: SchedulerConfig::default(),
888 web_search: WebSearchConfig::default(),
889 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
890 mcp: McpConfig::default(),
891 swarm: crate::swarm::SwarmConfig::default(),
892 browser: BrowserConfig::default(),
893 load: crate::load::LoadConfig::default(),
894 };
895
896 let result = config.validate();
901 assert!(result.is_err()); }
903
904 #[test]
905 fn test_validate_multi_provider() {
906 let config = Config {
907 llm: LLMConfig::default(),
908 llms: vec![LLMConfig {
909 provider: LLMProvider::Ollama,
910 endpoint: "http://localhost:11434".to_string(),
911 model: "llama3.1".to_string(),
912 api_key: None,
913 timeout_secs: 60,
914 system_prompt: default_system_prompt(),
915 token_budget: None,
916 retry_max: 3,
917 retry_base_delay_ms: 100,
918 retry_max_delay_ms: 10000,
919 }],
920 ravenfabric: RavenFabricConfig::default(),
921 security: SecurityConfig {
922 require_tls: false,
923 token_lifetime_secs: 3600,
924 audit_log: false,
925 prompt_injection_protection: false,
926 },
927 runtime: RuntimeConfig::default(),
928 telemetry: TelemetryConfig::default(),
929 scheduler: SchedulerConfig::default(),
930 web_search: WebSearchConfig::default(),
931 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
932 mcp: McpConfig::default(),
933 swarm: crate::swarm::SwarmConfig::default(),
934 browser: BrowserConfig::default(),
935 load: crate::load::LoadConfig::default(),
936 };
937
938 let result = config.validate();
939 assert!(result.is_ok());
940 }
941
942 #[test]
943 fn test_ravenfabric_config_default() {
944 let config = RavenFabricConfig::default();
945 assert!(config.endpoint.is_none());
946 assert!(config.agent_id.is_none());
947 assert!(config.remote_exec);
948 assert!(config.allowed_hosts.is_empty());
949 }
950
951 #[test]
952 fn test_security_config_default() {
953 let config = SecurityConfig::default();
954 assert!(config.require_tls);
955 assert_eq!(config.token_lifetime_secs, 3600);
956 assert!(config.audit_log);
957 }
958
959 #[test]
960 fn test_runtime_config_default() {
961 let config = RuntimeConfig::default();
962 assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
963 assert_eq!(config.max_agents, 10);
964 assert_eq!(config.health_interval_secs, 60);
965 }
966
967 #[test]
968 fn test_config_error_display() {
969 let err = ConfigError::LoadError("file not found".to_string());
970 assert_eq!(format!("{}", err), "Failed to load config: file not found");
971
972 let err = ConfigError::ValidationError("bad field".to_string());
973 assert_eq!(format!("{}", err), "Invalid configuration: bad field");
974
975 let err = ConfigError::MissingEnvVar("API_KEY".to_string());
976 assert_eq!(
977 format!("{}", err),
978 "Missing required environment variable: API_KEY"
979 );
980 }
981
982 #[test]
983 fn test_validate_openrouter_no_endpoint_needed() {
984 let config = Config {
985 llm: LLMConfig {
986 provider: LLMProvider::OpenRouter,
987 endpoint: String::new(),
988 model: "anthropic/claude-sonnet-4-20250514".to_string(),
989 api_key: Some("or-key".to_string()),
990 timeout_secs: 30,
991 system_prompt: default_system_prompt(),
992 token_budget: None,
993 retry_max: 3,
994 retry_base_delay_ms: 100,
995 retry_max_delay_ms: 10000,
996 },
997 llms: vec![],
998 ravenfabric: RavenFabricConfig::default(),
999 security: SecurityConfig {
1000 require_tls: false,
1001 token_lifetime_secs: 3600,
1002 audit_log: false,
1003 prompt_injection_protection: false,
1004 },
1005 runtime: RuntimeConfig::default(),
1006 telemetry: TelemetryConfig::default(),
1007 scheduler: SchedulerConfig::default(),
1008 web_search: WebSearchConfig::default(),
1009 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1010 mcp: McpConfig::default(),
1011 swarm: crate::swarm::SwarmConfig::default(),
1012 browser: BrowserConfig::default(),
1013 load: crate::load::LoadConfig::default(),
1014 };
1015
1016 let result = config.validate();
1019 assert!(result.is_err());
1020 }
1021
1022 #[test]
1023 fn test_validate_ollama_needs_endpoint() {
1024 let config = Config {
1025 llm: LLMConfig {
1026 provider: LLMProvider::Ollama,
1027 endpoint: String::new(),
1028 model: "llama3.1".to_string(),
1029 api_key: None,
1030 timeout_secs: 30,
1031 system_prompt: default_system_prompt(),
1032 token_budget: None,
1033 retry_max: 3,
1034 retry_base_delay_ms: 100,
1035 retry_max_delay_ms: 10000,
1036 },
1037 llms: vec![],
1038 ravenfabric: RavenFabricConfig::default(),
1039 security: SecurityConfig {
1040 require_tls: false,
1041 token_lifetime_secs: 3600,
1042 audit_log: false,
1043 prompt_injection_protection: false,
1044 },
1045 runtime: RuntimeConfig::default(),
1046 telemetry: TelemetryConfig::default(),
1047 scheduler: SchedulerConfig::default(),
1048 web_search: WebSearchConfig::default(),
1049 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1050 mcp: McpConfig::default(),
1051 swarm: crate::swarm::SwarmConfig::default(),
1052 browser: BrowserConfig::default(),
1053 load: crate::load::LoadConfig::default(),
1054 };
1055
1056 let result = config.validate();
1057 assert!(result.is_err());
1058 let err = result.unwrap_err().to_string();
1059 assert!(err.contains("At least one LLM provider"));
1060 }
1061
1062 #[test]
1063 fn test_validate_tls_localhost_ip_allowed() {
1064 let config = Config {
1065 llm: LLMConfig {
1066 provider: LLMProvider::LiteLLM,
1067 endpoint: "http://127.0.0.1:4000".to_string(),
1068 model: "gpt-4o-mini".to_string(),
1069 api_key: Some("key".to_string()),
1070 timeout_secs: 30,
1071 system_prompt: default_system_prompt(),
1072 token_budget: None,
1073 retry_max: 3,
1074 retry_base_delay_ms: 100,
1075 retry_max_delay_ms: 10000,
1076 },
1077 llms: vec![],
1078 ravenfabric: RavenFabricConfig::default(),
1079 security: SecurityConfig {
1080 require_tls: true,
1081 token_lifetime_secs: 3600,
1082 audit_log: false,
1083 prompt_injection_protection: false,
1084 },
1085 runtime: RuntimeConfig::default(),
1086 telemetry: TelemetryConfig::default(),
1087 scheduler: SchedulerConfig::default(),
1088 web_search: WebSearchConfig::default(),
1089 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1090 mcp: McpConfig::default(),
1091 swarm: crate::swarm::SwarmConfig::default(),
1092 browser: BrowserConfig::default(),
1093 load: crate::load::LoadConfig::default(),
1094 };
1095
1096 let result = config.validate();
1097 assert!(result.is_ok());
1098 }
1099
1100 #[test]
1101 fn test_validate_tls_wildcard_allowed() {
1102 let config = Config {
1103 llm: LLMConfig {
1104 provider: LLMProvider::LiteLLM,
1105 endpoint: "http://0.0.0.0:4000".to_string(),
1106 model: "gpt-4o-mini".to_string(),
1107 api_key: Some("key".to_string()),
1108 timeout_secs: 30,
1109 system_prompt: default_system_prompt(),
1110 token_budget: None,
1111 retry_max: 3,
1112 retry_base_delay_ms: 100,
1113 retry_max_delay_ms: 10000,
1114 },
1115 llms: vec![],
1116 ravenfabric: RavenFabricConfig::default(),
1117 security: SecurityConfig {
1118 require_tls: true,
1119 token_lifetime_secs: 3600,
1120 audit_log: false,
1121 prompt_injection_protection: false,
1122 },
1123 runtime: RuntimeConfig::default(),
1124 telemetry: TelemetryConfig::default(),
1125 scheduler: SchedulerConfig::default(),
1126 web_search: WebSearchConfig::default(),
1127 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1128 mcp: McpConfig::default(),
1129 swarm: crate::swarm::SwarmConfig::default(),
1130 browser: BrowserConfig::default(),
1131 load: crate::load::LoadConfig::default(),
1132 };
1133
1134 let result = config.validate();
1135 assert!(result.is_ok());
1136 }
1137
1138 #[test]
1139 fn test_validate_multi_provider_with_tls() {
1140 let config = Config {
1141 llm: LLMConfig::default(),
1142 llms: vec![
1143 LLMConfig {
1144 provider: LLMProvider::Ollama,
1145 endpoint: "http://localhost:11434".to_string(),
1146 model: "llama3.1".to_string(),
1147 api_key: None,
1148 timeout_secs: 60,
1149 system_prompt: default_system_prompt(),
1150 token_budget: None,
1151 retry_max: 3,
1152 retry_base_delay_ms: 100,
1153 retry_max_delay_ms: 10000,
1154 },
1155 LLMConfig {
1156 provider: LLMProvider::LiteLLM,
1157 endpoint: "https://litellm.example.com:4000".to_string(),
1158 model: "gpt-4o-mini".to_string(),
1159 api_key: Some("key".to_string()),
1160 timeout_secs: 30,
1161 system_prompt: default_system_prompt(),
1162 token_budget: None,
1163 retry_max: 3,
1164 retry_base_delay_ms: 100,
1165 retry_max_delay_ms: 10000,
1166 },
1167 ],
1168 ravenfabric: RavenFabricConfig::default(),
1169 security: SecurityConfig {
1170 require_tls: true,
1171 token_lifetime_secs: 3600,
1172 audit_log: false,
1173 prompt_injection_protection: false,
1174 },
1175 runtime: RuntimeConfig::default(),
1176 telemetry: TelemetryConfig::default(),
1177 scheduler: SchedulerConfig::default(),
1178 web_search: WebSearchConfig::default(),
1179 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1180 mcp: McpConfig::default(),
1181 swarm: crate::swarm::SwarmConfig::default(),
1182 browser: BrowserConfig::default(),
1183 load: crate::load::LoadConfig::default(),
1184 };
1185
1186 let result = config.validate();
1187 assert!(result.is_ok());
1188 }
1189
1190 #[test]
1191 fn test_validate_multi_provider_tls_failure() {
1192 let config = Config {
1193 llm: LLMConfig::default(),
1194 llms: vec![LLMConfig {
1195 provider: LLMProvider::LiteLLM,
1196 endpoint: "http://example.com:4000".to_string(),
1197 model: "gpt-4o-mini".to_string(),
1198 api_key: Some("key".to_string()),
1199 timeout_secs: 30,
1200 system_prompt: default_system_prompt(),
1201 token_budget: None,
1202 retry_max: 3,
1203 retry_base_delay_ms: 100,
1204 retry_max_delay_ms: 10000,
1205 }],
1206 ravenfabric: RavenFabricConfig::default(),
1207 security: SecurityConfig {
1208 require_tls: true,
1209 token_lifetime_secs: 3600,
1210 audit_log: false,
1211 prompt_injection_protection: false,
1212 },
1213 runtime: RuntimeConfig::default(),
1214 telemetry: TelemetryConfig::default(),
1215 scheduler: SchedulerConfig::default(),
1216 web_search: WebSearchConfig::default(),
1217 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1218 mcp: McpConfig::default(),
1219 swarm: crate::swarm::SwarmConfig::default(),
1220 browser: BrowserConfig::default(),
1221 load: crate::load::LoadConfig::default(),
1222 };
1223
1224 let result = config.validate();
1225 assert!(result.is_err());
1226 let err = result.unwrap_err().to_string();
1227 assert!(err.contains("TLS required"));
1228 }
1229
1230 #[test]
1231 fn test_ravenfabric_config_custom() {
1232 let config = RavenFabricConfig {
1233 endpoint: Some("https://fabric.example.com:8443".to_string()),
1234 agent_id: Some("agent-01".to_string()),
1235 remote_exec: false,
1236 allowed_hosts: vec!["10.0.0.0/8".to_string()],
1237 };
1238 assert_eq!(config.endpoint.unwrap(), "https://fabric.example.com:8443");
1239 assert_eq!(config.agent_id.unwrap(), "agent-01");
1240 assert!(!config.remote_exec);
1241 assert_eq!(config.allowed_hosts.len(), 1);
1242 }
1243
1244 #[test]
1245 fn test_security_config_custom() {
1246 let config = SecurityConfig {
1247 require_tls: false,
1248 token_lifetime_secs: 7200,
1249 audit_log: false,
1250 prompt_injection_protection: false,
1251 };
1252 assert!(!config.require_tls);
1253 assert_eq!(config.token_lifetime_secs, 7200);
1254 assert!(!config.audit_log);
1255 }
1256
1257 #[test]
1258 fn test_runtime_config_custom() {
1259 let config = RuntimeConfig {
1260 workdir: "/data".to_string(),
1261 max_agents: 5,
1262 health_interval_secs: 120,
1263 host: Some("127.0.0.1".to_string()),
1264 port: 9090,
1265 checkpoint_dir: None,
1266 checkpoint_interval: 1,
1267 };
1268 assert_eq!(config.workdir, "/data");
1269 assert_eq!(config.max_agents, 5);
1270 assert_eq!(config.health_interval_secs, 120);
1271 assert_eq!(config.host, Some("127.0.0.1".to_string()));
1272 assert_eq!(config.port, 9090);
1273 }
1274
1275 #[test]
1276 fn test_llm_config_custom() {
1277 let config = LLMConfig {
1278 provider: LLMProvider::OpenAI,
1279 endpoint: String::new(),
1280 model: "gpt-4o".to_string(),
1281 api_key: Some("sk-test".to_string()),
1282 timeout_secs: 120,
1283 system_prompt: default_system_prompt(),
1284 token_budget: None,
1285 retry_max: 3,
1286 retry_base_delay_ms: 100,
1287 retry_max_delay_ms: 10000,
1288 };
1289 assert_eq!(config.provider, LLMProvider::OpenAI);
1290 assert_eq!(config.model, "gpt-4o");
1291 assert_eq!(config.timeout_secs, 120);
1292 assert_eq!(config.api_key.clone().unwrap(), "sk-test");
1293 }
1294
1295 #[test]
1296 fn test_llm_provider_serde_invalid_fallback() {
1297 let json = r#""unknown_provider""#;
1299 let provider: LLMProvider = serde_json::from_str(json).unwrap_or_default();
1300 assert_eq!(provider, LLMProvider::LiteLLM);
1301 }
1302
1303 #[test]
1304 #[serial(env_test)]
1305 fn test_config_load_with_env_overrides() {
1306 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1308 std::env::set_var("RAVENCLAWS__LLM__MODEL", "gpt-4o");
1309 std::env::set_var("LITELLM_API_KEY", "env-key");
1310
1311 let config = Config::load(None).unwrap();
1312 assert_eq!(config.llm.endpoint, "http://localhost:4000");
1313 assert_eq!(config.llm.model, "gpt-4o");
1314 assert_eq!(config.llm.api_key.clone().unwrap(), "env-key");
1315
1316 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1318 std::env::remove_var("RAVENCLAWS__LLM__MODEL");
1319 std::env::remove_var("LITELLM_API_KEY");
1320 }
1321
1322 #[test]
1323 #[serial(env_test)]
1324 fn test_config_load_with_llms_json_env() {
1325 let llms_json = r#"[{"provider":"ollama","endpoint":"http://localhost:11434","model":"llama3.1","timeout_secs":60}]"#;
1326 std::env::set_var("RAVENCLAWS__LLMS", llms_json);
1327 std::env::set_var("LITELLM_API_KEY", "dummy");
1328 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1329
1330 let config = Config::load(None).unwrap();
1331 assert_eq!(config.llms.len(), 1);
1332 assert_eq!(config.llms[0].provider, LLMProvider::Ollama);
1333 assert_eq!(config.llms[0].endpoint, "http://localhost:11434");
1334 assert_eq!(config.llms[0].model, "llama3.1");
1335 assert_eq!(config.llms[0].timeout_secs, 60);
1336
1337 std::env::remove_var("RAVENCLAWS__LLMS");
1339 std::env::remove_var("LITELLM_API_KEY");
1340 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1341 }
1342
1343 #[test]
1344 #[serial(env_test)]
1345 fn test_config_load_with_ravenfabric_env() {
1346 std::env::set_var("RAVENFABRIC_ENDPOINT", "https://fabric.example.com:8443");
1347 std::env::set_var("LITELLM_API_KEY", "dummy");
1348 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1349
1350 let config = Config::load(None).unwrap();
1351 assert_eq!(
1352 config.ravenfabric.endpoint.unwrap(),
1353 "https://fabric.example.com:8443"
1354 );
1355
1356 std::env::remove_var("RAVENFABRIC_ENDPOINT");
1358 std::env::remove_var("LITELLM_API_KEY");
1359 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1360 }
1361
1362 #[test]
1363 #[serial(env_test)]
1364 fn test_config_load_with_provider_env() {
1365 std::env::set_var("RAVENCLAWS__LLM__PROVIDER", "openai");
1367 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "https://api.openai.com");
1368 std::env::set_var("LITELLM_API_KEY", "dummy");
1369
1370 let config = Config::load(None).unwrap();
1371 assert_eq!(config.llm.provider, LLMProvider::OpenAI);
1372
1373 std::env::remove_var("RAVENCLAWS__LLM__PROVIDER");
1375 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1376 std::env::remove_var("LITELLM_API_KEY");
1377 }
1378
1379 #[test]
1380 fn test_config_load_with_provider_env_fallback() {
1381 let mapped = match "unknown" {
1384 "openrouter" => LLMProvider::OpenRouter,
1385 "ollama" => LLMProvider::Ollama,
1386 "openai" => LLMProvider::OpenAI,
1387 _ => LLMProvider::LiteLLM,
1388 };
1389 assert_eq!(mapped, LLMProvider::LiteLLM);
1390
1391 let mapped = match "" {
1393 "openrouter" => LLMProvider::OpenRouter,
1394 "ollama" => LLMProvider::Ollama,
1395 "openai" => LLMProvider::OpenAI,
1396 _ => LLMProvider::LiteLLM,
1397 };
1398 assert_eq!(mapped, LLMProvider::LiteLLM);
1399 }
1400
1401 #[test]
1402 fn test_validate_openai_with_endpoint() {
1403 let config = Config {
1404 llm: LLMConfig {
1405 provider: LLMProvider::OpenAI,
1406 endpoint: "https://api.openai.com".to_string(),
1407 model: "gpt-4o".to_string(),
1408 api_key: Some("sk-key".to_string()),
1409 timeout_secs: 30,
1410 system_prompt: default_system_prompt(),
1411 token_budget: None,
1412 retry_max: 3,
1413 retry_base_delay_ms: 100,
1414 retry_max_delay_ms: 10000,
1415 },
1416 llms: vec![],
1417 ravenfabric: RavenFabricConfig::default(),
1418 security: SecurityConfig {
1419 require_tls: false,
1420 token_lifetime_secs: 3600,
1421 audit_log: false,
1422 prompt_injection_protection: false,
1423 },
1424 runtime: RuntimeConfig::default(),
1425 telemetry: TelemetryConfig::default(),
1426 scheduler: SchedulerConfig::default(),
1427 web_search: WebSearchConfig::default(),
1428 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1429 mcp: McpConfig::default(),
1430 swarm: crate::swarm::SwarmConfig::default(),
1431 browser: BrowserConfig::default(),
1432 load: crate::load::LoadConfig::default(),
1433 };
1434
1435 let result = config.validate();
1436 assert!(result.is_ok());
1437 }
1438
1439 #[test]
1440 fn test_validate_openrouter_with_endpoint() {
1441 let config = Config {
1442 llm: LLMConfig {
1443 provider: LLMProvider::OpenRouter,
1444 endpoint: "https://openrouter.ai/api".to_string(),
1445 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1446 api_key: Some("or-key".to_string()),
1447 timeout_secs: 30,
1448 system_prompt: default_system_prompt(),
1449 token_budget: None,
1450 retry_max: 3,
1451 retry_base_delay_ms: 100,
1452 retry_max_delay_ms: 10000,
1453 },
1454 llms: vec![],
1455 ravenfabric: RavenFabricConfig::default(),
1456 security: SecurityConfig {
1457 require_tls: false,
1458 token_lifetime_secs: 3600,
1459 audit_log: false,
1460 prompt_injection_protection: false,
1461 },
1462 runtime: RuntimeConfig::default(),
1463 telemetry: TelemetryConfig::default(),
1464 scheduler: SchedulerConfig::default(),
1465 web_search: WebSearchConfig::default(),
1466 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1467 mcp: McpConfig::default(),
1468 swarm: crate::swarm::SwarmConfig::default(),
1469 browser: BrowserConfig::default(),
1470 load: crate::load::LoadConfig::default(),
1471 };
1472
1473 let result = config.validate();
1474 assert!(result.is_ok());
1475 }
1476
1477 #[test]
1478 fn test_validate_https_endpoint_with_tls() {
1479 let config = Config {
1480 llm: LLMConfig {
1481 provider: LLMProvider::LiteLLM,
1482 endpoint: "https://api.example.com:4000".to_string(),
1483 model: "gpt-4o-mini".to_string(),
1484 api_key: Some("key".to_string()),
1485 timeout_secs: 30,
1486 system_prompt: default_system_prompt(),
1487 token_budget: None,
1488 retry_max: 3,
1489 retry_base_delay_ms: 100,
1490 retry_max_delay_ms: 10000,
1491 },
1492 llms: vec![],
1493 ravenfabric: RavenFabricConfig::default(),
1494 security: SecurityConfig {
1495 require_tls: true,
1496 token_lifetime_secs: 3600,
1497 audit_log: false,
1498 prompt_injection_protection: false,
1499 },
1500 runtime: RuntimeConfig::default(),
1501 telemetry: TelemetryConfig::default(),
1502 scheduler: SchedulerConfig::default(),
1503 web_search: WebSearchConfig::default(),
1504 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1505 mcp: McpConfig::default(),
1506 swarm: crate::swarm::SwarmConfig::default(),
1507 browser: BrowserConfig::default(),
1508 load: crate::load::LoadConfig::default(),
1509 };
1510
1511 let result = config.validate();
1512 assert!(result.is_ok());
1513 }
1514
1515 #[test]
1516 #[serial(env_test)]
1517 fn test_config_load_with_nonexistent_file() {
1518 std::env::set_var("LITELLM_API_KEY", "test-key");
1521 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1522
1523 let result = Config::load(Some("/tmp/nonexistent/ravenclaws.toml"));
1524 assert!(result.is_ok());
1525
1526 std::env::remove_var("LITELLM_API_KEY");
1527 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1528 }
1529
1530 #[test]
1531 fn test_config_error_missing_env_var_display() {
1532 let err = ConfigError::MissingEnvVar("DATABASE_URL".to_string());
1533 assert_eq!(
1534 format!("{}", err),
1535 "Missing required environment variable: DATABASE_URL"
1536 );
1537 }
1538
1539 #[test]
1540 fn test_llm_config_deserialize() {
1541 let json = r#"{
1542 "provider": "openai",
1543 "endpoint": "https://api.openai.com",
1544 "model": "gpt-4o",
1545 "api_key": "sk-test",
1546 "timeout_secs": 120
1547 }"#;
1548 let config: LLMConfig = serde_json::from_str(json).unwrap();
1549
1550 assert_eq!(config.provider, LLMProvider::OpenAI);
1551 assert_eq!(config.endpoint, "https://api.openai.com");
1552 assert_eq!(config.model, "gpt-4o");
1553 assert_eq!(config.timeout_secs, 120);
1554 }
1555
1556 #[test]
1557 fn test_security_config_serde_defaults() {
1558 let json = r#"{}"#;
1560 let config: SecurityConfig = serde_json::from_str(json).unwrap();
1561 assert!(config.require_tls);
1562 assert_eq!(config.token_lifetime_secs, 3600);
1563 assert!(config.audit_log);
1564 }
1565
1566 #[test]
1567 fn test_runtime_config_serde_defaults() {
1568 let json = r#"{}"#;
1569 let config: RuntimeConfig = serde_json::from_str(json).unwrap();
1570 assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
1571 assert_eq!(config.max_agents, 10);
1572 assert_eq!(config.health_interval_secs, 60);
1573 }
1574
1575 #[test]
1576 fn test_ravenfabric_config_serde_defaults() {
1577 let json = r#"{}"#;
1578 let config: RavenFabricConfig = serde_json::from_str(json).unwrap();
1579 assert!(config.endpoint.is_none());
1580 assert!(config.agent_id.is_none());
1581 assert!(config.remote_exec);
1582 assert!(config.allowed_hosts.is_empty());
1583 }
1584
1585 #[test]
1586 fn test_validate_ollama_with_endpoint_succeeds() {
1587 let config = Config {
1588 llm: LLMConfig {
1589 provider: LLMProvider::Ollama,
1590 endpoint: "http://localhost:11434".to_string(),
1591 model: "llama3.1".to_string(),
1592 api_key: None,
1593 timeout_secs: 60,
1594 system_prompt: default_system_prompt(),
1595 token_budget: None,
1596 retry_max: 3,
1597 retry_base_delay_ms: 100,
1598 retry_max_delay_ms: 10000,
1599 },
1600 llms: vec![],
1601 ravenfabric: RavenFabricConfig::default(),
1602 security: SecurityConfig {
1603 require_tls: false,
1604 token_lifetime_secs: 3600,
1605 audit_log: false,
1606 prompt_injection_protection: false,
1607 },
1608 runtime: RuntimeConfig::default(),
1609 telemetry: TelemetryConfig::default(),
1610 scheduler: SchedulerConfig::default(),
1611 web_search: WebSearchConfig::default(),
1612 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1613 mcp: McpConfig::default(),
1614 swarm: crate::swarm::SwarmConfig::default(),
1615 browser: BrowserConfig::default(),
1616 load: crate::load::LoadConfig::default(),
1617 };
1618
1619 let result = config.validate();
1620 assert!(result.is_ok());
1621 }
1622
1623 #[test]
1624 fn test_validate_openrouter_with_endpoint_succeeds() {
1625 let config = Config {
1626 llm: LLMConfig {
1627 provider: LLMProvider::OpenRouter,
1628 endpoint: "https://openrouter.ai/api".to_string(),
1629 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1630 api_key: Some("or-key".to_string()),
1631 timeout_secs: 30,
1632 system_prompt: default_system_prompt(),
1633 token_budget: None,
1634 retry_max: 3,
1635 retry_base_delay_ms: 100,
1636 retry_max_delay_ms: 10000,
1637 },
1638 llms: vec![],
1639 ravenfabric: RavenFabricConfig::default(),
1640 security: SecurityConfig {
1641 require_tls: false,
1642 token_lifetime_secs: 3600,
1643 audit_log: false,
1644 prompt_injection_protection: false,
1645 },
1646 runtime: RuntimeConfig::default(),
1647 telemetry: TelemetryConfig::default(),
1648 scheduler: SchedulerConfig::default(),
1649 web_search: WebSearchConfig::default(),
1650 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1651 mcp: McpConfig::default(),
1652 swarm: crate::swarm::SwarmConfig::default(),
1653 browser: BrowserConfig::default(),
1654 load: crate::load::LoadConfig::default(),
1655 };
1656
1657 let result = config.validate();
1658 assert!(result.is_ok());
1659 }
1660
1661 #[test]
1662 fn test_validate_litellm_with_empty_endpoint_fails() {
1663 let config = Config {
1664 llm: LLMConfig {
1665 provider: LLMProvider::LiteLLM,
1666 endpoint: String::new(),
1667 model: "gpt-4o-mini".to_string(),
1668 api_key: Some("key".to_string()),
1669 timeout_secs: 30,
1670 system_prompt: default_system_prompt(),
1671 token_budget: None,
1672 retry_max: 3,
1673 retry_base_delay_ms: 100,
1674 retry_max_delay_ms: 10000,
1675 },
1676 llms: vec![],
1677 ravenfabric: RavenFabricConfig::default(),
1678 security: SecurityConfig {
1679 require_tls: false,
1680 token_lifetime_secs: 3600,
1681 audit_log: false,
1682 prompt_injection_protection: false,
1683 },
1684 runtime: RuntimeConfig::default(),
1685 telemetry: TelemetryConfig::default(),
1686 scheduler: SchedulerConfig::default(),
1687 web_search: WebSearchConfig::default(),
1688 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1689 mcp: McpConfig::default(),
1690 swarm: crate::swarm::SwarmConfig::default(),
1691 browser: BrowserConfig::default(),
1692 load: crate::load::LoadConfig::default(),
1693 };
1694
1695 let result = config.validate();
1696 assert!(result.is_err());
1697 assert!(result
1698 .unwrap_err()
1699 .to_string()
1700 .contains("At least one LLM provider"));
1701 }
1702
1703 #[test]
1704 fn test_llm_provider_serde_serialize() {
1705 let provider = LLMProvider::OpenAI;
1706 let json = serde_json::to_string(&provider).unwrap();
1707 assert_eq!(json, r#""openai""#);
1708
1709 let provider = LLMProvider::Ollama;
1710 let json = serde_json::to_string(&provider).unwrap();
1711 assert_eq!(json, r#""ollama""#);
1712
1713 let provider = LLMProvider::OpenRouter;
1714 let json = serde_json::to_string(&provider).unwrap();
1715 assert_eq!(json, r#""openrouter""#);
1716
1717 let provider = LLMProvider::LiteLLM;
1718 let json = serde_json::to_string(&provider).unwrap();
1719 assert_eq!(json, r#""litellm""#);
1720 }
1721}