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
102#[derive(Debug, Clone, Deserialize, Default)]
120#[non_exhaustive]
121pub struct McpConfig {
122 #[serde(default)]
124 pub servers: Vec<McpServerConfig>,
125}
126
127#[derive(Debug, Clone, Deserialize)]
132#[non_exhaustive]
133pub struct McpServerConfig {
134 pub name: String,
136 #[serde(default)]
139 pub command: String,
140 #[serde(default)]
142 pub args: Vec<String>,
143 #[serde(default)]
145 pub env: std::collections::HashMap<String, String>,
146 #[serde(default)]
149 pub url: String,
150}
151
152#[derive(Debug, Clone, Deserialize)]
157#[non_exhaustive]
158pub struct WebSearchConfig {
159 #[serde(default = "default_search_endpoint")]
161 pub endpoint: String,
162
163 #[serde(default = "default_search_engine")]
165 pub engine: String,
166
167 #[serde(default = "default_search_max_results")]
169 pub max_results: usize,
170
171 #[serde(default = "default_true")]
173 pub fetch_content: bool,
174}
175
176impl Default for WebSearchConfig {
177 fn default() -> Self {
178 Self {
179 endpoint: default_search_endpoint(),
180 engine: default_search_engine(),
181 max_results: default_search_max_results(),
182 fetch_content: default_true(),
183 }
184 }
185}
186
187fn default_search_endpoint() -> String {
188 "https://searx.be".to_string()
189}
190
191fn default_search_engine() -> String {
192 "duckduckgo".to_string()
193}
194
195fn default_search_max_results() -> usize {
196 5
197}
198
199fn default_otel_disabled() -> bool {
200 true
201}
202
203#[derive(Debug, Clone, Deserialize, Default)]
208#[non_exhaustive]
209pub struct SchedulerConfig {
210 #[serde(default)]
212 pub triggers: Vec<crate::scheduler::TriggerConfig>,
213}
214
215#[derive(Debug, Clone, Deserialize, Default)]
220#[non_exhaustive]
221pub struct TelemetryConfig {
222 #[serde(default)]
224 pub otel_endpoint: Option<String>,
225
226 #[serde(default)]
228 pub otel_service_name: Option<String>,
229
230 #[serde(default = "default_otel_disabled")]
232 pub otel_disabled: bool,
233}
234
235#[derive(Debug, Clone, Deserialize)]
241#[non_exhaustive]
242pub struct LLMConfig {
243 #[serde(default)]
245 pub provider: LLMProvider,
246
247 #[serde(default)]
249 pub endpoint: String,
250
251 #[serde(default = "default_model")]
253 pub model: String,
254
255 #[serde(default)]
257 pub api_key: Option<String>,
258
259 #[serde(default = "default_timeout")]
261 pub timeout_secs: u64,
262
263 #[serde(default = "default_system_prompt")]
265 pub system_prompt: String,
266
267 #[serde(default)]
269 pub token_budget: Option<u32>,
270
271 #[serde(default = "default_retry_max")]
273 pub retry_max: u32,
274
275 #[serde(default = "default_retry_base_delay")]
277 pub retry_base_delay_ms: u64,
278
279 #[serde(default = "default_retry_max_delay")]
281 pub retry_max_delay_ms: u64,
282}
283
284pub fn default_retry_max() -> u32 {
285 3
286}
287pub fn default_retry_base_delay() -> u64 {
288 100
289}
290pub fn default_retry_max_delay() -> u64 {
291 10000
292}
293
294pub fn default_system_prompt() -> String {
295 "You are RavenClaws, a lightweight autonomous agent. \
296 Be concise, efficient, and secure. Always validate inputs and outputs. \
297 When you have completed the task, prefix your final answer with FINAL: \
298 so the system knows the task is done."
299 .to_string()
300}
301
302#[derive(Debug, Clone, Deserialize)]
307#[non_exhaustive]
308pub struct RavenFabricConfig {
309 #[serde(default)]
311 pub endpoint: Option<String>,
312
313 #[serde(default)]
315 pub agent_id: Option<String>,
316
317 #[serde(default = "default_true")]
319 pub remote_exec: bool,
320
321 #[serde(default)]
323 #[allow(dead_code)]
324 pub allowed_hosts: Vec<String>,
325}
326
327impl Default for RavenFabricConfig {
328 fn default() -> Self {
329 Self {
330 endpoint: None,
331 agent_id: None,
332 remote_exec: default_true(),
333 allowed_hosts: Vec::new(),
334 }
335 }
336}
337
338#[derive(Debug, Clone, Deserialize)]
343#[non_exhaustive]
344pub struct SecurityConfig {
345 #[serde(default = "default_true")]
347 pub require_tls: bool,
348
349 #[serde(default = "default_token_lifetime")]
353 pub token_lifetime_secs: u64,
354
355 #[serde(default = "default_true")]
357 #[allow(dead_code)]
358 pub audit_log: bool,
359
360 #[serde(default = "default_true")]
364 #[allow(dead_code)]
365 pub prompt_injection_protection: bool,
366}
367
368impl Default for SecurityConfig {
369 fn default() -> Self {
370 Self {
371 require_tls: default_true(),
372 token_lifetime_secs: default_token_lifetime(),
373 audit_log: default_true(),
374 prompt_injection_protection: default_true(),
375 }
376 }
377}
378
379#[derive(Debug, Clone, Deserialize)]
384#[non_exhaustive]
385pub struct RuntimeConfig {
386 #[serde(default = "default_workdir")]
388 #[allow(dead_code)]
389 pub workdir: String,
390
391 #[serde(default = "default_max_agents")]
393 #[allow(dead_code)]
394 pub max_agents: usize,
395
396 #[serde(default = "default_health_interval")]
398 #[allow(dead_code)]
399 pub health_interval_secs: u64,
400
401 #[serde(default)]
403 pub host: Option<String>,
404
405 #[serde(default = "default_server_port")]
407 pub port: u16,
408
409 #[serde(default)]
414 #[allow(dead_code)]
415 pub checkpoint_dir: Option<String>,
416
417 #[serde(default = "default_checkpoint_interval")]
421 #[allow(dead_code)]
422 pub checkpoint_interval: usize,
423}
424
425fn default_checkpoint_interval() -> usize {
426 1
427}
428
429impl Default for RuntimeConfig {
430 fn default() -> Self {
431 Self {
432 workdir: default_workdir(),
433 max_agents: default_max_agents(),
434 health_interval_secs: default_health_interval(),
435 host: None,
436 port: default_server_port(),
437 checkpoint_dir: None,
438 checkpoint_interval: 1,
439 }
440 }
441}
442
443fn default_model() -> String {
444 "gpt-4o-mini".to_string()
445}
446
447fn default_timeout() -> u64 {
448 30
449}
450
451fn default_true() -> bool {
452 true
453}
454
455fn default_token_lifetime() -> u64 {
456 3600
457}
458
459fn default_workdir() -> String {
460 "/tmp/ravenclaws-workdir".to_string()
461}
462
463fn default_max_agents() -> usize {
464 10
465}
466
467fn default_health_interval() -> u64 {
468 60
469}
470
471fn default_server_port() -> u16 {
472 8080
473}
474
475impl Default for LLMConfig {
476 fn default() -> Self {
477 Self {
478 provider: LLMProvider::LiteLLM,
479 endpoint: String::new(),
480 model: default_model(),
481 api_key: None,
482 timeout_secs: default_timeout(),
483 system_prompt: default_system_prompt(),
484 token_budget: None,
485 retry_max: default_retry_max(),
486 retry_base_delay_ms: default_retry_base_delay(),
487 retry_max_delay_ms: default_retry_max_delay(),
488 }
489 }
490}
491
492impl Drop for LLMConfig {
494 fn drop(&mut self) {
495 if let Some(ref mut key) = self.api_key {
496 key.zeroize();
497 }
498 }
499}
500
501impl Config {
502 pub fn load(config_path: Option<&str>) -> Result<Self, ConfigError> {
504 dotenvy::dotenv().ok();
506
507 let mut config_builder = config::Config::builder();
508
509 if let Some(path) = config_path {
511 config_builder =
512 config_builder.add_source(config::File::with_name(path).required(false));
513 }
514
515 let ravenclaws_llms = std::env::var("RAVENCLAWS__LLMS").ok();
520 if ravenclaws_llms.is_some() {
521 std::env::remove_var("RAVENCLAWS__LLMS");
522 }
523
524 config_builder = config_builder
525 .add_source(config::Environment::with_prefix("RAVENCLAW").separator("__"));
526
527 let config = config_builder
528 .build()
529 .map_err(|e| ConfigError::LoadError(e.to_string()))?;
530
531 let mut cfg: Config = config
532 .try_deserialize()
533 .map_err(|e| ConfigError::LoadError(e.to_string()))?;
534
535 if let Some(ref val) = ravenclaws_llms {
537 std::env::set_var("RAVENCLAWS__LLMS", val);
538 }
539
540 if let Ok(key) = std::env::var("LITELLM_API_KEY") {
543 cfg.llm.api_key = Some(key);
544 }
545 if let Ok(provider) = std::env::var("RAVENCLAWS__LLM__PROVIDER") {
546 cfg.llm.provider = match provider.to_lowercase().as_str() {
547 "openrouter" => LLMProvider::OpenRouter,
548 "ollama" => LLMProvider::Ollama,
549 "openai" => LLMProvider::OpenAI,
550 "anthropic" => LLMProvider::Anthropic,
551 _ => LLMProvider::LiteLLM,
552 };
553 }
554 if let Ok(endpoint) = std::env::var("RAVENCLAWS__LLM__ENDPOINT") {
555 cfg.llm.endpoint = endpoint;
556 }
557 if let Ok(model) = std::env::var("RAVENCLAWS__LLM__MODEL") {
558 cfg.llm.model = model;
559 }
560
561 if let Ok(keys) = std::env::var("RAVENCLAWS__LLMS") {
565 if let Ok(llms) = serde_json::from_str::<Vec<LLMConfig>>(&keys) {
567 cfg.llms = llms;
568 }
569 }
570
571 if let Ok(endpoint) = std::env::var("RAVENFABRIC_ENDPOINT") {
572 cfg.ravenfabric.endpoint = Some(endpoint);
573 }
574
575 cfg.validate()?;
577
578 Ok(cfg)
579 }
580
581 fn validate(&self) -> Result<(), ConfigError> {
583 if !self.llm.endpoint.is_empty() {
585 self.validate_llm_config(&self.llm)?;
586 }
587
588 for (i, llm) in self.llms.iter().enumerate() {
590 self.validate_llm_config(llm)
591 .map_err(|e| ConfigError::ValidationError(format!("LLM[{}]: {}", i, e)))?;
592 }
593
594 if self.llm.endpoint.is_empty() && self.llms.is_empty() {
596 return Err(ConfigError::ValidationError(
597 "At least one LLM provider must be configured (llm or llms)".to_string(),
598 ));
599 }
600
601 Ok(())
602 }
603
604 fn validate_llm_config(&self, llm: &LLMConfig) -> Result<(), ConfigError> {
605 if llm.endpoint.is_empty()
606 && llm.provider != LLMProvider::OpenAI
607 && llm.provider != LLMProvider::OpenRouter
608 && llm.provider != LLMProvider::Anthropic
609 {
610 return Err(ConfigError::ValidationError(
612 "LLM endpoint is required for this provider".to_string(),
613 ));
614 }
615
616 if self.security.require_tls
617 && !llm.endpoint.is_empty()
618 && !llm.endpoint.starts_with("https://")
619 && !llm.endpoint.contains("localhost")
620 && !llm.endpoint.contains("127.0.0.1")
621 && !llm.endpoint.contains("0.0.0.0")
622 {
623 return Err(ConfigError::ValidationError(
624 "TLS required but endpoint is not HTTPS".to_string(),
625 ));
626 }
627
628 Ok(())
629 }
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use serial_test::serial;
636
637 #[test]
638 #[serial(env_test)]
639 fn test_default_config() {
640 std::env::set_var("LITELLM_API_KEY", "test-key");
641 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
642
643 let config = Config::load(None).unwrap();
644 assert_eq!(config.llm.model, "gpt-4o-mini");
645 assert_eq!(config.llm.timeout_secs, 30);
646 assert!(config.security.require_tls);
650
651 std::env::remove_var("LITELLM_API_KEY");
653 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
654 }
655
656 #[test]
657 fn test_llm_provider_default() {
658 assert_eq!(LLMProvider::default(), LLMProvider::LiteLLM);
659 }
660
661 #[test]
662 fn test_llm_provider_serde() {
663 let json = r#""litellm""#;
664 let provider: LLMProvider = serde_json::from_str(json).unwrap();
665 assert_eq!(provider, LLMProvider::LiteLLM);
666
667 let json = r#""openai""#;
668 let provider: LLMProvider = serde_json::from_str(json).unwrap();
669 assert_eq!(provider, LLMProvider::OpenAI);
670
671 let json = r#""ollama""#;
672 let provider: LLMProvider = serde_json::from_str(json).unwrap();
673 assert_eq!(provider, LLMProvider::Ollama);
674
675 let json = r#""openrouter""#;
676 let provider: LLMProvider = serde_json::from_str(json).unwrap();
677 assert_eq!(provider, LLMProvider::OpenRouter);
678 }
679
680 #[test]
681 fn test_llm_config_default() {
682 let config = LLMConfig::default();
683 assert_eq!(config.provider, LLMProvider::LiteLLM);
684 assert_eq!(config.model, "gpt-4o-mini");
685 assert_eq!(config.timeout_secs, 30);
686 assert!(config.api_key.is_none());
687 assert!(config.endpoint.is_empty());
688 assert!(config.system_prompt.contains("RavenClaws"));
689 }
690
691 #[test]
692 fn test_system_prompt_custom() {
693 let mut config = LLMConfig::default();
694 config.system_prompt = "You are a helpful coding assistant.".to_string();
695 assert_eq!(config.system_prompt, "You are a helpful coding assistant.");
696 }
697
698 #[test]
699 fn test_validate_missing_endpoint() {
700 let config = Config {
701 llm: LLMConfig {
702 provider: LLMProvider::LiteLLM,
703 endpoint: String::new(),
704 model: "gpt-4o-mini".to_string(),
705 api_key: None,
706 timeout_secs: 30,
707 system_prompt: default_system_prompt(),
708 token_budget: None,
709 retry_max: 3,
710 retry_base_delay_ms: 100,
711 retry_max_delay_ms: 10000,
712 },
713 llms: vec![],
714 ravenfabric: RavenFabricConfig::default(),
715 security: SecurityConfig {
716 require_tls: false,
717 token_lifetime_secs: 3600,
718 audit_log: false,
719 prompt_injection_protection: false,
720 },
721 runtime: RuntimeConfig::default(),
722 telemetry: TelemetryConfig::default(),
723 scheduler: SchedulerConfig::default(),
724 web_search: WebSearchConfig::default(),
725 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
726 mcp: McpConfig::default(),
727 swarm: crate::swarm::SwarmConfig::default(),
728 };
729
730 let result = config.validate();
731 assert!(result.is_err());
732 assert!(result
733 .unwrap_err()
734 .to_string()
735 .contains("At least one LLM provider"));
736 }
737
738 #[test]
739 fn test_validate_tls_required() {
740 let config = Config {
741 llm: LLMConfig {
742 provider: LLMProvider::LiteLLM,
743 endpoint: "http://example.com:4000".to_string(),
744 model: "gpt-4o-mini".to_string(),
745 api_key: Some("key".to_string()),
746 timeout_secs: 30,
747 system_prompt: default_system_prompt(),
748 token_budget: None,
749 retry_max: 3,
750 retry_base_delay_ms: 100,
751 retry_max_delay_ms: 10000,
752 },
753 llms: vec![],
754 ravenfabric: RavenFabricConfig::default(),
755 security: SecurityConfig {
756 require_tls: true,
757 token_lifetime_secs: 3600,
758 audit_log: false,
759 prompt_injection_protection: false,
760 },
761 runtime: RuntimeConfig::default(),
762 telemetry: TelemetryConfig::default(),
763 scheduler: SchedulerConfig::default(),
764 web_search: WebSearchConfig::default(),
765 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
766 mcp: McpConfig::default(),
767 swarm: crate::swarm::SwarmConfig::default(),
768 };
769
770 let result = config.validate();
771 assert!(result.is_err());
772 let err = result.unwrap_err().to_string();
773 assert!(err.contains("TLS required"));
774 }
775
776 #[test]
777 fn test_validate_tls_localhost_allowed() {
778 let config = Config {
779 llm: LLMConfig {
780 provider: LLMProvider::LiteLLM,
781 endpoint: "http://localhost:4000".to_string(),
782 model: "gpt-4o-mini".to_string(),
783 api_key: Some("key".to_string()),
784 timeout_secs: 30,
785 system_prompt: default_system_prompt(),
786 token_budget: None,
787 retry_max: 3,
788 retry_base_delay_ms: 100,
789 retry_max_delay_ms: 10000,
790 },
791 llms: vec![],
792 ravenfabric: RavenFabricConfig::default(),
793 security: SecurityConfig {
794 require_tls: true,
795 token_lifetime_secs: 3600,
796 audit_log: false,
797 prompt_injection_protection: false,
798 },
799 runtime: RuntimeConfig::default(),
800 telemetry: TelemetryConfig::default(),
801 scheduler: SchedulerConfig::default(),
802 web_search: WebSearchConfig::default(),
803 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
804 mcp: McpConfig::default(),
805 swarm: crate::swarm::SwarmConfig::default(),
806 };
807
808 let result = config.validate();
809 assert!(result.is_ok());
810 }
811
812 #[test]
813 fn test_validate_openai_no_endpoint_needed() {
814 let config = Config {
815 llm: LLMConfig {
816 provider: LLMProvider::OpenAI,
817 endpoint: String::new(),
818 model: "gpt-4o".to_string(),
819 api_key: Some("sk-key".to_string()),
820 timeout_secs: 30,
821 system_prompt: default_system_prompt(),
822 token_budget: None,
823 retry_max: 3,
824 retry_base_delay_ms: 100,
825 retry_max_delay_ms: 10000,
826 },
827 llms: vec![],
828 ravenfabric: RavenFabricConfig::default(),
829 security: SecurityConfig {
830 require_tls: false,
831 token_lifetime_secs: 3600,
832 audit_log: false,
833 prompt_injection_protection: false,
834 },
835 runtime: RuntimeConfig::default(),
836 telemetry: TelemetryConfig::default(),
837 scheduler: SchedulerConfig::default(),
838 web_search: WebSearchConfig::default(),
839 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
840 mcp: McpConfig::default(),
841 swarm: crate::swarm::SwarmConfig::default(),
842 };
843
844 let result = config.validate();
849 assert!(result.is_err()); }
851
852 #[test]
853 fn test_validate_multi_provider() {
854 let config = Config {
855 llm: LLMConfig::default(),
856 llms: vec![LLMConfig {
857 provider: LLMProvider::Ollama,
858 endpoint: "http://localhost:11434".to_string(),
859 model: "llama3.1".to_string(),
860 api_key: None,
861 timeout_secs: 60,
862 system_prompt: default_system_prompt(),
863 token_budget: None,
864 retry_max: 3,
865 retry_base_delay_ms: 100,
866 retry_max_delay_ms: 10000,
867 }],
868 ravenfabric: RavenFabricConfig::default(),
869 security: SecurityConfig {
870 require_tls: false,
871 token_lifetime_secs: 3600,
872 audit_log: false,
873 prompt_injection_protection: false,
874 },
875 runtime: RuntimeConfig::default(),
876 telemetry: TelemetryConfig::default(),
877 scheduler: SchedulerConfig::default(),
878 web_search: WebSearchConfig::default(),
879 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
880 mcp: McpConfig::default(),
881 swarm: crate::swarm::SwarmConfig::default(),
882 };
883
884 let result = config.validate();
885 assert!(result.is_ok());
886 }
887
888 #[test]
889 fn test_ravenfabric_config_default() {
890 let config = RavenFabricConfig::default();
891 assert!(config.endpoint.is_none());
892 assert!(config.agent_id.is_none());
893 assert!(config.remote_exec);
894 assert!(config.allowed_hosts.is_empty());
895 }
896
897 #[test]
898 fn test_security_config_default() {
899 let config = SecurityConfig::default();
900 assert!(config.require_tls);
901 assert_eq!(config.token_lifetime_secs, 3600);
902 assert!(config.audit_log);
903 }
904
905 #[test]
906 fn test_runtime_config_default() {
907 let config = RuntimeConfig::default();
908 assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
909 assert_eq!(config.max_agents, 10);
910 assert_eq!(config.health_interval_secs, 60);
911 }
912
913 #[test]
914 fn test_config_error_display() {
915 let err = ConfigError::LoadError("file not found".to_string());
916 assert_eq!(format!("{}", err), "Failed to load config: file not found");
917
918 let err = ConfigError::ValidationError("bad field".to_string());
919 assert_eq!(format!("{}", err), "Invalid configuration: bad field");
920
921 let err = ConfigError::MissingEnvVar("API_KEY".to_string());
922 assert_eq!(
923 format!("{}", err),
924 "Missing required environment variable: API_KEY"
925 );
926 }
927
928 #[test]
929 fn test_validate_openrouter_no_endpoint_needed() {
930 let config = Config {
931 llm: LLMConfig {
932 provider: LLMProvider::OpenRouter,
933 endpoint: String::new(),
934 model: "anthropic/claude-sonnet-4-20250514".to_string(),
935 api_key: Some("or-key".to_string()),
936 timeout_secs: 30,
937 system_prompt: default_system_prompt(),
938 token_budget: None,
939 retry_max: 3,
940 retry_base_delay_ms: 100,
941 retry_max_delay_ms: 10000,
942 },
943 llms: vec![],
944 ravenfabric: RavenFabricConfig::default(),
945 security: SecurityConfig {
946 require_tls: false,
947 token_lifetime_secs: 3600,
948 audit_log: false,
949 prompt_injection_protection: false,
950 },
951 runtime: RuntimeConfig::default(),
952 telemetry: TelemetryConfig::default(),
953 scheduler: SchedulerConfig::default(),
954 web_search: WebSearchConfig::default(),
955 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
956 mcp: McpConfig::default(),
957 swarm: crate::swarm::SwarmConfig::default(),
958 };
959
960 let result = config.validate();
963 assert!(result.is_err());
964 }
965
966 #[test]
967 fn test_validate_ollama_needs_endpoint() {
968 let config = Config {
969 llm: LLMConfig {
970 provider: LLMProvider::Ollama,
971 endpoint: String::new(),
972 model: "llama3.1".to_string(),
973 api_key: None,
974 timeout_secs: 30,
975 system_prompt: default_system_prompt(),
976 token_budget: None,
977 retry_max: 3,
978 retry_base_delay_ms: 100,
979 retry_max_delay_ms: 10000,
980 },
981 llms: vec![],
982 ravenfabric: RavenFabricConfig::default(),
983 security: SecurityConfig {
984 require_tls: false,
985 token_lifetime_secs: 3600,
986 audit_log: false,
987 prompt_injection_protection: false,
988 },
989 runtime: RuntimeConfig::default(),
990 telemetry: TelemetryConfig::default(),
991 scheduler: SchedulerConfig::default(),
992 web_search: WebSearchConfig::default(),
993 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
994 mcp: McpConfig::default(),
995 swarm: crate::swarm::SwarmConfig::default(),
996 };
997
998 let result = config.validate();
999 assert!(result.is_err());
1000 let err = result.unwrap_err().to_string();
1001 assert!(err.contains("At least one LLM provider"));
1002 }
1003
1004 #[test]
1005 fn test_validate_tls_localhost_ip_allowed() {
1006 let config = Config {
1007 llm: LLMConfig {
1008 provider: LLMProvider::LiteLLM,
1009 endpoint: "http://127.0.0.1:4000".to_string(),
1010 model: "gpt-4o-mini".to_string(),
1011 api_key: Some("key".to_string()),
1012 timeout_secs: 30,
1013 system_prompt: default_system_prompt(),
1014 token_budget: None,
1015 retry_max: 3,
1016 retry_base_delay_ms: 100,
1017 retry_max_delay_ms: 10000,
1018 },
1019 llms: vec![],
1020 ravenfabric: RavenFabricConfig::default(),
1021 security: SecurityConfig {
1022 require_tls: true,
1023 token_lifetime_secs: 3600,
1024 audit_log: false,
1025 prompt_injection_protection: false,
1026 },
1027 runtime: RuntimeConfig::default(),
1028 telemetry: TelemetryConfig::default(),
1029 scheduler: SchedulerConfig::default(),
1030 web_search: WebSearchConfig::default(),
1031 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1032 mcp: McpConfig::default(),
1033 swarm: crate::swarm::SwarmConfig::default(),
1034 };
1035
1036 let result = config.validate();
1037 assert!(result.is_ok());
1038 }
1039
1040 #[test]
1041 fn test_validate_tls_wildcard_allowed() {
1042 let config = Config {
1043 llm: LLMConfig {
1044 provider: LLMProvider::LiteLLM,
1045 endpoint: "http://0.0.0.0:4000".to_string(),
1046 model: "gpt-4o-mini".to_string(),
1047 api_key: Some("key".to_string()),
1048 timeout_secs: 30,
1049 system_prompt: default_system_prompt(),
1050 token_budget: None,
1051 retry_max: 3,
1052 retry_base_delay_ms: 100,
1053 retry_max_delay_ms: 10000,
1054 },
1055 llms: vec![],
1056 ravenfabric: RavenFabricConfig::default(),
1057 security: SecurityConfig {
1058 require_tls: true,
1059 token_lifetime_secs: 3600,
1060 audit_log: false,
1061 prompt_injection_protection: false,
1062 },
1063 runtime: RuntimeConfig::default(),
1064 telemetry: TelemetryConfig::default(),
1065 scheduler: SchedulerConfig::default(),
1066 web_search: WebSearchConfig::default(),
1067 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1068 mcp: McpConfig::default(),
1069 swarm: crate::swarm::SwarmConfig::default(),
1070 };
1071
1072 let result = config.validate();
1073 assert!(result.is_ok());
1074 }
1075
1076 #[test]
1077 fn test_validate_multi_provider_with_tls() {
1078 let config = Config {
1079 llm: LLMConfig::default(),
1080 llms: vec![
1081 LLMConfig {
1082 provider: LLMProvider::Ollama,
1083 endpoint: "http://localhost:11434".to_string(),
1084 model: "llama3.1".to_string(),
1085 api_key: None,
1086 timeout_secs: 60,
1087 system_prompt: default_system_prompt(),
1088 token_budget: None,
1089 retry_max: 3,
1090 retry_base_delay_ms: 100,
1091 retry_max_delay_ms: 10000,
1092 },
1093 LLMConfig {
1094 provider: LLMProvider::LiteLLM,
1095 endpoint: "https://litellm.example.com:4000".to_string(),
1096 model: "gpt-4o-mini".to_string(),
1097 api_key: Some("key".to_string()),
1098 timeout_secs: 30,
1099 system_prompt: default_system_prompt(),
1100 token_budget: None,
1101 retry_max: 3,
1102 retry_base_delay_ms: 100,
1103 retry_max_delay_ms: 10000,
1104 },
1105 ],
1106 ravenfabric: RavenFabricConfig::default(),
1107 security: SecurityConfig {
1108 require_tls: true,
1109 token_lifetime_secs: 3600,
1110 audit_log: false,
1111 prompt_injection_protection: false,
1112 },
1113 runtime: RuntimeConfig::default(),
1114 telemetry: TelemetryConfig::default(),
1115 scheduler: SchedulerConfig::default(),
1116 web_search: WebSearchConfig::default(),
1117 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1118 mcp: McpConfig::default(),
1119 swarm: crate::swarm::SwarmConfig::default(),
1120 };
1121
1122 let result = config.validate();
1123 assert!(result.is_ok());
1124 }
1125
1126 #[test]
1127 fn test_validate_multi_provider_tls_failure() {
1128 let config = Config {
1129 llm: LLMConfig::default(),
1130 llms: vec![LLMConfig {
1131 provider: LLMProvider::LiteLLM,
1132 endpoint: "http://example.com:4000".to_string(),
1133 model: "gpt-4o-mini".to_string(),
1134 api_key: Some("key".to_string()),
1135 timeout_secs: 30,
1136 system_prompt: default_system_prompt(),
1137 token_budget: None,
1138 retry_max: 3,
1139 retry_base_delay_ms: 100,
1140 retry_max_delay_ms: 10000,
1141 }],
1142 ravenfabric: RavenFabricConfig::default(),
1143 security: SecurityConfig {
1144 require_tls: true,
1145 token_lifetime_secs: 3600,
1146 audit_log: false,
1147 prompt_injection_protection: false,
1148 },
1149 runtime: RuntimeConfig::default(),
1150 telemetry: TelemetryConfig::default(),
1151 scheduler: SchedulerConfig::default(),
1152 web_search: WebSearchConfig::default(),
1153 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1154 mcp: McpConfig::default(),
1155 swarm: crate::swarm::SwarmConfig::default(),
1156 };
1157
1158 let result = config.validate();
1159 assert!(result.is_err());
1160 let err = result.unwrap_err().to_string();
1161 assert!(err.contains("TLS required"));
1162 }
1163
1164 #[test]
1165 fn test_ravenfabric_config_custom() {
1166 let config = RavenFabricConfig {
1167 endpoint: Some("https://fabric.example.com:8443".to_string()),
1168 agent_id: Some("agent-01".to_string()),
1169 remote_exec: false,
1170 allowed_hosts: vec!["10.0.0.0/8".to_string()],
1171 };
1172 assert_eq!(config.endpoint.unwrap(), "https://fabric.example.com:8443");
1173 assert_eq!(config.agent_id.unwrap(), "agent-01");
1174 assert!(!config.remote_exec);
1175 assert_eq!(config.allowed_hosts.len(), 1);
1176 }
1177
1178 #[test]
1179 fn test_security_config_custom() {
1180 let config = SecurityConfig {
1181 require_tls: false,
1182 token_lifetime_secs: 7200,
1183 audit_log: false,
1184 prompt_injection_protection: false,
1185 };
1186 assert!(!config.require_tls);
1187 assert_eq!(config.token_lifetime_secs, 7200);
1188 assert!(!config.audit_log);
1189 }
1190
1191 #[test]
1192 fn test_runtime_config_custom() {
1193 let config = RuntimeConfig {
1194 workdir: "/data".to_string(),
1195 max_agents: 5,
1196 health_interval_secs: 120,
1197 host: Some("127.0.0.1".to_string()),
1198 port: 9090,
1199 checkpoint_dir: None,
1200 checkpoint_interval: 1,
1201 };
1202 assert_eq!(config.workdir, "/data");
1203 assert_eq!(config.max_agents, 5);
1204 assert_eq!(config.health_interval_secs, 120);
1205 assert_eq!(config.host, Some("127.0.0.1".to_string()));
1206 assert_eq!(config.port, 9090);
1207 }
1208
1209 #[test]
1210 fn test_llm_config_custom() {
1211 let config = LLMConfig {
1212 provider: LLMProvider::OpenAI,
1213 endpoint: String::new(),
1214 model: "gpt-4o".to_string(),
1215 api_key: Some("sk-test".to_string()),
1216 timeout_secs: 120,
1217 system_prompt: default_system_prompt(),
1218 token_budget: None,
1219 retry_max: 3,
1220 retry_base_delay_ms: 100,
1221 retry_max_delay_ms: 10000,
1222 };
1223 assert_eq!(config.provider, LLMProvider::OpenAI);
1224 assert_eq!(config.model, "gpt-4o");
1225 assert_eq!(config.timeout_secs, 120);
1226 assert_eq!(config.api_key.clone().unwrap(), "sk-test");
1227 }
1228
1229 #[test]
1230 fn test_llm_provider_serde_invalid_fallback() {
1231 let json = r#""unknown_provider""#;
1233 let provider: LLMProvider = serde_json::from_str(json).unwrap_or_default();
1234 assert_eq!(provider, LLMProvider::LiteLLM);
1235 }
1236
1237 #[test]
1238 #[serial(env_test)]
1239 fn test_config_load_with_env_overrides() {
1240 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1242 std::env::set_var("RAVENCLAWS__LLM__MODEL", "gpt-4o");
1243 std::env::set_var("LITELLM_API_KEY", "env-key");
1244
1245 let config = Config::load(None).unwrap();
1246 assert_eq!(config.llm.endpoint, "http://localhost:4000");
1247 assert_eq!(config.llm.model, "gpt-4o");
1248 assert_eq!(config.llm.api_key.clone().unwrap(), "env-key");
1249
1250 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1252 std::env::remove_var("RAVENCLAWS__LLM__MODEL");
1253 std::env::remove_var("LITELLM_API_KEY");
1254 }
1255
1256 #[test]
1257 #[serial(env_test)]
1258 fn test_config_load_with_llms_json_env() {
1259 let llms_json = r#"[{"provider":"ollama","endpoint":"http://localhost:11434","model":"llama3.1","timeout_secs":60}]"#;
1260 std::env::set_var("RAVENCLAWS__LLMS", llms_json);
1261 std::env::set_var("LITELLM_API_KEY", "dummy");
1262 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1263
1264 let config = Config::load(None).unwrap();
1265 assert_eq!(config.llms.len(), 1);
1266 assert_eq!(config.llms[0].provider, LLMProvider::Ollama);
1267 assert_eq!(config.llms[0].endpoint, "http://localhost:11434");
1268 assert_eq!(config.llms[0].model, "llama3.1");
1269 assert_eq!(config.llms[0].timeout_secs, 60);
1270
1271 std::env::remove_var("RAVENCLAWS__LLMS");
1273 std::env::remove_var("LITELLM_API_KEY");
1274 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1275 }
1276
1277 #[test]
1278 #[serial(env_test)]
1279 fn test_config_load_with_ravenfabric_env() {
1280 std::env::set_var("RAVENFABRIC_ENDPOINT", "https://fabric.example.com:8443");
1281 std::env::set_var("LITELLM_API_KEY", "dummy");
1282 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1283
1284 let config = Config::load(None).unwrap();
1285 assert_eq!(
1286 config.ravenfabric.endpoint.unwrap(),
1287 "https://fabric.example.com:8443"
1288 );
1289
1290 std::env::remove_var("RAVENFABRIC_ENDPOINT");
1292 std::env::remove_var("LITELLM_API_KEY");
1293 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1294 }
1295
1296 #[test]
1297 #[serial(env_test)]
1298 fn test_config_load_with_provider_env() {
1299 std::env::set_var("RAVENCLAWS__LLM__PROVIDER", "openai");
1301 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "https://api.openai.com");
1302 std::env::set_var("LITELLM_API_KEY", "dummy");
1303
1304 let config = Config::load(None).unwrap();
1305 assert_eq!(config.llm.provider, LLMProvider::OpenAI);
1306
1307 std::env::remove_var("RAVENCLAWS__LLM__PROVIDER");
1309 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1310 std::env::remove_var("LITELLM_API_KEY");
1311 }
1312
1313 #[test]
1314 fn test_config_load_with_provider_env_fallback() {
1315 let mapped = match "unknown" {
1318 "openrouter" => LLMProvider::OpenRouter,
1319 "ollama" => LLMProvider::Ollama,
1320 "openai" => LLMProvider::OpenAI,
1321 _ => LLMProvider::LiteLLM,
1322 };
1323 assert_eq!(mapped, LLMProvider::LiteLLM);
1324
1325 let mapped = match "" {
1327 "openrouter" => LLMProvider::OpenRouter,
1328 "ollama" => LLMProvider::Ollama,
1329 "openai" => LLMProvider::OpenAI,
1330 _ => LLMProvider::LiteLLM,
1331 };
1332 assert_eq!(mapped, LLMProvider::LiteLLM);
1333 }
1334
1335 #[test]
1336 fn test_validate_openai_with_endpoint() {
1337 let config = Config {
1338 llm: LLMConfig {
1339 provider: LLMProvider::OpenAI,
1340 endpoint: "https://api.openai.com".to_string(),
1341 model: "gpt-4o".to_string(),
1342 api_key: Some("sk-key".to_string()),
1343 timeout_secs: 30,
1344 system_prompt: default_system_prompt(),
1345 token_budget: None,
1346 retry_max: 3,
1347 retry_base_delay_ms: 100,
1348 retry_max_delay_ms: 10000,
1349 },
1350 llms: vec![],
1351 ravenfabric: RavenFabricConfig::default(),
1352 security: SecurityConfig {
1353 require_tls: false,
1354 token_lifetime_secs: 3600,
1355 audit_log: false,
1356 prompt_injection_protection: false,
1357 },
1358 runtime: RuntimeConfig::default(),
1359 telemetry: TelemetryConfig::default(),
1360 scheduler: SchedulerConfig::default(),
1361 web_search: WebSearchConfig::default(),
1362 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1363 mcp: McpConfig::default(),
1364 swarm: crate::swarm::SwarmConfig::default(),
1365 };
1366
1367 let result = config.validate();
1368 assert!(result.is_ok());
1369 }
1370
1371 #[test]
1372 fn test_validate_openrouter_with_endpoint() {
1373 let config = Config {
1374 llm: LLMConfig {
1375 provider: LLMProvider::OpenRouter,
1376 endpoint: "https://openrouter.ai/api".to_string(),
1377 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1378 api_key: Some("or-key".to_string()),
1379 timeout_secs: 30,
1380 system_prompt: default_system_prompt(),
1381 token_budget: None,
1382 retry_max: 3,
1383 retry_base_delay_ms: 100,
1384 retry_max_delay_ms: 10000,
1385 },
1386 llms: vec![],
1387 ravenfabric: RavenFabricConfig::default(),
1388 security: SecurityConfig {
1389 require_tls: false,
1390 token_lifetime_secs: 3600,
1391 audit_log: false,
1392 prompt_injection_protection: false,
1393 },
1394 runtime: RuntimeConfig::default(),
1395 telemetry: TelemetryConfig::default(),
1396 scheduler: SchedulerConfig::default(),
1397 web_search: WebSearchConfig::default(),
1398 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1399 mcp: McpConfig::default(),
1400 swarm: crate::swarm::SwarmConfig::default(),
1401 };
1402
1403 let result = config.validate();
1404 assert!(result.is_ok());
1405 }
1406
1407 #[test]
1408 fn test_validate_https_endpoint_with_tls() {
1409 let config = Config {
1410 llm: LLMConfig {
1411 provider: LLMProvider::LiteLLM,
1412 endpoint: "https://api.example.com:4000".to_string(),
1413 model: "gpt-4o-mini".to_string(),
1414 api_key: Some("key".to_string()),
1415 timeout_secs: 30,
1416 system_prompt: default_system_prompt(),
1417 token_budget: None,
1418 retry_max: 3,
1419 retry_base_delay_ms: 100,
1420 retry_max_delay_ms: 10000,
1421 },
1422 llms: vec![],
1423 ravenfabric: RavenFabricConfig::default(),
1424 security: SecurityConfig {
1425 require_tls: true,
1426 token_lifetime_secs: 3600,
1427 audit_log: false,
1428 prompt_injection_protection: false,
1429 },
1430 runtime: RuntimeConfig::default(),
1431 telemetry: TelemetryConfig::default(),
1432 scheduler: SchedulerConfig::default(),
1433 web_search: WebSearchConfig::default(),
1434 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1435 mcp: McpConfig::default(),
1436 swarm: crate::swarm::SwarmConfig::default(),
1437 };
1438
1439 let result = config.validate();
1440 assert!(result.is_ok());
1441 }
1442
1443 #[test]
1444 #[serial(env_test)]
1445 fn test_config_load_with_nonexistent_file() {
1446 std::env::set_var("LITELLM_API_KEY", "test-key");
1449 std::env::set_var("RAVENCLAWS__LLM__ENDPOINT", "http://localhost:4000");
1450
1451 let result = Config::load(Some("/tmp/nonexistent/ravenclaws.toml"));
1452 assert!(result.is_ok());
1453
1454 std::env::remove_var("LITELLM_API_KEY");
1455 std::env::remove_var("RAVENCLAWS__LLM__ENDPOINT");
1456 }
1457
1458 #[test]
1459 fn test_config_error_missing_env_var_display() {
1460 let err = ConfigError::MissingEnvVar("DATABASE_URL".to_string());
1461 assert_eq!(
1462 format!("{}", err),
1463 "Missing required environment variable: DATABASE_URL"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_llm_config_deserialize() {
1469 let json = r#"{
1470 "provider": "openai",
1471 "endpoint": "https://api.openai.com",
1472 "model": "gpt-4o",
1473 "api_key": "sk-test",
1474 "timeout_secs": 120
1475 }"#;
1476 let config: LLMConfig = serde_json::from_str(json).unwrap();
1477
1478 assert_eq!(config.provider, LLMProvider::OpenAI);
1479 assert_eq!(config.endpoint, "https://api.openai.com");
1480 assert_eq!(config.model, "gpt-4o");
1481 assert_eq!(config.timeout_secs, 120);
1482 }
1483
1484 #[test]
1485 fn test_security_config_serde_defaults() {
1486 let json = r#"{}"#;
1488 let config: SecurityConfig = serde_json::from_str(json).unwrap();
1489 assert!(config.require_tls);
1490 assert_eq!(config.token_lifetime_secs, 3600);
1491 assert!(config.audit_log);
1492 }
1493
1494 #[test]
1495 fn test_runtime_config_serde_defaults() {
1496 let json = r#"{}"#;
1497 let config: RuntimeConfig = serde_json::from_str(json).unwrap();
1498 assert_eq!(config.workdir, "/tmp/ravenclaws-workdir");
1499 assert_eq!(config.max_agents, 10);
1500 assert_eq!(config.health_interval_secs, 60);
1501 }
1502
1503 #[test]
1504 fn test_ravenfabric_config_serde_defaults() {
1505 let json = r#"{}"#;
1506 let config: RavenFabricConfig = serde_json::from_str(json).unwrap();
1507 assert!(config.endpoint.is_none());
1508 assert!(config.agent_id.is_none());
1509 assert!(config.remote_exec);
1510 assert!(config.allowed_hosts.is_empty());
1511 }
1512
1513 #[test]
1514 fn test_validate_ollama_with_endpoint_succeeds() {
1515 let config = Config {
1516 llm: LLMConfig {
1517 provider: LLMProvider::Ollama,
1518 endpoint: "http://localhost:11434".to_string(),
1519 model: "llama3.1".to_string(),
1520 api_key: None,
1521 timeout_secs: 60,
1522 system_prompt: default_system_prompt(),
1523 token_budget: None,
1524 retry_max: 3,
1525 retry_base_delay_ms: 100,
1526 retry_max_delay_ms: 10000,
1527 },
1528 llms: vec![],
1529 ravenfabric: RavenFabricConfig::default(),
1530 security: SecurityConfig {
1531 require_tls: false,
1532 token_lifetime_secs: 3600,
1533 audit_log: false,
1534 prompt_injection_protection: false,
1535 },
1536 runtime: RuntimeConfig::default(),
1537 telemetry: TelemetryConfig::default(),
1538 scheduler: SchedulerConfig::default(),
1539 web_search: WebSearchConfig::default(),
1540 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1541 mcp: McpConfig::default(),
1542 swarm: crate::swarm::SwarmConfig::default(),
1543 };
1544
1545 let result = config.validate();
1546 assert!(result.is_ok());
1547 }
1548
1549 #[test]
1550 fn test_validate_openrouter_with_endpoint_succeeds() {
1551 let config = Config {
1552 llm: LLMConfig {
1553 provider: LLMProvider::OpenRouter,
1554 endpoint: "https://openrouter.ai/api".to_string(),
1555 model: "anthropic/claude-sonnet-4-20250514".to_string(),
1556 api_key: Some("or-key".to_string()),
1557 timeout_secs: 30,
1558 system_prompt: default_system_prompt(),
1559 token_budget: None,
1560 retry_max: 3,
1561 retry_base_delay_ms: 100,
1562 retry_max_delay_ms: 10000,
1563 },
1564 llms: vec![],
1565 ravenfabric: RavenFabricConfig::default(),
1566 security: SecurityConfig {
1567 require_tls: false,
1568 token_lifetime_secs: 3600,
1569 audit_log: false,
1570 prompt_injection_protection: false,
1571 },
1572 runtime: RuntimeConfig::default(),
1573 telemetry: TelemetryConfig::default(),
1574 scheduler: SchedulerConfig::default(),
1575 web_search: WebSearchConfig::default(),
1576 heartbeat: crate::heartbeat::HeartbeatConfig::default(),
1577 mcp: McpConfig::default(),
1578 swarm: crate::swarm::SwarmConfig::default(),
1579 };
1580
1581 let result = config.validate();
1582 assert!(result.is_ok());
1583 }
1584
1585 #[test]
1586 fn test_validate_litellm_with_empty_endpoint_fails() {
1587 let config = Config {
1588 llm: LLMConfig {
1589 provider: LLMProvider::LiteLLM,
1590 endpoint: String::new(),
1591 model: "gpt-4o-mini".to_string(),
1592 api_key: Some("key".to_string()),
1593 timeout_secs: 30,
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 };
1616
1617 let result = config.validate();
1618 assert!(result.is_err());
1619 assert!(result
1620 .unwrap_err()
1621 .to_string()
1622 .contains("At least one LLM provider"));
1623 }
1624
1625 #[test]
1626 fn test_llm_provider_serde_serialize() {
1627 let provider = LLMProvider::OpenAI;
1628 let json = serde_json::to_string(&provider).unwrap();
1629 assert_eq!(json, r#""openai""#);
1630
1631 let provider = LLMProvider::Ollama;
1632 let json = serde_json::to_string(&provider).unwrap();
1633 assert_eq!(json, r#""ollama""#);
1634
1635 let provider = LLMProvider::OpenRouter;
1636 let json = serde_json::to_string(&provider).unwrap();
1637 assert_eq!(json, r#""openrouter""#);
1638
1639 let provider = LLMProvider::LiteLLM;
1640 let json = serde_json::to_string(&provider).unwrap();
1641 assert_eq!(json, r#""litellm""#);
1642 }
1643}