mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
Merge e3f4b04ce9 into 08f2a98250
This commit is contained in:
commit
168930c66d
4 changed files with 125 additions and 6 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue