1pub mod loader;
23pub mod validation;
24pub mod watcher;
25
26#[cfg(feature = "schema")]
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30fn default_timeout_ms() -> u64 {
39 5000
40}
41
42fn default_max_context_tokens() -> usize {
49 2048
50}
51
52fn default_retry_base_ms() -> u64 {
60 100
61}
62
63fn default_retry_max_ms() -> u64 {
69 5000
70}
71
72fn default_dedup_window_s() -> u64 {
80 300
81}
82
83fn default_dedup_max_entries() -> usize {
90 10_000
91}
92
93fn default_channel_capacity() -> usize {
102 512
103}
104
105fn default_true() -> bool {
112 true
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
126#[cfg_attr(feature = "schema", derive(JsonSchema))]
127pub struct ChannelSizes {
128 pub rag_to_assemble: Option<usize>,
130 pub assemble_to_inference: Option<usize>,
132 pub inference_to_post: Option<usize>,
134 pub post_to_stream: Option<usize>,
136 pub stream_output: Option<usize>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163#[cfg_attr(feature = "schema", derive(JsonSchema))]
164#[serde(deny_unknown_fields)]
165pub struct PipelineConfig {
166 pub pipeline: PipelineSection,
168 pub stages: StagesConfig,
170 pub resilience: ResilienceConfig,
172 #[serde(default)]
174 pub rate_limits: RateLimitConfig,
175 pub deduplication: DeduplicationConfig,
177 pub observability: ObservabilityConfig,
179 #[serde(default)]
182 pub channel_sizes: Option<ChannelSizes>,
183 #[serde(default)]
189 pub workers: WorkersConfig,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[cfg_attr(feature = "schema", derive(JsonSchema))]
201#[serde(deny_unknown_fields)]
202pub struct PipelineSection {
203 pub name: String,
205 pub version: String,
207 pub description: Option<String>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219#[cfg_attr(feature = "schema", derive(JsonSchema))]
220#[serde(deny_unknown_fields)]
221pub struct StagesConfig {
222 pub rag: RagStageConfig,
224 pub assemble: AssembleStageConfig,
226 pub inference: InferenceStageConfig,
228 pub post_process: PostProcessStageConfig,
230 pub stream: StreamStageConfig,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
242#[cfg_attr(feature = "schema", derive(JsonSchema))]
243#[serde(deny_unknown_fields)]
244pub struct RagStageConfig {
245 #[serde(default = "default_true")]
247 pub enabled: bool,
248 #[serde(default = "default_timeout_ms")]
250 pub timeout_ms: u64,
251 #[serde(default = "default_max_context_tokens")]
253 pub max_context_tokens: usize,
254 pub channel_capacity: Option<usize>,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
264#[cfg_attr(feature = "schema", derive(JsonSchema))]
265#[serde(deny_unknown_fields)]
266pub struct AssembleStageConfig {
267 #[serde(default = "default_true")]
269 pub enabled: bool,
270 #[serde(default = "default_channel_capacity")]
272 pub channel_capacity: usize,
273}
274
275fn default_inference_workers() -> usize {
282 1
283}
284
285fn default_adaptive_timeout_min_ms() -> u64 {
295 500
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
306#[cfg_attr(feature = "schema", derive(JsonSchema))]
307#[serde(deny_unknown_fields)]
308pub struct InferenceStageConfig {
309 pub worker: WorkerKind,
311 pub model: String,
313 pub max_tokens: Option<u32>,
315 pub temperature: Option<f32>,
317 pub timeout_ms: Option<u64>,
319 #[serde(default = "default_inference_workers")]
323 pub inference_workers: usize,
324 #[serde(default)]
326 pub adaptive_timeout_enabled: bool,
327 #[serde(default = "default_adaptive_timeout_min_ms")]
329 pub adaptive_timeout_min_ms: u64,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
338#[cfg_attr(feature = "schema", derive(JsonSchema))]
339#[serde(deny_unknown_fields)]
340#[serde(rename_all = "snake_case")]
341pub enum WorkerKind {
342 OpenAi,
344 Anthropic,
346 LlamaCpp,
348 Vllm,
350 Echo,
352}
353
354#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
365#[cfg_attr(feature = "schema", derive(JsonSchema))]
366pub enum Provider {
367 OpenAi,
369 Anthropic,
371 LlamaCpp,
373 Vllm,
375 Echo,
377}
378
379impl<'de> serde::Deserialize<'de> for Provider {
380 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
381 let s = String::deserialize(deserializer)?;
382 match s.to_lowercase().replace('-', "_").as_str() {
383 "openai" => Ok(Provider::OpenAi),
384 "anthropic" => Ok(Provider::Anthropic),
385 "llama_cpp" => Ok(Provider::LlamaCpp),
386 "vllm" => Ok(Provider::Vllm),
387 "echo" => Ok(Provider::Echo),
388 other => Err(serde::de::Error::custom(format!(
389 "unknown provider {other:?}; expected one of: openai, anthropic, llama_cpp, vllm, echo"
390 ))),
391 }
392 }
393}
394
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
424#[cfg_attr(feature = "schema", derive(JsonSchema))]
425#[derive(Default)]
426pub struct WorkerConfig {
427 pub model: Option<String>,
429 pub api_base_url: Option<String>,
432 pub max_tokens: Option<u32>,
434 pub temperature: Option<f32>,
436 pub host: Option<String>,
438 pub port: Option<u16>,
440}
441
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
451#[cfg_attr(feature = "schema", derive(JsonSchema))]
452pub struct WorkersConfig {
453 #[serde(default)]
455 pub openai: WorkerConfig,
456 #[serde(default)]
458 pub anthropic: WorkerConfig,
459 #[serde(default)]
461 pub llama_cpp: WorkerConfig,
462 #[serde(default)]
464 pub vllm: WorkerConfig,
465 #[serde(default)]
467 pub echo: WorkerConfig,
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
476#[cfg_attr(feature = "schema", derive(JsonSchema))]
477#[serde(deny_unknown_fields)]
478pub struct PostProcessStageConfig {
479 #[serde(default = "default_true")]
481 pub enabled: bool,
482 #[serde(default = "default_channel_capacity")]
484 pub channel_capacity: usize,
485}
486
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
493#[cfg_attr(feature = "schema", derive(JsonSchema))]
494#[serde(deny_unknown_fields)]
495pub struct StreamStageConfig {
496 #[serde(default = "default_true")]
498 pub enabled: bool,
499 #[serde(default = "default_channel_capacity")]
501 pub channel_capacity: usize,
502}
503
504fn default_rps() -> u32 {
513 100
514}
515
516fn default_burst() -> u32 {
523 20
524}
525
526fn default_rate_limit_enabled() -> bool {
533 false
534}
535
536#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
545#[cfg_attr(feature = "schema", derive(JsonSchema))]
546#[serde(deny_unknown_fields)]
547pub struct RateLimitConfig {
548 #[serde(default = "default_rate_limit_enabled")]
550 pub enabled: bool,
551 #[serde(default = "default_rps")]
553 pub requests_per_second: u32,
554 #[serde(default = "default_burst")]
556 pub burst_capacity: u32,
557}
558
559impl Default for RateLimitConfig {
560 fn default() -> Self {
561 Self {
562 enabled: default_rate_limit_enabled(),
563 requests_per_second: default_rps(),
564 burst_capacity: default_burst(),
565 }
566 }
567}
568
569#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
577#[cfg_attr(feature = "schema", derive(JsonSchema))]
578#[serde(deny_unknown_fields)]
579pub struct ResilienceConfig {
580 pub retry_attempts: u32,
582 #[serde(default = "default_retry_base_ms")]
584 pub retry_base_ms: u64,
585 #[serde(default = "default_retry_max_ms")]
587 pub retry_max_ms: u64,
588 pub circuit_breaker_threshold: u32,
590 pub circuit_breaker_timeout_s: u64,
592 pub circuit_breaker_success_rate: f64,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
604#[cfg_attr(feature = "schema", derive(JsonSchema))]
605#[serde(deny_unknown_fields)]
606pub struct DeduplicationConfig {
607 pub enabled: bool,
609 #[serde(default = "default_dedup_window_s")]
611 pub window_s: u64,
612 #[serde(default = "default_dedup_max_entries")]
614 pub max_entries: usize,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
625#[cfg_attr(feature = "schema", derive(JsonSchema))]
626#[serde(deny_unknown_fields)]
627pub struct ObservabilityConfig {
628 pub log_format: LogFormat,
630 pub metrics_port: Option<u16>,
632 pub tracing_endpoint: Option<String>,
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
642#[cfg_attr(feature = "schema", derive(JsonSchema))]
643#[serde(deny_unknown_fields)]
644#[serde(rename_all = "snake_case")]
645pub enum LogFormat {
646 Pretty,
648 Json,
650}
651
652#[cfg(feature = "schema")]
665pub fn export_schema() -> Result<String, serde_json::Error> {
666 let schema = schemars::schema_for!(PipelineConfig);
667 serde_json::to_string_pretty(&schema)
668}
669
670#[cfg(test)]
671mod tests {
672 use super::*;
673
674 #[test]
675 fn test_default_timeout_ms_returns_5000() {
676 assert_eq!(default_timeout_ms(), 5000);
677 }
678
679 #[test]
680 fn test_default_max_context_tokens_returns_2048() {
681 assert_eq!(default_max_context_tokens(), 2048);
682 }
683
684 #[test]
685 fn test_default_retry_base_ms_returns_100() {
686 assert_eq!(default_retry_base_ms(), 100);
687 }
688
689 #[test]
690 fn test_default_retry_max_ms_returns_5000() {
691 assert_eq!(default_retry_max_ms(), 5000);
692 }
693
694 #[test]
695 fn test_default_dedup_window_s_returns_300() {
696 assert_eq!(default_dedup_window_s(), 300);
697 }
698
699 #[test]
700 fn test_default_dedup_max_entries_returns_10000() {
701 assert_eq!(default_dedup_max_entries(), 10_000);
702 }
703
704 #[test]
705 fn test_default_channel_capacity_returns_512() {
706 assert_eq!(default_channel_capacity(), 512);
707 }
708
709 #[test]
710 fn test_default_true_returns_true() {
711 assert!(default_true());
712 }
713
714 #[test]
715 fn test_worker_kind_serializes_to_snake_case() {
716 let json = serde_json::to_string(&WorkerKind::OpenAi).expect("test: serialization");
717 assert_eq!(json, "\"open_ai\"");
718 }
719
720 #[test]
721 fn test_worker_kind_deserializes_from_snake_case() {
722 let kind: WorkerKind =
723 serde_json::from_str("\"llama_cpp\"").expect("test: deserialization");
724 assert_eq!(kind, WorkerKind::LlamaCpp);
725 }
726
727 #[test]
728 fn test_log_format_serializes_to_snake_case() {
729 let json = serde_json::to_string(&LogFormat::Pretty).expect("test: serialization");
730 assert_eq!(json, "\"pretty\"");
731 }
732
733 #[test]
734 fn test_log_format_deserializes_from_snake_case() {
735 let fmt: LogFormat = serde_json::from_str("\"json\"").expect("test: deserialization");
736 assert_eq!(fmt, LogFormat::Json);
737 }
738
739 #[cfg(feature = "schema")]
740 #[test]
741 fn test_export_schema_produces_valid_json() {
742 let schema = export_schema().expect("test: schema export");
743 let parsed: serde_json::Value =
744 serde_json::from_str(&schema).expect("test: schema is valid JSON");
745 assert!(parsed.get("properties").is_some() || parsed.get("$ref").is_some());
747 }
748
749 #[test]
750 fn test_pipeline_config_minimal_toml_parses() {
751 let toml_str = r#"
752[pipeline]
753name = "test"
754version = "1.0"
755
756[stages.rag]
757enabled = true
758
759[stages.assemble]
760enabled = true
761
762[stages.inference]
763worker = "echo"
764model = "test-model"
765
766[stages.post_process]
767enabled = true
768
769[stages.stream]
770enabled = true
771
772[resilience]
773retry_attempts = 3
774circuit_breaker_threshold = 5
775circuit_breaker_timeout_s = 60
776circuit_breaker_success_rate = 0.8
777
778[deduplication]
779enabled = false
780
781[observability]
782log_format = "pretty"
783"#;
784 let config: PipelineConfig = toml::from_str(toml_str).expect("test: minimal TOML parses");
785 assert_eq!(config.pipeline.name, "test");
786 assert_eq!(config.stages.inference.worker, WorkerKind::Echo);
787 assert_eq!(config.resilience.retry_base_ms, 100); assert!(!config.deduplication.enabled);
789 }
790
791 #[test]
792 fn test_pipeline_config_full_toml_parses() {
793 let toml_str = r#"
794[pipeline]
795name = "production"
796version = "1.0"
797description = "Production pipeline with OpenAI GPT-4"
798
799[stages.rag]
800enabled = true
801timeout_ms = 5000
802max_context_tokens = 2048
803
804[stages.assemble]
805enabled = true
806channel_capacity = 256
807
808[stages.inference]
809worker = "open_ai"
810model = "gpt-4"
811max_tokens = 1024
812temperature = 0.7
813timeout_ms = 30000
814
815[stages.post_process]
816enabled = true
817
818[stages.stream]
819enabled = true
820
821[resilience]
822retry_attempts = 3
823retry_base_ms = 100
824retry_max_ms = 5000
825circuit_breaker_threshold = 5
826circuit_breaker_timeout_s = 60
827circuit_breaker_success_rate = 0.8
828
829[deduplication]
830enabled = true
831window_s = 300
832max_entries = 10000
833
834[observability]
835log_format = "json"
836metrics_port = 9090
837"#;
838 let config: PipelineConfig = toml::from_str(toml_str).expect("test: full TOML parses");
839 assert_eq!(config.pipeline.name, "production");
840 assert_eq!(config.stages.inference.worker, WorkerKind::OpenAi);
841 assert_eq!(config.stages.inference.temperature, Some(0.7));
842 assert_eq!(config.stages.assemble.channel_capacity, 256);
843 assert_eq!(config.observability.metrics_port, Some(9090));
844 }
845
846 #[test]
847 fn test_pipeline_config_serialize_deserialize_roundtrip() {
848 let config = PipelineConfig {
849 pipeline: PipelineSection {
850 name: "roundtrip".into(),
851 version: "2.0".into(),
852 description: Some("Roundtrip test".into()),
853 },
854 stages: StagesConfig {
855 rag: RagStageConfig {
856 enabled: true,
857 timeout_ms: 3000,
858 max_context_tokens: 1024,
859 channel_capacity: Some(256),
860 },
861 assemble: AssembleStageConfig {
862 enabled: true,
863 channel_capacity: 512,
864 },
865 inference: InferenceStageConfig {
866 worker: WorkerKind::Anthropic,
867 model: "claude-3-opus".into(),
868 max_tokens: Some(2048),
869 temperature: Some(0.5),
870 timeout_ms: Some(60000),
871 inference_workers: 1,
872 adaptive_timeout_enabled: false,
873 adaptive_timeout_min_ms: 500,
874 },
875 post_process: PostProcessStageConfig {
876 enabled: true,
877 channel_capacity: 512,
878 },
879 stream: StreamStageConfig {
880 enabled: false,
881 channel_capacity: 128,
882 },
883 },
884 resilience: ResilienceConfig {
885 retry_attempts: 5,
886 retry_base_ms: 200,
887 retry_max_ms: 10000,
888 circuit_breaker_threshold: 10,
889 circuit_breaker_timeout_s: 120,
890 circuit_breaker_success_rate: 0.9,
891 },
892 deduplication: DeduplicationConfig {
893 enabled: true,
894 window_s: 600,
895 max_entries: 20000,
896 },
897 observability: ObservabilityConfig {
898 log_format: LogFormat::Json,
899 metrics_port: Some(8080),
900 tracing_endpoint: Some("http://jaeger:14268".into()),
901 },
902 rate_limits: RateLimitConfig::default(),
903 channel_sizes: None,
904 workers: WorkersConfig::default(),
905 };
906
907 let toml_str = toml::to_string_pretty(&config).expect("test: serialize to TOML");
908 let deserialized: PipelineConfig =
909 toml::from_str(&toml_str).expect("test: deserialize from TOML");
910 assert_eq!(config, deserialized);
911 }
912
913 #[test]
914 fn test_pipeline_config_json_roundtrip() {
915 let config = PipelineConfig {
916 pipeline: PipelineSection {
917 name: "json-rt".into(),
918 version: "1.0".into(),
919 description: None,
920 },
921 stages: StagesConfig {
922 rag: RagStageConfig {
923 enabled: true,
924 timeout_ms: 5000,
925 max_context_tokens: 2048,
926 channel_capacity: None,
927 },
928 assemble: AssembleStageConfig {
929 enabled: true,
930 channel_capacity: 512,
931 },
932 inference: InferenceStageConfig {
933 worker: WorkerKind::Echo,
934 model: "echo".into(),
935 max_tokens: None,
936 temperature: None,
937 timeout_ms: None,
938 inference_workers: 1,
939 adaptive_timeout_enabled: false,
940 adaptive_timeout_min_ms: 500,
941 },
942 post_process: PostProcessStageConfig {
943 enabled: true,
944 channel_capacity: 512,
945 },
946 stream: StreamStageConfig {
947 enabled: true,
948 channel_capacity: 512,
949 },
950 },
951 resilience: ResilienceConfig {
952 retry_attempts: 3,
953 retry_base_ms: 100,
954 retry_max_ms: 5000,
955 circuit_breaker_threshold: 5,
956 circuit_breaker_timeout_s: 60,
957 circuit_breaker_success_rate: 0.8,
958 },
959 deduplication: DeduplicationConfig {
960 enabled: false,
961 window_s: 300,
962 max_entries: 10000,
963 },
964 observability: ObservabilityConfig {
965 log_format: LogFormat::Pretty,
966 metrics_port: None,
967 tracing_endpoint: None,
968 },
969 rate_limits: RateLimitConfig::default(),
970 channel_sizes: None,
971 workers: WorkersConfig::default(),
972 };
973
974 let json = serde_json::to_string(&config).expect("test: serialize to JSON");
975 let deserialized: PipelineConfig =
976 serde_json::from_str(&json).expect("test: deserialize from JSON");
977 assert_eq!(config, deserialized);
978 }
979
980 #[test]
981 fn test_all_worker_kinds_roundtrip_toml() {
982 #[derive(Debug, Serialize, Deserialize, PartialEq)]
984 struct Wrapper {
985 kind: WorkerKind,
986 }
987
988 let kinds = vec![
989 WorkerKind::OpenAi,
990 WorkerKind::Anthropic,
991 WorkerKind::LlamaCpp,
992 WorkerKind::Vllm,
993 WorkerKind::Echo,
994 ];
995 for kind in kinds {
996 let w = Wrapper { kind: kind.clone() };
997 let s = toml::to_string(&w).expect("test: serialize worker kind");
998 let deserialized: Wrapper = toml::from_str(&s).expect("test: deserialize worker kind");
999 assert_eq!(w, deserialized);
1000 }
1001 }
1002
1003 #[test]
1004 fn test_pipeline_section_optional_description_omitted() {
1005 let toml_str = r#"
1006name = "no-desc"
1007version = "1.0"
1008"#;
1009 let section: PipelineSection =
1010 toml::from_str(toml_str).expect("test: parse without description");
1011 assert!(section.description.is_none());
1012 }
1013
1014 #[test]
1015 fn test_rag_stage_defaults_applied_when_omitted() {
1016 let toml_str = r#"
1017enabled = true
1018"#;
1019 let rag: RagStageConfig = toml::from_str(toml_str).expect("test: parse with defaults");
1020 assert_eq!(rag.timeout_ms, 5000);
1021 assert_eq!(rag.max_context_tokens, 2048);
1022 assert!(rag.channel_capacity.is_none());
1023 }
1024
1025 #[test]
1026 fn test_resilience_defaults_applied_when_omitted() {
1027 let toml_str = r#"
1028retry_attempts = 3
1029circuit_breaker_threshold = 5
1030circuit_breaker_timeout_s = 60
1031circuit_breaker_success_rate = 0.8
1032"#;
1033 let resilience: ResilienceConfig =
1034 toml::from_str(toml_str).expect("test: parse with defaults");
1035 assert_eq!(resilience.retry_base_ms, 100);
1036 assert_eq!(resilience.retry_max_ms, 5000);
1037 }
1038
1039 #[test]
1040 fn test_deduplication_defaults_applied_when_omitted() {
1041 let toml_str = r#"
1042enabled = true
1043"#;
1044 let dedup: DeduplicationConfig =
1045 toml::from_str(toml_str).expect("test: parse with defaults");
1046 assert_eq!(dedup.window_s, 300);
1047 assert_eq!(dedup.max_entries, 10_000);
1048 }
1049
1050 #[test]
1051 fn test_unknown_field_in_resilience_is_rejected() {
1052 let bad_toml = r#"
1054retry_attempts = 3
1055circuit_breaker_threshold = 5
1056circuit_breaker_timeout_s = 60
1057circuit_breaker_success_rate = 0.8
1058typo_field = "oops"
1059"#;
1060 let result = toml::from_str::<ResilienceConfig>(bad_toml);
1061 assert!(result.is_err(), "unknown fields must be rejected");
1062 let msg = result.unwrap_err().to_string();
1063 assert!(
1064 msg.contains("typo_field") || msg.contains("unknown"),
1065 "error should mention the unknown field: {msg}"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_unknown_field_in_deduplication_is_rejected() {
1071 let bad_toml = r#"
1072enabled = true
1073unknwon_key = 42
1074"#;
1075 let result = toml::from_str::<DeduplicationConfig>(bad_toml);
1076 assert!(result.is_err(), "unknown fields must be rejected");
1077 }
1078}