This commit is contained in:
DewellNaa 2026-11-06 16:38:56 +09:00 • committed by GitHub
commit 168930c66d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 125 additions and 6 deletions

View file

@ -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

View file

@ -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)"
},

View file

@ -900,6 +900,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):

View file

@ -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,