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