feat(guardrails): wire a Rust pattern prefilter into ContentFilterGuardrail

Signed-off-by: Jacinta Ferrant <jacinta.ferrant@gmail.com>
This commit is contained in:
Jacinta Ferrant 2026-06-18 20:55:28 -07:00
parent b31a7fa2c8
commit 6e5c5aaa26
No known key found for this signature in database
2 changed files with 110 additions and 2 deletions

View file

@ -48,6 +48,12 @@ from litellm.types.guardrails import (
GuardrailEventHooks,
Mode,
)
from .pattern_prefilter import (
AlwaysMatchPrefilter,
PatternPrefilter,
build_rust_pattern_prefilter,
)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
BlockedWordDetection,
CategoryKeywordDetection,
@ -169,6 +175,7 @@ class ContentFilterGuardrail(CustomGuardrail):
llm_router: Optional[Router] = None,
image_model: Optional[str] = None,
competitor_intent_config: Optional[Dict[str, Any]] = None,
pattern_prefilter: Optional[PatternPrefilter] = None,
**kwargs,
):
"""
@ -242,6 +249,10 @@ class ContentFilterGuardrail(CustomGuardrail):
for pattern_config in normalized_patterns:
self._add_pattern(pattern_config)
self._pattern_prefilter, self._always_run_patterns = (
self._build_pattern_prefilter(pattern_prefilter)
)
# Warn if using during_call with MASK action (unstable)
if self.event_hook == GuardrailEventHooks.during_call and any(
p["action"] == ContentFilterAction.MASK for p in self.compiled_patterns
@ -779,6 +790,48 @@ class ContentFilterGuardrail(CustomGuardrail):
verbose_proxy_logger.error(f"Error adding pattern {pattern_config}: {e}")
raise
def _build_pattern_prefilter(
self, injected_prefilter: Optional[PatternPrefilter]
) -> Tuple[PatternPrefilter, Tuple[Dict[str, Any], ...]]:
"""
Partition compiled_patterns into ones a fast pre-filter can rule out
and ones that must always run (contextual keyword-proximity patterns,
or any pattern whose regex syntax the pre-filter can't represent).
Returns the pre-filter and the "always run" subset. When the
pre-filter reports no match, _filter_single_text only iterates the
"always run" subset; otherwise it iterates compiled_patterns in full,
unchanged. The pre-filter therefore only ever skips patterns it has
proven cannot match. It never changes what gets matched or masked.
"""
def is_contextual(p: Dict[str, Any]) -> bool:
return p["keyword_regex"] is not None or p["allow_word_numbers"]
if injected_prefilter is not None:
return injected_prefilter, tuple(
p for p in self.compiled_patterns if is_contextual(p)
)
simple_patterns = tuple(
p for p in self.compiled_patterns if not is_contextual(p)
)
if not simple_patterns:
return AlwaysMatchPrefilter(), tuple(self.compiled_patterns)
prefilter, uncovered_indices = build_rust_pattern_prefilter(
[p["regex"].pattern for p in simple_patterns]
)
uncovered_simple_patterns = {
id(p) for i, p in enumerate(simple_patterns) if i in uncovered_indices
}
always_run_patterns = tuple(
p
for p in self.compiled_patterns
if is_contextual(p) or id(p) in uncovered_simple_patterns
)
return prefilter, always_run_patterns
def _load_blocked_words_file(self, file_path: str) -> None:
"""
Load blocked words from a YAML file.
@ -1456,8 +1509,16 @@ class ContentFilterGuardrail(CustomGuardrail):
keyword, category_name, severity, action, text, detections
)
# Check regex patterns - process ALL patterns, not just first match
for pattern_entry in self.compiled_patterns:
# Check regex patterns - process ALL patterns, not just first match.
# The pre-filter only ever narrows this to a subset it has proven
# cannot match; when it can't rule anything out, this is identical
# to iterating self.compiled_patterns directly.
patterns_to_check = (
self.compiled_patterns
if self._pattern_prefilter.any_match(text)
else self._always_run_patterns
)
for pattern_entry in patterns_to_check:
spans = self._find_pattern_spans(text, pattern_entry)
if spans:
pattern_name = pattern_entry["pattern_name"]

View file

@ -0,0 +1,47 @@
"""
Pattern pre-filter used by ContentFilterGuardrail to skip regex patterns that
cannot possibly match a given text, without changing matching/masking
semantics for patterns that might.
"""
from dataclasses import dataclass
from typing import FrozenSet, List, Protocol, Tuple
class PatternPrefilter(Protocol):
def any_match(self, text: str) -> bool: ...
@dataclass(frozen=True)
class AlwaysMatchPrefilter:
"""Safe default: never skips a pattern. Used when the Rust extension
isn't installed, or when no pattern was eligible for the fast path."""
def any_match(self, text: str) -> bool:
return True
def build_rust_pattern_prefilter(
pattern_sources: List[str],
) -> Tuple[PatternPrefilter, FrozenSet[int]]:
"""
Build a Rust-backed prefilter for `pattern_sources`.
Returns the prefilter and the indices of `pattern_sources` it does not
cover (incompatible regex syntax, e.g. lookaround/backreferences). Those
must keep going through the existing per-pattern path. Falls back to a
pass-through prefilter covering nothing if the Rust extension isn't built.
"""
try:
import litellm_core # pyright: ignore[reportMissingTypeStubs]
except ImportError:
return AlwaysMatchPrefilter(), frozenset()
prefilter, rejected_indices = (
litellm_core.build_pattern_prefilter( # pyright: ignore[reportAttributeAccessIssue,reportUnknownVariableType,reportUnknownMemberType]
pattern_sources
)
)
return prefilter, frozenset(
rejected_indices
) # pyright: ignore[reportUnknownArgumentType]