mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-09 03:18:44 +00:00
test(guardrails): add ContentFilterGuardrail pattern prefilter integration tests
Signed-off-by: Jacinta Ferrant <jacinta.ferrant@gmail.com>
This commit is contained in:
parent
90b03faf90
commit
fc98f18664
1 changed files with 140 additions and 0 deletions
|
|
@ -4,6 +4,7 @@ Tests for the Content Filter Guardrail
|
|||
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -17,6 +18,9 @@ from fastapi import HTTPException
|
|||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import (
|
||||
ContentFilterGuardrail,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.pattern_prefilter import (
|
||||
PatternPrefilter,
|
||||
)
|
||||
from litellm.types.guardrails import (
|
||||
BlockedWord,
|
||||
ContentFilterAction,
|
||||
|
|
@ -2398,3 +2402,139 @@ class TestTracingFieldsE2E:
|
|||
# No detections, so these should be None
|
||||
assert slg.get("detection_method") is None
|
||||
assert slg.get("match_details") is None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FixedPrefilter:
|
||||
"""Test double for PatternPrefilter; no Rust extension required."""
|
||||
|
||||
result: bool
|
||||
|
||||
def any_match(self, text: str) -> bool:
|
||||
return self.result
|
||||
|
||||
|
||||
class TestPatternPrefilterIntegration:
|
||||
"""
|
||||
Regression tests for the Rust pattern pre-filter wiring in
|
||||
ContentFilterGuardrail. The pre-filter is only ever supposed to narrow
|
||||
which patterns _filter_single_text iterates over (skipping ones it has
|
||||
proven cannot match); it must never change the matching/masking result.
|
||||
These tests inject a fake prefilter via dependency injection so they run
|
||||
deterministically in CI regardless of whether the Rust extension is built.
|
||||
"""
|
||||
|
||||
def _make_guardrail(
|
||||
self,
|
||||
prefilter: PatternPrefilter,
|
||||
action: ContentFilterAction = ContentFilterAction.MASK,
|
||||
) -> ContentFilterGuardrail:
|
||||
return ContentFilterGuardrail(
|
||||
guardrail_name="prefilter-test",
|
||||
patterns=[
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt", pattern_name="us_ssn", action=action
|
||||
),
|
||||
],
|
||||
pattern_prefilter=prefilter,
|
||||
)
|
||||
|
||||
def test_clean_text_identical_with_prefilter_true_or_false(self):
|
||||
"""The pre-filter result must not change the outcome for clean text."""
|
||||
text = "hello world, nothing sensitive here"
|
||||
|
||||
full_path = self._make_guardrail(_FixedPrefilter(True))
|
||||
fast_path = self._make_guardrail(_FixedPrefilter(False))
|
||||
|
||||
assert (
|
||||
full_path._filter_single_text(text)
|
||||
== fast_path._filter_single_text(text)
|
||||
== text
|
||||
)
|
||||
|
||||
def test_real_prefilter_matches_full_path_on_matching_text(self):
|
||||
"""The auto-detected pre-filter (real Rust binding, or its
|
||||
AlwaysMatchPrefilter fallback when the extension isn't built) must
|
||||
produce identical output to the forced full path for text that
|
||||
actually contains a match; proving the fast path never causes a
|
||||
false negative in practice, not just when told not to."""
|
||||
text = "my ssn is 123-45-6789"
|
||||
|
||||
auto_detected = ContentFilterGuardrail(
|
||||
guardrail_name="auto-detect-test",
|
||||
patterns=[
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="us_ssn",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
],
|
||||
)
|
||||
forced_full_path = self._make_guardrail(_FixedPrefilter(True))
|
||||
|
||||
auto_result = auto_detected._filter_single_text(text)
|
||||
full_result = forced_full_path._filter_single_text(text)
|
||||
|
||||
assert auto_result == full_result
|
||||
assert "US_SSN_REDACTED" in auto_result
|
||||
|
||||
def test_prefilter_must_be_conservative_or_matches_are_missed(self):
|
||||
"""Documents the prefilter's contract: it is trusted completely when
|
||||
it reports no match, so a prefilter that lies will cause the
|
||||
guardrail to miss a real match. This is why build_rust_pattern_prefilter
|
||||
only ever marks a pattern as covered when its compiled Rust regex set
|
||||
agrees with what the pattern actually is; never something looser."""
|
||||
text = "my ssn is 123-45-6789"
|
||||
lying_prefilter = self._make_guardrail(_FixedPrefilter(False))
|
||||
assert lying_prefilter._filter_single_text(text) == text
|
||||
|
||||
def test_contextual_pattern_always_checked_when_prefilter_says_no_match(self):
|
||||
"""Contextual (keyword-proximity) patterns aren't covered by the
|
||||
pre-filter and must always run, even when it reports no match."""
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="contextual-test",
|
||||
patterns=[
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="us_ssn",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="ca_sin",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
],
|
||||
pattern_prefilter=_FixedPrefilter(False),
|
||||
)
|
||||
always_run_names = {p["pattern_name"] for p in guardrail._always_run_patterns}
|
||||
# ca_sin needs keyword-proximity matching, which isn't expressible as
|
||||
# a plain regex, so it must always run no matter what the pre-filter says.
|
||||
assert "ca_sin" in always_run_names
|
||||
# us_ssn has no keyword_regex/allow_word_numbers, so it's eligible for
|
||||
# the fast path and must NOT be forced into the always-run set.
|
||||
assert "us_ssn" not in always_run_names
|
||||
|
||||
def test_sequential_masking_order_preserved_on_full_path(self):
|
||||
"""Mixing a MASK-action simple pattern with a contextual pattern must
|
||||
still mask in original list order when the pre-filter takes the full
|
||||
path (mirrors production behavior when the extension is unavailable)."""
|
||||
guardrail = ContentFilterGuardrail(
|
||||
guardrail_name="sequential-mask-test",
|
||||
patterns=[
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="us_ssn",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
ContentFilterPattern(
|
||||
pattern_type="prebuilt",
|
||||
pattern_name="ca_sin",
|
||||
action=ContentFilterAction.MASK,
|
||||
),
|
||||
],
|
||||
pattern_prefilter=_FixedPrefilter(True),
|
||||
)
|
||||
result = guardrail._filter_single_text("ssn 123-45-6789 and sin 123-456-789")
|
||||
assert "US_SSN_REDACTED" in result
|
||||
assert "123-45-6789" not in result
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue