fix: guardrails issues streaming-response regex (#19901)

This commit is contained in:
Harshit Jain
2026-01-27 17:36:18 -08:00
committed by GitHub
parent 8c4ccdc313
commit d0939075bc
6 changed files with 180 additions and 136 deletions
+8 -4
View File
@@ -650,11 +650,15 @@ class ProxyBaseLLMRequestProcessing:
)
tasks = []
# Start the moderation check (during_call_hook) as early as possible
# This gives it a head start to mask/validate input while the proxy handles routing
tasks.append(
proxy_logging_obj.during_call_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
call_type=route_type, # type: ignore
asyncio.create_task(
proxy_logging_obj.during_call_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
call_type=route_type, # type: ignore
)
)
)
@@ -198,6 +198,15 @@ class ContentFilterGuardrail(CustomGuardrail):
for pattern_config in normalized_patterns:
self._add_pattern(pattern_config)
# 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
):
verbose_proxy_logger.warning(
f"ContentFilterGuardrail '{self.guardrail_name}': 'during_call' mode with 'MASK' action is unstable due to race conditions. "
"Use 'pre_call' mode for reliable request masking."
)
# Load blocked words - always initialize as dict
self.blocked_words: Dict[str, Tuple[ContentFilterAction, Optional[str]]] = {}
for word in normalized_blocked_words:
@@ -905,11 +914,15 @@ class ContentFilterGuardrail(CustomGuardrail):
elif isinstance(e.detail, str):
e.detail = e.detail + " (Image description): " + description
else:
e.detail = "Content blocked: Image description detected" + description
e.detail = (
"Content blocked: Image description detected" + description
)
raise e
def _count_masked_entities(
self, detections: List[ContentFilterDetection], masked_entity_count: Dict[str, int]
self,
detections: List[ContentFilterDetection],
masked_entity_count: Dict[str, int],
) -> None:
"""
Count masked entities by type from detections.
@@ -964,9 +977,11 @@ class ContentFilterGuardrail(CustomGuardrail):
dict(detection) for detection in detections
]
if status != "success":
guardrail_json_response = exception_str if exception_str else [
dict(detection) for detection in detections
]
guardrail_json_response = (
exception_str
if exception_str
else [dict(detection) for detection in detections]
)
self.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider=self.guardrail_provider,
@@ -1066,99 +1081,84 @@ class ContentFilterGuardrail(CustomGuardrail):
Process streaming response chunks and check for blocked content.
For BLOCK action: Raises HTTPException immediately when blocked content is detected.
For MASK action: Content passes through (masking streaming responses is not supported).
For MASK action: Content is buffered to handle patterns split across chunks.
"""
accumulated_full_text = ""
yielded_masked_text_len = 0
buffer_size = 50 # Increased buffer to catch patterns split across many chunks
# Accumulate content as we iterate through chunks
accumulated_content = ""
verbose_proxy_logger.info(
f"ContentFilterGuardrail: Starting robust streaming masking for model {request_data.get('model')}"
)
async for item in response:
# Accumulate content from this chunk before checking
if isinstance(item, ModelResponseStream) and item.choices:
delta_content = ""
is_final = False
for choice in item.choices:
if hasattr(choice, "delta") and choice.delta:
content = getattr(choice.delta, "content", None)
if content and isinstance(content, str):
accumulated_content += content
delta_content += content
if getattr(choice, "finish_reason", None):
is_final = True
# Check accumulated content for blocked patterns/keywords after processing all choices
# Only check for BLOCK actions, not MASK (masking streaming is not supported)
if accumulated_content:
try:
# Check patterns
pattern_match = self._check_patterns(accumulated_content)
if pattern_match:
matched_text, pattern_name, action = pattern_match
if action == ContentFilterAction.BLOCK:
error_msg = (
f"Content blocked: {pattern_name} pattern detected"
)
verbose_proxy_logger.warning(error_msg)
raise HTTPException(
status_code=403,
detail={
"error": error_msg,
"pattern": pattern_name,
},
)
accumulated_full_text += delta_content
# Check blocked words
blocked_word_match = self._check_blocked_words(
accumulated_content
)
if blocked_word_match:
keyword, action, description = blocked_word_match
if action == ContentFilterAction.BLOCK:
error_msg = (
f"Content blocked: keyword '{keyword}' detected"
)
if description:
error_msg += f" ({description})"
verbose_proxy_logger.warning(error_msg)
raise HTTPException(
status_code=403,
detail={
"error": error_msg,
"keyword": keyword,
"description": description,
},
)
# Check for blocking or apply masking
# Add a space at the end if it's the final chunk to trigger word boundaries (\b)
text_to_check = accumulated_full_text
if is_final:
text_to_check += " "
# Check category keywords
all_exceptions = []
for category in self.loaded_categories.values():
all_exceptions.extend(category.exceptions)
category_match = self._check_category_keywords(
accumulated_content, all_exceptions
)
if category_match:
keyword, category_name, severity, action = category_match
if action == ContentFilterAction.BLOCK:
error_msg = (
f"Content blocked: {category_name} category keyword '{keyword}' detected "
f"(severity: {severity})"
)
verbose_proxy_logger.warning(error_msg)
raise HTTPException(
status_code=403,
detail={
"error": error_msg,
"category": category_name,
"keyword": keyword,
"severity": severity,
},
)
except HTTPException:
# Re-raise HTTPException (blocked content detected)
raise
except Exception as e:
# Log other exceptions but don't block the stream
verbose_proxy_logger.warning(
f"Error checking content filter in streaming: {e}"
)
try:
masked_text = self._filter_single_text(text_to_check)
if is_final and masked_text.endswith(" "):
masked_text = masked_text[:-1]
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.error(
f"ContentFilterGuardrail: Error in masking: {e}"
)
masked_text = text_to_check # Fallback to current text
# Yield the chunk (only if no exception was raised above)
yield item
# Determine how much can be safely yielded
if is_final:
safe_to_yield_len = len(masked_text)
else:
safe_to_yield_len = max(0, len(masked_text) - buffer_size)
if safe_to_yield_len > yielded_masked_text_len:
new_masked_content = masked_text[
yielded_masked_text_len:safe_to_yield_len
]
# Modify the chunk to contain only the new masked content
if (
item.choices
and hasattr(item.choices[0], "delta")
and item.choices[0].delta
):
item.choices[0].delta.content = new_masked_content
yielded_masked_text_len = safe_to_yield_len
yield item
else:
# Hold content by yielding empty content chunk (keeps metadata/structure)
if (
item.choices
and hasattr(item.choices[0], "delta")
and item.choices[0].delta
):
item.choices[0].delta.content = ""
yield item
else:
# Not a ModelResponseStream or no choices - yield as is
yield item
# Any remaining content (should have been handled by is_final, but just in case)
if yielded_masked_text_len < len(accumulated_full_text):
# We already reached the end of the generator
pass
@staticmethod
def get_config_model():
@@ -108,7 +108,7 @@
{
"name": "ipv6",
"display_name": "IP Address (IPv6)",
"pattern": "\\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\b",
"pattern": "(?<![0-9a-fA-F:])(?:(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,7}:|:(?::[0-9a-fA-F]{1,4}){1,7}|::|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6})(?![0-9a-fA-F:])",
"category": "Network Patterns",
"description": "Detects IPv6 addresses"
},
@@ -122,9 +122,9 @@
{
"name": "passport_us",
"display_name": "Passport (US)",
"pattern": "\\b[0-9]{9}\\b",
"pattern": "\\b([A-Z][0-9]{8}|[0-9]{9})\\b",
"category": "PII Patterns",
"description": "US passport numbers (9 digits)"
"description": "US passport numbers (9 digits or alphanumeric letter + 8 digits)"
},
{
"name": "passport_uk",
@@ -157,9 +157,9 @@
{
"name": "passport_canada",
"display_name": "Passport (Canada)",
"pattern": "\\b[A-Z]{2}[0-9]{6}\\b",
"pattern": "\\b([A-Z]{2}[0-9]{6}|[A-Z][0-9]{6}[A-Z]{2})\\b",
"category": "PII Patterns",
"description": "Canadian passport numbers (2 letters + 6 digits)"
"description": "Canadian passport numbers (old: 2 letters + 6 digits; new: 1 letter + 6 digits + 2 letters)"
},
{
"name": "passport_india",
@@ -369,4 +369,4 @@
"description": "Detects Brazilian RG identity card numbers (common pattern for SP, RJ, MG states)"
}
]
}
}
+12 -1
View File
@@ -1901,7 +1901,18 @@ class ProxyLogging:
) or _callback.should_run_guardrail(
data=request_data, event_type=GuardrailEventHooks.post_call
):
if "apply_guardrail" in type(callback).__dict__:
if (
"async_post_call_streaming_iterator_hook"
in type(callback).__dict__
):
current_response = (
_callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=current_response,
request_data=request_data,
)
)
elif "apply_guardrail" in type(callback).__dict__:
request_data["guardrail_to_apply"] = callback
current_response = (
unified_guardrail.async_post_call_streaming_iterator_hook(
+22 -3
View File
@@ -20,6 +20,9 @@ from litellm.types.proxy.guardrails.guardrail_hooks.tool_permission import (
from litellm.types.proxy.guardrails.guardrail_hooks.qualifire import (
QualifireGuardrailConfigModel,
)
from litellm.types.proxy.guardrails.guardrail_hooks.litellm_content_filter import (
ContentFilterCategoryConfig,
)
"""
Pydantic object defining how to set guardrails on litellm proxy
@@ -547,9 +550,27 @@ class ContentFilterConfigModel(BaseModel):
blocked_words_file: Optional[str] = Field(
default=None, description="Path to YAML file containing blocked_words list"
)
categories: Optional[List[ContentFilterCategoryConfig]] = Field(
default=None,
description="List of prebuilt categories to enable (harmful_*, bias_*)",
)
severity_threshold: Optional[str] = Field(
default=None,
description="Minimum severity to block (high, medium, low)",
)
pattern_redaction_format: Optional[str] = Field(
default=None,
description="Format string for pattern redaction (use {pattern_name} placeholder)",
)
keyword_redaction_tag: Optional[str] = Field(
default=None,
description="Tag to use for keyword redaction",
)
class BaseLitellmParams(BaseModel): # works for new and patch update guardrails
class BaseLitellmParams(
ContentFilterConfigModel
): # works for new and patch update guardrails
api_key: Optional[str] = Field(
default=None, description="API key for the guardrail service"
)
@@ -630,7 +651,6 @@ class BaseLitellmParams(BaseModel): # works for new and patch update guardrails
description="Whether to fail the request if Model Armor encounters an error",
)
# Generic Guardrail API params
additional_provider_specific_params: Optional[Dict[str, Any]] = Field(
default=None,
description="Additional provider-specific parameters for generic guardrail APIs",
@@ -657,7 +677,6 @@ class LitellmParams(
ToolPermissionGuardrailConfigModel,
ZscalerAIGuardConfigModel,
JavelinGuardrailConfigModel,
ContentFilterConfigModel,
BaseLitellmParams,
EnkryptAIGuardrailConfigs,
IBMGuardrailsBaseConfigModel,
@@ -4,7 +4,7 @@ Tests for the Content Filter Guardrail
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import MagicMock
import pytest
@@ -385,20 +385,12 @@ class TestContentFilterGuardrail:
assert result is not None
assert result[1] == "aws_access_key"
@pytest.mark.skip(
reason="Masking in streaming responses is no longer supported after unified_guardrail.py changes. Only blocking/rejecting is supported for responses."
)
@pytest.mark.asyncio
async def test_streaming_hook_mask(self):
"""
Test streaming hook with MASK action
Note: After changes to unified_guardrail.py, masking responses to users
is no longer supported. This test is skipped as the feature is deprecated.
Only BLOCK actions (test_streaming_hook_block) are supported for streaming responses.
Test streaming hook with MASK action.
This now works with the 50-char sliding window buffer.
"""
from unittest.mock import AsyncMock
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
patterns = [
@@ -415,51 +407,54 @@ class TestContentFilterGuardrail:
event_hook=GuardrailEventHooks.during_call,
)
# Create mock streaming chunks
# Create mock streaming chunks that split an email
async def mock_stream():
# Chunk 1: contains email
chunk1 = ModelResponseStream(
# Chunk 1: starts email
yield ModelResponseStream(
id="chunk1",
choices=[
StreamingChoices(
delta=Delta(content="Contact me at test@example.com"), index=0
delta=Delta(content="Contact me at test@ex"), index=0
)
],
model="gpt-4",
)
yield chunk1
# Chunk 2: normal content
chunk2 = ModelResponseStream(
# Chunk 2: ends email
yield ModelResponseStream(
id="chunk2",
choices=[
StreamingChoices(delta=Delta(content=" for more info"), index=0)
StreamingChoices(
delta=Delta(content="ample.com for info"),
index=0,
finish_reason="stop",
)
],
model="gpt-4",
)
yield chunk2
user_api_key_dict = MagicMock()
request_data = {}
# Process streaming response - no masking expected
result_chunks = []
# Process streaming response - masking IS expected now
full_content = ""
async for chunk in guardrail.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=mock_stream(),
request_data=request_data,
):
result_chunks.append(chunk)
if chunk.choices[0].delta.content:
full_content += chunk.choices[0].delta.content
# Chunks should pass through unchanged since masking is no longer supported
assert len(result_chunks) == 2
# The email should be redacted even though it was split
assert "test@example.com" not in full_content
assert "[EMAIL_REDACTED]" in full_content
assert "Contact me at [EMAIL_REDACTED] for info" in full_content
@pytest.mark.asyncio
async def test_streaming_hook_block(self):
"""
Test streaming hook with BLOCK action
"""
from unittest.mock import AsyncMock
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
@@ -715,7 +710,10 @@ class TestContentFilterGuardrail:
assert result is not None
assert len(result) == 1
# All matches should be redacted
assert result[0] == "[CUSTOM_KEY_REDACTED] [CUSTOM_KEY_REDACTED] [CUSTOM_KEY_REDACTED]"
assert (
result[0]
== "[CUSTOM_KEY_REDACTED] [CUSTOM_KEY_REDACTED] [CUSTOM_KEY_REDACTED]"
)
assert "Key1" not in result[0]
assert "Key2" not in result[0]
@@ -797,7 +795,7 @@ class TestContentFilterGuardrail:
# Apply guardrail with content that triggers detections
# Email will be masked, blocked word will be masked
result = await guardrail.apply_guardrail(
await guardrail.apply_guardrail(
inputs={"texts": ["Contact me at test@example.com for confidential info"]},
request_data=request_data,
input_type="request",
@@ -807,7 +805,9 @@ class TestContentFilterGuardrail:
assert "metadata" in request_data
assert "standard_logging_guardrail_information" in request_data["metadata"]
guardrail_info_list = request_data["metadata"]["standard_logging_guardrail_information"]
guardrail_info_list = request_data["metadata"][
"standard_logging_guardrail_information"
]
assert isinstance(guardrail_info_list, list)
assert len(guardrail_info_list) == 1
@@ -820,8 +820,8 @@ class TestContentFilterGuardrail:
assert "start_time" in guardrail_info
assert "end_time" in guardrail_info
assert "duration" in guardrail_info
assert guardrail_info["duration"] > 0
assert guardrail_info["start_time"] < guardrail_info["end_time"]
assert guardrail_info["duration"] >= 0
assert guardrail_info["start_time"] <= guardrail_info["end_time"]
# Verify detections are logged
assert "guardrail_response" in guardrail_info
@@ -839,15 +839,21 @@ class TestContentFilterGuardrail:
assert "action" in detection
assert detection["action"] == "MASK"
# Verify sensitive content (matched_text) is NOT included
assert "matched_text" not in detection, "Sensitive content should not be logged"
assert (
"matched_text" not in detection
), "Sensitive content should not be logged"
# Verify blocked word detection structure
blocked_word_detections = [d for d in detections if d.get("type") == "blocked_word"]
blocked_word_detections = [
d for d in detections if d.get("type") == "blocked_word"
]
assert len(blocked_word_detections) > 0
for detection in blocked_word_detections:
assert detection["type"] == "blocked_word"
assert "keyword" in detection
assert detection["keyword"] == "confidential" # Config keyword, not user content
assert (
detection["keyword"] == "confidential"
) # Config keyword, not user content
assert "action" in detection
assert detection["action"] == "MASK"
assert "description" in detection
@@ -896,7 +902,9 @@ class TestContentFilterGuardrail:
assert "metadata" in request_data
assert "standard_logging_guardrail_information" in request_data["metadata"]
guardrail_info_list = request_data["metadata"]["standard_logging_guardrail_information"]
guardrail_info_list = request_data["metadata"][
"standard_logging_guardrail_information"
]
assert len(guardrail_info_list) == 1
guardrail_info = guardrail_info_list[0]
@@ -909,4 +917,6 @@ class TestContentFilterGuardrail:
# If detections are logged, verify they don't contain sensitive content
for detection in detections:
if detection.get("type") == "pattern":
assert "matched_text" not in detection, "Sensitive content should not be logged"
assert (
"matched_text" not in detection
), "Sensitive content should not be logged"