From e3f4b04ce986d61788b60815a4bdf83469486ac7 Mon Sep 17 00:00:00 2001 From: DewellNaa <210961864+DewellNaa@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:33:20 -0700 Subject: [PATCH] fix(content_filter): defer streaming buffer trim across unbounded pattern prefixes (#48007) Content filter streamed patterns with an unbounded separator or gap (e.g. generic_api_key with whitespace, street_address) were missed when the distinguishing prefix arrived in an earlier chunk than the completion. When the stream buffer exceeded twice the scan context, _trim_streamed_choice_buffer dropped the prefix because masked_head + masked_tail == masked_text held true before the completion arrived. - Support prefix_pattern in ContentFilterPattern, _PatternExtraLookup, and patterns.json - In _cut_breaks_wider_context, defer trimming when buffered_text matches an active prefix pattern while tail alone does not, ensuring the distinguishing prefix remains buffered until the completion arrives - Added regression tests covering streamed gap detection across chunks up to 2048 chars for generic_api_key, street_address, and custom regex patterns Closes #48007 --- .../litellm_content_filter/content_filter.py | 26 ++++- .../litellm_content_filter/patterns.json | 2 + litellm/types/guardrails.py | 4 + .../content_filter/test_content_filter.py | 99 ++++++++++++++++++- 4 files changed, 125 insertions(+), 6 deletions(-) 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 71b8976bebf..a3538499449 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 @@ -130,11 +130,13 @@ class CompiledPatternEntry(TypedDict): action: ContentFilterAction keyword_regex: Pattern[str] | None allow_word_numbers: bool + prefix_regex: Pattern[str] | None class _PatternExtraLookup(TypedDict): keyword_pattern: str | None allow_word_numbers: bool + prefix_pattern: str | None class _CategoryConfigView(TypedDict): @@ -224,6 +226,7 @@ class _StreamedScanPlan: context_chars: int exception_phrases: tuple[str, ...] conditional_words: tuple[str, ...] + pattern_prefixes: tuple[Pattern[str], ...] = () class CategoryFileData(TypedDict, total=False): @@ -820,7 +823,11 @@ class ContentFilterGuardrail(CustomGuardrail): pattern_config: ContentFilterPattern configuration """ try: - extra_config: _PatternExtraLookup = {"keyword_pattern": None, "allow_word_numbers": False} + extra_config: _PatternExtraLookup = { + "keyword_pattern": None, + "allow_word_numbers": False, + "prefix_pattern": None, + } if pattern_config.pattern_type == "prebuilt": if not pattern_config.pattern_name: raise ValueError("pattern_name is required for prebuilt patterns") @@ -838,6 +845,11 @@ class ContentFilterGuardrail(CustomGuardrail): keyword_pattern: Final = extra_config["keyword_pattern"] keyword_regex: Final = re.compile(keyword_pattern, re.IGNORECASE) if keyword_pattern else None + prefix_pattern: Final = ( + getattr(pattern_config, "prefix_pattern", None) or extra_config["prefix_pattern"] + ) + prefix_regex: Final = re.compile(prefix_pattern, re.IGNORECASE) if prefix_pattern else None + self.compiled_patterns.append( { "regex": compiled, @@ -845,6 +857,7 @@ class ContentFilterGuardrail(CustomGuardrail): "action": pattern_config.action, "keyword_regex": keyword_regex, "allow_word_numbers": extra_config["allow_word_numbers"], + "prefix_regex": prefix_regex, } ) verbose_proxy_logger.debug("Added pattern: %s with action %s", pattern_name, pattern_config.action) @@ -858,6 +871,7 @@ class ContentFilterGuardrail(CustomGuardrail): return { "keyword_pattern": extra.get("keyword_pattern") if extra is not None else None, "allow_word_numbers": bool(extra.get("allow_word_numbers")) if extra is not None else False, + "prefix_pattern": extra.get("prefix_pattern") if extra is not None else None, } def _load_blocked_words_file(self, file_path: str) -> None: @@ -2088,6 +2102,9 @@ class ContentFilterGuardrail(CustomGuardrail): for config in self.conditional_categories.values() for word in (*config["identifier_words"], *config["block_words"]) ), + pattern_prefixes=tuple( + p["prefix_regex"] for p in self.compiled_patterns if p.get("prefix_regex") is not None + ), ) @staticmethod @@ -2099,7 +2116,12 @@ class ContentFilterGuardrail(CustomGuardrail): cut_sentence: Final = ( SENTENCE_TERMINATORS.split(head.lower())[-1] + SENTENCE_TERMINATORS.split(tail_lower, maxsplit=1)[0] ) - return any(word in cut_sentence for word in plan.conditional_words) + if any(word in cut_sentence for word in plan.conditional_words): + return True + return any( + pattern.search(buffered_text) is not None and pattern.search(tail) is None + for pattern in plan.pattern_prefixes + ) def _trim_streamed_choice_buffer( self, state: _StreamedChoiceState, masked_text: str, plan: _StreamedScanPlan diff --git a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json index db6dd36726c..3ccbb19aa65 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json +++ b/litellm/proxy/guardrails/guardrail_hooks/litellm_content_filter/patterns.json @@ -95,6 +95,7 @@ "name": "generic_api_key", "display_name": "API Key (Generic)", "pattern": "\\b([Aa][Pp][Ii][-_]?[Kk][Ee][Yy][\\s:=]+['\"]?[A-Za-z0-9_\\-]{20,}['\"]?)\\b", + "prefix_pattern": "\\b[Aa][Pp][Ii][-_]?[Kk][Ee][Yy][\\s:=]+['\"]?$", "category": "Credential Patterns", "description": "Detects generic API key patterns" }, @@ -450,6 +451,7 @@ "name": "street_address", "display_name": "Street Address", "pattern": "\\b\\d{1,6}\\s+[A-Za-z0-9][A-Za-z0-9\\s.'-]*\\s+(?:Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Drive|Dr|Lane|Ln|Way|Court|Ct|Place|Pl|Circle|Cir)\\b", + "prefix_pattern": "\\b\\d{1,6}\\s+[A-Za-z0-9][A-Za-z0-9\\s.'-]*$", "category": "PII Patterns", "description": "Detects street addresses (number + street name + street type)" }, diff --git a/litellm/types/guardrails.py b/litellm/types/guardrails.py index 0803485aa02..5397c549875 100644 --- a/litellm/types/guardrails.py +++ b/litellm/types/guardrails.py @@ -890,6 +890,10 @@ class ContentFilterPattern(LiteLLMBaseModel): description="Name for this pattern (used in logging and error messages)", ) action: ContentFilterAction = Field(description="Action to take when pattern matches (BLOCK or MASK)") + prefix_pattern: str | None = Field( + default=None, + description="Optional regex pattern indicating a partial or open prefix of this pattern at stream boundaries", + ) class ContentFilterConfigModel(LiteLLMBaseModel): diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py b/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py index 9a14222c684..4e6410867c2 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/content_filter/test_content_filter.py @@ -9,8 +9,6 @@ from typing import Final from unittest.mock import MagicMock import pytest - - from fastapi import HTTPException from litellm import Router @@ -18,6 +16,7 @@ from litellm.constants import ( CONTENT_FILTER_STREAMING_HOLDBACK_CHARS, CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, ) +from litellm.llms.base_llm.guardrail_translation.utils import stream_item_field from litellm.proxy.guardrails.guardrail_hooks.litellm_content_filter.content_filter import ( ContentFilterGuardrail, ) @@ -27,12 +26,11 @@ from litellm.types.guardrails import ( ContentFilterPattern, GuardrailEventHooks, ) +from litellm.types.llms.openai import OutputTextDeltaEvent, ResponsesAPIStreamEvents from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import ( ContentFilterCategoryConfig, ContentFilterDetection, ) -from litellm.llms.base_llm.guardrail_translation.utils import stream_item_field -from litellm.types.llms.openai import OutputTextDeltaEvent, ResponsesAPIStreamEvents from litellm.types.utils import ( Delta, ModelResponseStream, @@ -1186,6 +1184,99 @@ class TestContentFilterGuardrail: entry: Final = metadata["standard_logging_guardrail_information"][0] assert entry["guardrail_status"] == "guardrail_intervened" + @pytest.mark.parametrize( + ("pattern_name", "prefix", "gap_unit", "completion"), + [ + ("generic_api_key", "api_key:", " ", "7391826405518273640192837465"), + ("street_address", "742 ", "word ", "Street"), + ], + ) + @pytest.mark.parametrize( + "gap_len", + [20, 256, 2 * CONTENT_FILTER_STREAMING_SCAN_CONTEXT_CHARS, 2048], + ) + @pytest.mark.asyncio + async def test_streamed_scan_detects_what_the_one_shot_scan_detects( + self, pattern_name, prefix, gap_unit, completion, gap_len + ): + """ + A pattern whose distinguishing prefix is separated from the rest of the + match by an unbounded run that can contain whitespace was detected when + the text was scanned whole, but not when the prefix and the completion + arrived in different chunks. + + Once the buffer passes twice the scan context, the streamed scan trims it + to the last context-sized tail, taking the cut only when head and tail + mask to the same output as the whole. With the prefix buffered but no + completion yet, nothing matches anywhere, so the cut looks safe and the + prefix is dropped; when the completion arrives the match can no longer + form and the match is never reported. + """ + guardrail: Final = ContentFilterGuardrail( + guardrail_name=f"test-gap-detection-{pattern_name}", + patterns=[ + ContentFilterPattern( + pattern_type="prebuilt", + pattern_name=pattern_name, + action=ContentFilterAction.MASK, + ) + ], + event_hook=GuardrailEventHooks.post_call, + ) + head: Final = prefix + gap_unit * (gap_len // len(gap_unit)) + whole: Final = head + completion + + one_shot: Final[list[ContentFilterDetection]] = [] + guardrail._filter_single_text(whole, detections=one_shot) + want: Final = sorted({d["pattern_name"] for d in one_shot if d.get("pattern_name")}) + assert want == [pattern_name], ( + f"gap={gap_len}: a one-shot scan of this text must still detect" + f" {pattern_name}, got {want}" + ) + + metadata: Final[dict[str, list[StandardLoggingGuardrailInformation]]] = {} + await self._collect_streamed_text(guardrail, [head, completion], metadata) + entry: Final = metadata["standard_logging_guardrail_information"][0] + got: Final = sorted( + { + d["pattern_name"] + for d in entry["guardrail_response"] + if d.get("pattern_name") + } + ) + + assert got == want, ( + f"gap={gap_len}: the streamed scan reported {got} but a one-shot scan" + f" of the same text reported {want}" + ) + + @pytest.mark.asyncio + async def test_streamed_scan_detects_custom_regex_with_prefix_pattern(self): + """ + Custom regex with an explicit prefix_pattern defers trimming until + the completion arrives across chunks. + """ + guardrail: Final = ContentFilterGuardrail( + guardrail_name="test-streaming-custom-prefix-pattern", + patterns=[ + ContentFilterPattern( + pattern_type="regex", + pattern=r"\bsecret_token:\s+[0-9a-f]{32}\b", + prefix_pattern=r"\bsecret_token:\s+$", + name="secret_token", + action=ContentFilterAction.MASK, + ) + ], + event_hook=GuardrailEventHooks.post_call, + ) + head: Final = "secret_token:" + " " * 2048 + completion: Final = "0123456789abcdef0123456789abcdef" + metadata: Final[dict[str, list[StandardLoggingGuardrailInformation]]] = {} + await self._collect_streamed_text(guardrail, [head, completion], metadata) + entry: Final = metadata["standard_logging_guardrail_information"][0] + got: Final = [d["pattern_name"] for d in entry["guardrail_response"] if d.get("pattern_name")] + assert got == ["secret_token"] + @pytest.mark.asyncio async def test_streaming_hook_masks_every_email_in_long_stream_and_logs_once( self,