1use std::fmt;
26
27use async_trait::async_trait;
28use tracing::warn;
29
30use crate::types::{
31 ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError,
32};
33
34#[derive(Debug, Clone)]
36pub struct GuardrailViolation {
37 pub guardrail_name: String,
39 pub reason: String,
41}
42
43impl fmt::Display for GuardrailViolation {
44 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45 write!(f, "{}: {}", self.guardrail_name, self.reason)
46 }
47}
48
49impl From<GuardrailViolation> for RunnerError {
50 fn from(violation: GuardrailViolation) -> Self {
51 Self::guardrail(violation.to_string())
52 }
53}
54
55pub trait Guardrail: Send + Sync {
60 fn name(&self) -> &str;
62
63 fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation>;
67
68 fn check_response(
72 &self,
73 request: &ChatRequest,
74 response: &ChatResponse,
75 ) -> Result<(), GuardrailViolation>;
76}
77
78pub struct GuardrailProvider {
80 inner: Box<dyn LlmProvider>,
81 guardrails: Vec<Box<dyn Guardrail>>,
82}
83
84impl GuardrailProvider {
85 pub fn new(inner: Box<dyn LlmProvider>, guardrails: Vec<Box<dyn Guardrail>>) -> Self {
87 Self { inner, guardrails }
88 }
89
90 fn check_all_requests(&self, request: &ChatRequest) -> Result<(), RunnerError> {
92 for guardrail in &self.guardrails {
93 guardrail.check_request(request).map_err(|violation| {
94 warn!(
95 guardrail = guardrail.name(),
96 reason = %violation.reason,
97 "guardrail: pre-request check failed"
98 );
99 RunnerError::from(violation)
100 })?;
101 }
102 Ok(())
103 }
104
105 fn check_all_responses(
107 &self,
108 request: &ChatRequest,
109 response: &ChatResponse,
110 ) -> Result<(), RunnerError> {
111 for guardrail in &self.guardrails {
112 guardrail
113 .check_response(request, response)
114 .map_err(|violation| {
115 warn!(
116 guardrail = guardrail.name(),
117 reason = %violation.reason,
118 "guardrail: post-response check failed"
119 );
120 RunnerError::from(violation)
121 })?;
122 }
123 Ok(())
124 }
125}
126
127#[async_trait]
128impl LlmProvider for GuardrailProvider {
129 fn name(&self) -> &'static str {
130 self.inner.name()
131 }
132
133 fn display_name(&self) -> &str {
134 self.inner.display_name()
135 }
136
137 fn capabilities(&self) -> LlmCapabilities {
138 self.inner.capabilities()
139 }
140
141 fn default_model(&self) -> &str {
142 self.inner.default_model()
143 }
144
145 fn available_models(&self) -> &[String] {
146 self.inner.available_models()
147 }
148
149 async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
150 self.check_all_requests(request)?;
151 let response = self.inner.complete(request).await?;
152 self.check_all_responses(request, &response)?;
153 Ok(response)
154 }
155
156 async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
158 self.check_all_requests(request)?;
159 self.inner.complete_stream(request).await
160 }
161
162 async fn health_check(&self) -> Result<bool, RunnerError> {
163 self.inner.health_check().await
164 }
165}
166
167pub struct ContentLengthGuardrail {
173 pub max_message_chars: usize,
175 pub max_total_chars: usize,
177}
178
179impl Guardrail for ContentLengthGuardrail {
180 fn name(&self) -> &str {
181 "content_length"
182 }
183
184 fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
185 let mut total = 0;
186 for msg in &request.messages {
187 if msg.content.len() > self.max_message_chars {
188 return Err(GuardrailViolation {
189 guardrail_name: self.name().to_owned(),
190 reason: format!(
191 "message exceeds max length ({} > {} chars)",
192 msg.content.len(),
193 self.max_message_chars
194 ),
195 });
196 }
197 total += msg.content.len();
198 }
199 if total > self.max_total_chars {
200 return Err(GuardrailViolation {
201 guardrail_name: self.name().to_owned(),
202 reason: format!(
203 "total content exceeds max length ({total} > {} chars)",
204 self.max_total_chars
205 ),
206 });
207 }
208 Ok(())
209 }
210
211 fn check_response(
212 &self,
213 _request: &ChatRequest,
214 _response: &ChatResponse,
215 ) -> Result<(), GuardrailViolation> {
216 Ok(())
217 }
218}
219
220pub struct TopicFilterGuardrail {
222 pub blocked_patterns: Vec<String>,
224}
225
226impl Guardrail for TopicFilterGuardrail {
227 fn name(&self) -> &str {
228 "topic_filter"
229 }
230
231 fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
232 for msg in &request.messages {
233 let content_lower = msg.content.to_lowercase();
234 for pattern in &self.blocked_patterns {
235 if content_lower.contains(&pattern.to_lowercase()) {
236 return Err(GuardrailViolation {
237 guardrail_name: self.name().to_owned(),
238 reason: format!("blocked topic detected: \"{pattern}\""),
239 });
240 }
241 }
242 }
243 Ok(())
244 }
245
246 fn check_response(
247 &self,
248 _request: &ChatRequest,
249 response: &ChatResponse,
250 ) -> Result<(), GuardrailViolation> {
251 let content_lower = response.content.to_lowercase();
252 for pattern in &self.blocked_patterns {
253 if content_lower.contains(&pattern.to_lowercase()) {
254 return Err(GuardrailViolation {
255 guardrail_name: self.name().to_owned(),
256 reason: format!("blocked topic detected in response: \"{pattern}\""),
257 });
258 }
259 }
260 Ok(())
261 }
262}
263
264pub struct PiiScrubGuardrail {
269 pub check_email: bool,
271 pub check_phone: bool,
273}
274
275impl PiiScrubGuardrail {
276 fn contains_email(text: &str) -> bool {
278 let chars: Vec<char> = text.chars().collect();
279 for (i, &c) in chars.iter().enumerate() {
280 if c == '@' {
281 let has_before = i > 0 && (chars[i - 1].is_alphanumeric() || chars[i - 1] == '.');
283 let has_after =
285 i + 1 < chars.len() && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '.');
286 if has_before && has_after {
287 return true;
288 }
289 }
290 }
291 false
292 }
293
294 fn contains_phone(text: &str) -> bool {
297 let mut digit_count = 0;
298 let mut in_sequence = false;
299 for c in text.chars() {
300 if c.is_ascii_digit() {
301 digit_count += 1;
302 in_sequence = true;
303 } else if in_sequence && (c == '-' || c == ' ' || c == '.' || c == '(' || c == ')') {
304 } else {
306 if digit_count >= 7 {
307 return true;
308 }
309 digit_count = 0;
310 in_sequence = false;
311 }
312 }
313 digit_count >= 7
314 }
315}
316
317impl Guardrail for PiiScrubGuardrail {
318 fn name(&self) -> &str {
319 "pii_scrub"
320 }
321
322 fn check_request(&self, request: &ChatRequest) -> Result<(), GuardrailViolation> {
323 for msg in &request.messages {
324 if self.check_email && Self::contains_email(&msg.content) {
325 return Err(GuardrailViolation {
326 guardrail_name: self.name().to_owned(),
327 reason: "email address detected in request".to_owned(),
328 });
329 }
330 if self.check_phone && Self::contains_phone(&msg.content) {
331 return Err(GuardrailViolation {
332 guardrail_name: self.name().to_owned(),
333 reason: "phone number detected in request".to_owned(),
334 });
335 }
336 }
337 Ok(())
338 }
339
340 fn check_response(
341 &self,
342 _request: &ChatRequest,
343 response: &ChatResponse,
344 ) -> Result<(), GuardrailViolation> {
345 if self.check_email && Self::contains_email(&response.content) {
346 return Err(GuardrailViolation {
347 guardrail_name: self.name().to_owned(),
348 reason: "email address detected in response".to_owned(),
349 });
350 }
351 if self.check_phone && Self::contains_phone(&response.content) {
352 return Err(GuardrailViolation {
353 guardrail_name: self.name().to_owned(),
354 reason: "phone number detected in response".to_owned(),
355 });
356 }
357 Ok(())
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::types::{
365 ChatMessage, ChatRequest, ChatResponse, ChatStream, ErrorKind, LlmCapabilities,
366 LlmProvider, RunnerError,
367 };
368 use async_trait::async_trait;
369 use std::sync::atomic::{AtomicU32, Ordering};
370 use std::sync::Mutex;
371
372 struct TestProvider {
373 responses: Mutex<Vec<Result<ChatResponse, RunnerError>>>,
374 call_count: AtomicU32,
375 }
376
377 impl TestProvider {
378 fn ok(content: &str) -> Self {
379 Self {
380 responses: Mutex::new(vec![Ok(ChatResponse {
381 content: content.to_owned(),
382 model: "test-model".to_owned(),
383 usage: None,
384 finish_reason: Some("stop".to_owned()),
385 warnings: None,
386 tool_calls: None,
387 })]),
388 call_count: AtomicU32::new(0),
389 }
390 }
391 }
392
393 #[async_trait]
394 impl LlmProvider for TestProvider {
395 fn name(&self) -> &'static str {
396 "test"
397 }
398 fn display_name(&self) -> &str {
399 "Test Provider"
400 }
401 fn capabilities(&self) -> LlmCapabilities {
402 LlmCapabilities::text_only()
403 }
404 fn default_model(&self) -> &'static str {
405 "test-model"
406 }
407 fn available_models(&self) -> &[String] {
408 &[]
409 }
410 async fn complete(&self, _request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
411 self.call_count.fetch_add(1, Ordering::SeqCst);
412 let mut responses = self.responses.lock().expect("test lock"); if responses.is_empty() {
414 Ok(ChatResponse {
415 content: "default".to_owned(),
416 model: "test-model".to_owned(),
417 usage: None,
418 finish_reason: Some("stop".to_owned()),
419 warnings: None,
420 tool_calls: None,
421 })
422 } else {
423 responses.remove(0)
424 }
425 }
426 async fn complete_stream(&self, _request: &ChatRequest) -> Result<ChatStream, RunnerError> {
427 Err(RunnerError::internal("streaming not supported in test"))
428 }
429 async fn health_check(&self) -> Result<bool, RunnerError> {
430 Ok(true)
431 }
432 }
433
434 #[test]
439 fn content_length_rejects_long_message() {
440 let guard = ContentLengthGuardrail {
441 max_message_chars: 10,
442 max_total_chars: 1000,
443 };
444 let request = ChatRequest::new(vec![ChatMessage::user("a".repeat(11))]);
445 let result = guard.check_request(&request);
446 assert!(result.is_err());
447 assert!(result.unwrap_err().reason.contains("max length"));
448 }
449
450 #[test]
451 fn content_length_rejects_total_overflow() {
452 let guard = ContentLengthGuardrail {
453 max_message_chars: 100,
454 max_total_chars: 15,
455 };
456 let request = ChatRequest::new(vec![
457 ChatMessage::user("abcdefgh"), ChatMessage::user("ijklmnop"), ]);
460 let result = guard.check_request(&request);
461 assert!(result.is_err());
462 assert!(result.unwrap_err().reason.contains("total content"));
463 }
464
465 #[test]
466 fn content_length_accepts_within_limits() {
467 let guard = ContentLengthGuardrail {
468 max_message_chars: 100,
469 max_total_chars: 1000,
470 };
471 let request = ChatRequest::new(vec![ChatMessage::user("hello")]);
472 assert!(guard.check_request(&request).is_ok());
473 }
474
475 #[test]
476 fn content_length_response_passthrough() {
477 let guard = ContentLengthGuardrail {
478 max_message_chars: 5,
479 max_total_chars: 5,
480 };
481 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
482 let response = ChatResponse {
483 content: "a very long response that exceeds limits".to_owned(),
484 model: "m".to_owned(),
485 usage: None,
486 finish_reason: None,
487 warnings: None,
488 tool_calls: None,
489 };
490 assert!(guard.check_response(&request, &response).is_ok());
492 }
493
494 #[test]
499 fn topic_filter_blocks_request() {
500 let guard = TopicFilterGuardrail {
501 blocked_patterns: vec!["forbidden".to_owned()],
502 };
503 let request = ChatRequest::new(vec![ChatMessage::user("this is forbidden content")]);
504 let result = guard.check_request(&request);
505 assert!(result.is_err());
506 assert!(result.unwrap_err().reason.contains("forbidden"));
507 }
508
509 #[test]
510 fn topic_filter_case_insensitive() {
511 let guard = TopicFilterGuardrail {
512 blocked_patterns: vec!["SECRET".to_owned()],
513 };
514 let request = ChatRequest::new(vec![ChatMessage::user("tell me the secret")]);
515 assert!(guard.check_request(&request).is_err());
516 }
517
518 #[test]
519 fn topic_filter_allows_clean_content() {
520 let guard = TopicFilterGuardrail {
521 blocked_patterns: vec!["forbidden".to_owned()],
522 };
523 let request = ChatRequest::new(vec![ChatMessage::user("hello world")]);
524 assert!(guard.check_request(&request).is_ok());
525 }
526
527 #[test]
528 fn topic_filter_blocks_response() {
529 let guard = TopicFilterGuardrail {
530 blocked_patterns: vec!["classified".to_owned()],
531 };
532 let request = ChatRequest::new(vec![ChatMessage::user("tell me")]);
533 let response = ChatResponse {
534 content: "this information is classified".to_owned(),
535 model: "m".to_owned(),
536 usage: None,
537 finish_reason: None,
538 warnings: None,
539 tool_calls: None,
540 };
541 assert!(guard.check_response(&request, &response).is_err());
542 }
543
544 #[test]
545 fn topic_filter_multiple_patterns() {
546 let guard = TopicFilterGuardrail {
547 blocked_patterns: vec!["alpha".to_owned(), "beta".to_owned()],
548 };
549 let request = ChatRequest::new(vec![ChatMessage::user("the beta release")]);
550 assert!(guard.check_request(&request).is_err());
551 }
552
553 #[test]
558 fn pii_detects_email() {
559 let guard = PiiScrubGuardrail {
560 check_email: true,
561 check_phone: false,
562 };
563 let request = ChatRequest::new(vec![ChatMessage::user("contact me at user@example.com")]);
564 assert!(guard.check_request(&request).is_err());
565 }
566
567 #[test]
568 fn pii_detects_phone() {
569 let guard = PiiScrubGuardrail {
570 check_email: false,
571 check_phone: true,
572 };
573 let request = ChatRequest::new(vec![ChatMessage::user("call me at 555-123-4567 please")]);
574 assert!(guard.check_request(&request).is_err());
575 }
576
577 #[test]
578 fn pii_allows_clean_content() {
579 let guard = PiiScrubGuardrail {
580 check_email: true,
581 check_phone: true,
582 };
583 let request = ChatRequest::new(vec![ChatMessage::user("hello world 42")]);
584 assert!(guard.check_request(&request).is_ok());
585 }
586
587 #[test]
588 fn pii_detects_email_in_response() {
589 let guard = PiiScrubGuardrail {
590 check_email: true,
591 check_phone: false,
592 };
593 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
594 let response = ChatResponse {
595 content: "reach out to admin@corp.io".to_owned(),
596 model: "m".to_owned(),
597 usage: None,
598 finish_reason: None,
599 warnings: None,
600 tool_calls: None,
601 };
602 assert!(guard.check_response(&request, &response).is_err());
603 }
604
605 #[test]
606 fn pii_phone_without_separators() {
607 let guard = PiiScrubGuardrail {
608 check_email: false,
609 check_phone: true,
610 };
611 let request = ChatRequest::new(vec![ChatMessage::user("number is 5551234567")]);
612 assert!(guard.check_request(&request).is_err());
613 }
614
615 #[tokio::test]
620 async fn pre_request_rejection_prevents_inner_call() {
621 let provider = TestProvider::ok("should not reach");
622 let guard = TopicFilterGuardrail {
623 blocked_patterns: vec!["blocked".to_owned()],
624 };
625 let guarded = GuardrailProvider::new(Box::new(provider), vec![Box::new(guard)]);
626 let request = ChatRequest::new(vec![ChatMessage::user("this is blocked")]);
627
628 let err = guarded.complete(&request).await.unwrap_err();
629 assert_eq!(err.kind, ErrorKind::Guardrail);
630 assert!(err.message.contains("blocked"));
631 }
632
633 #[tokio::test]
634 async fn post_response_rejection() {
635 let provider = TestProvider::ok("this contains classified info");
636 let guard = TopicFilterGuardrail {
637 blocked_patterns: vec!["classified".to_owned()],
638 };
639 let guarded = GuardrailProvider::new(Box::new(provider), vec![Box::new(guard)]);
640 let request = ChatRequest::new(vec![ChatMessage::user("tell me something")]);
641
642 let err = guarded.complete(&request).await.unwrap_err();
643 assert_eq!(err.kind, ErrorKind::Guardrail);
644 assert!(err.message.contains("classified"));
645 }
646
647 #[tokio::test]
648 async fn multiple_guardrails() {
649 let provider = TestProvider::ok("safe response");
650 let length_guard = ContentLengthGuardrail {
651 max_message_chars: 5,
652 max_total_chars: 100,
653 };
654 let topic_guard = TopicFilterGuardrail {
655 blocked_patterns: vec!["forbidden".to_owned()],
656 };
657 let guarded = GuardrailProvider::new(
658 Box::new(provider),
659 vec![Box::new(length_guard), Box::new(topic_guard)],
660 );
661
662 let request = ChatRequest::new(vec![ChatMessage::user("this is too long")]);
664 let err = guarded.complete(&request).await.unwrap_err();
665 assert_eq!(err.kind, ErrorKind::Guardrail);
666 assert!(err.message.contains("content_length"));
667 }
668
669 #[tokio::test]
670 async fn empty_guardrails_passthrough() {
671 let provider = TestProvider::ok("hello");
672 let guarded = GuardrailProvider::new(Box::new(provider), vec![]);
673 let request = ChatRequest::new(vec![ChatMessage::user("hi")]);
674
675 let response = guarded.complete(&request).await.expect("should pass"); assert_eq!(response.content, "hello");
677 }
678
679 #[tokio::test]
680 async fn delegates_trait_methods() {
681 let provider = TestProvider::ok("hello");
682 let guarded = GuardrailProvider::new(Box::new(provider), vec![]);
683
684 assert_eq!(guarded.name(), "test");
685 assert_eq!(guarded.display_name(), "Test Provider");
686 assert_eq!(guarded.default_model(), "test-model");
687 assert!(guarded.capabilities().supports_streaming());
688
689 let healthy = guarded.health_check().await.expect("health check"); assert!(healthy);
691 }
692}