1use super::BinaryMiddleware;
19use crate::llms::AiModel;
20use crate::middleware::{AgentMiddleware, MiddlewareStage, pipeline::MiddlewarePipeline};
21use serde::Deserialize;
22use std::path::PathBuf;
23use std::sync::Arc;
24use std::time::Duration;
25
26fn default_timeout() -> u64 {
28 30
29}
30
31#[derive(Debug, Clone, Deserialize, Default)]
33pub struct MiddlewareConfig {
34 #[serde(default)]
36 pub before_release: Vec<MiddlewareEntry>,
37 #[serde(default)]
39 pub on_provider_response: Vec<MiddlewareEntry>,
40 #[serde(default)]
42 pub before_prompt: Vec<MiddlewareEntry>,
43 #[serde(default)]
45 pub on_completion: Vec<MiddlewareEntry>,
46 #[serde(default)]
48 pub on_job_complete: Vec<MiddlewareEntry>,
49 #[serde(skip)]
52 pub moderation_model: Option<Arc<dyn AiModel>>,
53}
54
55impl MiddlewareConfig {
56 pub fn with_moderation_model(mut self, model: Arc<dyn AiModel>) -> Self {
59 self.moderation_model = Some(model);
60 self
61 }
62}
63
64#[derive(Debug, Clone, Deserialize)]
66#[serde(untagged)]
67pub enum MiddlewareEntry {
68 Builtin {
70 builtin: BuiltinMiddlewareType,
72 #[serde(default)]
74 stages: Option<Vec<MiddlewareStage>>,
75 #[serde(default)]
77 config: serde_json::Value,
78 },
79 Dylib {
81 dylib: PathBuf,
83 #[serde(default)]
85 stages: Option<Vec<MiddlewareStage>>,
86 #[serde(default)]
91 config: serde_json::Value,
92 },
93 Binary {
95 binary: PathBuf,
97 #[serde(default)]
99 args: Vec<String>,
100 #[serde(default = "default_timeout")]
102 timeout_secs: u64,
103 #[serde(default)]
105 stages: Option<Vec<MiddlewareStage>>,
106 },
107}
108
109#[derive(Debug, Clone, Deserialize)]
111#[serde(rename_all = "snake_case")]
112pub enum BuiltinMiddlewareType {
113 SignatureVerification,
115 RuleBased,
117 LlmModeration,
119 PromptExposure,
122}
123
124impl MiddlewareConfig {
125 pub fn build_before_release_pipeline(&self) -> Result<MiddlewarePipeline, String> {
128 self.build_pipeline(
129 &self.before_release,
130 &[MiddlewareStage::Edit, MiddlewareStage::Release],
131 )
132 }
133
134 pub fn build_provider_response_pipeline(&self) -> Result<MiddlewarePipeline, String> {
136 self.build_pipeline(
137 &self.on_provider_response,
138 &[MiddlewareStage::ProviderResponse],
139 )
140 }
141
142 pub fn build_before_prompt_pipeline(&self) -> Result<MiddlewarePipeline, String> {
144 self.build_pipeline(&self.before_prompt, &[MiddlewareStage::BeforePrompt])
145 }
146
147 pub fn build_completion_pipeline(&self) -> Result<MiddlewarePipeline, String> {
149 self.build_pipeline(&self.on_completion, &[MiddlewareStage::Completion])
150 }
151
152 pub fn build_job_complete_pipeline(&self) -> Result<MiddlewarePipeline, String> {
154 self.build_pipeline(&self.on_job_complete, &[MiddlewareStage::JobComplete])
155 }
156
157 pub fn is_empty(&self) -> bool {
159 self.before_release.is_empty()
160 && self.on_provider_response.is_empty()
161 && self.before_prompt.is_empty()
162 && self.on_completion.is_empty()
163 && self.on_job_complete.is_empty()
164 }
165
166 fn build_pipeline(
167 &self,
168 entries: &[MiddlewareEntry],
169 default_stages: &[MiddlewareStage],
170 ) -> Result<MiddlewarePipeline, String> {
171 let middleware = entries
176 .iter()
177 .map(|entry| self.build_entry(entry, default_stages))
178 .collect::<Result<Vec<_>, _>>()?;
179 Ok(MiddlewarePipeline::new(middleware))
180 }
181
182 fn build_entry(
183 &self,
184 entry: &MiddlewareEntry,
185 default_stages: &[MiddlewareStage],
186 ) -> Result<Box<dyn AgentMiddleware>, String> {
187 match entry {
188 MiddlewareEntry::Builtin {
189 builtin,
190 stages,
191 config,
192 } => {
193 let active_stages = stages
194 .as_ref()
195 .cloned()
196 .unwrap_or_else(|| default_stages.to_vec());
197
198 match super::builtin::create_builtin_middleware(
199 builtin,
200 config,
201 active_stages,
202 self.moderation_model.clone(),
203 ) {
204 Ok(mw) => {
205 tracing::info!(
206 builtin_type = ?builtin,
207 "Loaded builtin middleware"
208 );
209 Ok(mw)
210 }
211 Err(e) => Err(format!(
212 "failed to create builtin middleware {builtin:?}: {e}"
213 )),
214 }
215 }
216 MiddlewareEntry::Dylib {
217 dylib,
218 stages,
219 config,
220 } => {
221 let active_stages = stages
222 .as_ref()
223 .cloned()
224 .unwrap_or_else(|| default_stages.to_vec());
225
226 match unsafe { super::DylibMiddleware::load(dylib, active_stages, config.clone()) }
229 {
230 Ok(mw) => {
231 tracing::info!(
232 dylib = ?dylib,
233 "Loaded dynamic library middleware"
234 );
235 Ok(Box::new(mw))
236 }
237 Err(e) => Err(format!(
241 "failed to load dynamic library middleware {dylib:?}: {e} \
242 — fix the dylib path or remove it from config"
243 )),
244 }
245 }
246 MiddlewareEntry::Binary {
247 binary,
248 args,
249 timeout_secs,
250 stages,
251 } => {
252 let active_stages = stages
253 .as_ref()
254 .cloned()
255 .unwrap_or_else(|| default_stages.to_vec());
256
257 let name = binary
258 .file_name()
259 .and_then(|n| n.to_str())
260 .unwrap_or("binary")
261 .to_string();
262
263 Ok(Box::new(BinaryMiddleware {
264 display_name: name,
265 path: binary.clone(),
266 args: args.clone(),
267 timeout: Duration::from_secs(*timeout_secs),
268 active_stages,
269 }))
270 }
271 }
272 }
273}
274
275#[cfg(test)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn config_deserialize_empty() {
285 let yaml = "{}";
286 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
287 assert!(config.is_empty());
288 }
289
290 #[test]
291 fn config_deserialize_binary_entry() {
292 let yaml = r#"
293before_release:
294 - binary: ./hooks/moderate
295 timeout_secs: 15
296 stages: [release]
297"#;
298 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
299 assert_eq!(config.before_release.len(), 1);
300 match &config.before_release[0] {
301 MiddlewareEntry::Binary {
302 binary,
303 timeout_secs,
304 stages,
305 ..
306 } => {
307 assert_eq!(binary, &PathBuf::from("./hooks/moderate"));
308 assert_eq!(*timeout_secs, 15);
309 assert_eq!(stages.as_ref().unwrap(), &[MiddlewareStage::Release]);
310 }
311 _ => panic!("Expected Binary entry"),
312 }
313 }
314
315 #[test]
316 fn config_deserialize_builtin_entry() {
317 let yaml = r#"
318before_release:
319 - builtin: rule_based
320 stages: [edit, release]
321 config:
322 max_content_length: 50000
323 pii_patterns: true
324"#;
325 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
326 assert_eq!(config.before_release.len(), 1);
327 match &config.before_release[0] {
328 MiddlewareEntry::Builtin {
329 builtin,
330 stages,
331 config,
332 } => {
333 assert!(matches!(builtin, BuiltinMiddlewareType::RuleBased));
334 assert_eq!(
335 stages.as_ref().unwrap(),
336 &[MiddlewareStage::Edit, MiddlewareStage::Release]
337 );
338 assert_eq!(config["max_content_length"], 50000);
339 assert_eq!(config["pii_patterns"], true);
340 }
341 _ => panic!("Expected Builtin entry"),
342 }
343 }
344
345 #[test]
346 fn config_deserialize_mixed() {
347 let yaml = r#"
348before_release:
349 - builtin: signature_verification
350 stages: [release]
351 - binary: ./hooks/moderate
352 timeout_secs: 30
353 stages: [release]
354on_provider_response:
355 - binary: ./hooks/transform
356 timeout_secs: 10
357"#;
358 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
359 assert_eq!(config.before_release.len(), 2);
360 assert_eq!(config.on_provider_response.len(), 1);
361 assert!(!config.is_empty());
362 }
363
364 #[test]
365 fn config_default_timeout() {
366 let yaml = r#"
367before_release:
368 - binary: ./hooks/check
369"#;
370 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
371 match &config.before_release[0] {
372 MiddlewareEntry::Binary { timeout_secs, .. } => {
373 assert_eq!(*timeout_secs, 30); }
375 _ => panic!("Expected Binary entry"),
376 }
377 }
378
379 #[test]
380 fn build_binary_pipeline() {
381 let yaml = r#"
382before_release:
383 - binary: /bin/true
384 timeout_secs: 5
385"#;
386 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
387 let pipeline = config.build_before_release_pipeline().unwrap();
388 assert_eq!(pipeline.len(), 1);
389 assert!(!pipeline.is_empty());
390 }
391
392 #[test]
393 fn build_builtin_pipeline_creates_rule_based() {
394 let yaml = r#"
395before_release:
396 - builtin: rule_based
397 config: {}
398"#;
399 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
400 let pipeline = config.build_before_release_pipeline().unwrap();
401 assert_eq!(pipeline.len(), 1);
403 }
404
405 #[test]
406 fn unloadable_dylib_fails_closed_not_dropped() {
407 let yaml = r#"
410before_prompt:
411 - dylib: /nonexistent/path/to/guard.so
412"#;
413 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
414 let result = config.build_before_prompt_pipeline();
415 assert!(
416 result.is_err(),
417 "an unloadable dylib guard must fail closed (Err), got Ok"
418 );
419 assert!(result.unwrap_err().contains("dynamic library"));
420 }
421
422 #[test]
423 fn dylib_entry_parses_config() {
424 let yaml = r#"
425before_prompt:
426 - dylib: /nonexistent.dylib
427 config:
428 patch_deliberation:
429 upstream: epic
430 downstream_root: ./downstreams
431"#;
432 let config: MiddlewareConfig = serde_yaml::from_str(yaml).unwrap();
433 match &config.before_prompt[0] {
434 MiddlewareEntry::Dylib { dylib, config, .. } => {
435 assert_eq!(dylib.to_str(), Some("/nonexistent.dylib"));
436 assert_eq!(
437 config["patch_deliberation"]["upstream"],
438 serde_json::json!("epic")
439 );
440 assert_eq!(
441 config["patch_deliberation"]["downstream_root"],
442 serde_json::json!("./downstreams")
443 );
444 }
445 other => panic!("expected Dylib entry, got {other:?}"),
446 }
447 }
448}