diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py index c6dfe141ab5..f7e6b1947e0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/content_filter.py @@ -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"] diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/pattern_prefilter.py b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/pattern_prefilter.py new file mode 100644 index 00000000000..b822368a5d9 --- /dev/null +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/pattern_prefilter.py @@ -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]