Guardrails API - add streaming support (#17400)

* fix(initial-commit): adding a way to get the right response type based on the api route

* feat(unified_guardrail.py): support streaming guardrails

* test: update tests

* fix: fix linting errors

* test: update tests
This commit is contained in:
Krish Dholakia
2025-12-02 22:52:09 -08:00
committed by GitHub
parent 74ba18df55
commit 8edcc4ecc3
21 changed files with 1134 additions and 379 deletions
-2
View File
@@ -18,8 +18,6 @@ from typing import Any, Coroutine, Dict, Literal, Optional, Union, cast
import httpx
from openai.types.batch import BatchRequestCounts
from openai.types.batch import Metadata
from openai.types.batch import Metadata as OpenAIBatchMetadata
import litellm
from litellm._logging import verbose_logger
+13 -5
View File
@@ -262,7 +262,9 @@ TOGETHER_AI_EMBEDDING_350_M = int(os.getenv("TOGETHER_AI_EMBEDDING_350_M", 350))
QDRANT_SCALAR_QUANTILE = float(os.getenv("QDRANT_SCALAR_QUANTILE", 0.99))
QDRANT_VECTOR_SIZE = int(os.getenv("QDRANT_VECTOR_SIZE", 1536))
CACHED_STREAMING_CHUNK_DELAY = float(os.getenv("CACHED_STREAMING_CHUNK_DELAY", 0.02))
AUDIO_SPEECH_CHUNK_SIZE = 8192 # chunk_size for audio speech streaming. Balance between latency and memory usage
AUDIO_SPEECH_CHUNK_SIZE = int(
os.getenv("AUDIO_SPEECH_CHUNK_SIZE", 8192)
) # chunk_size for audio speech streaming. Balance between latency and memory usage
MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB = int(
os.getenv("MAX_SIZE_PER_ITEM_IN_MEMORY_CACHE_IN_KB", 512)
)
@@ -285,10 +287,16 @@ REDACTED_BY_LITELM_STRING = "REDACTED_BY_LITELM"
MAX_LANGFUSE_INITIALIZED_CLIENTS = int(
os.getenv("MAX_LANGFUSE_INITIALIZED_CLIENTS", 50)
)
LOGGING_WORKER_CONCURRENCY = int(os.getenv("LOGGING_WORKER_CONCURRENCY", 100)) # Must be above 0
LOGGING_WORKER_CONCURRENCY = int(
os.getenv("LOGGING_WORKER_CONCURRENCY", 100)
) # Must be above 0
LOGGING_WORKER_MAX_QUEUE_SIZE = int(os.getenv("LOGGING_WORKER_MAX_QUEUE_SIZE", 50_000))
LOGGING_WORKER_MAX_TIME_PER_COROUTINE = float(os.getenv("LOGGING_WORKER_MAX_TIME_PER_COROUTINE", 20.0))
LOGGING_WORKER_CLEAR_PERCENTAGE = int(os.getenv("LOGGING_WORKER_CLEAR_PERCENTAGE", 50)) # Percentage of queue to clear (default: 50%)
LOGGING_WORKER_MAX_TIME_PER_COROUTINE = float(
os.getenv("LOGGING_WORKER_MAX_TIME_PER_COROUTINE", 20.0)
)
LOGGING_WORKER_CLEAR_PERCENTAGE = int(
os.getenv("LOGGING_WORKER_CLEAR_PERCENTAGE", 50)
) # Percentage of queue to clear (default: 50%)
MAX_ITERATIONS_TO_CLEAR_QUEUE = int(os.getenv("MAX_ITERATIONS_TO_CLEAR_QUEUE", 200))
MAX_TIME_TO_CLEAR_QUEUE = float(os.getenv("MAX_TIME_TO_CLEAR_QUEUE", 5.0))
LOGGING_WORKER_AGGRESSIVE_CLEAR_COOLDOWN_SECONDS = float(
@@ -866,7 +874,7 @@ BEDROCK_INVOKE_PROVIDERS_LITERAL = Literal[
"deepseek_r1",
"qwen3",
"twelvelabs",
"openai"
"openai",
]
BEDROCK_EMBEDDING_PROVIDERS_LITERAL = Literal[
-1
View File
@@ -20,7 +20,6 @@ from litellm.types.guardrails import (
GuardrailEventHooks,
LitellmParams,
Mode,
PiiEntityType,
)
from litellm.types.llms.openai import AllMessageValues
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
+1
View File
@@ -9,4 +9,5 @@ Core files:
- `default_encoding.py`: code for loading the default encoding (tiktoken)
- `get_llm_provider_logic.py`: code for inferring the LLM provider from a given model name.
- `duration_parser.py`: code for parsing durations - e.g. "1d", "1mo", "10s"
- `api_route_to_call_types.py`: mapping of API routes to their corresponding CallTypes (e.g., `/chat/completions` -> [acompletion, completion])
@@ -0,0 +1,38 @@
"""
Dictionary mapping API routes to their corresponding CallTypes in LiteLLM.
This dictionary maps each API endpoint to the CallTypes that can be used for that route.
Each route can have both async (prefixed with 'a') and sync call types.
"""
from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes
def get_call_types_for_route(route: str) -> list:
"""
Get the list of CallTypes for a given API route.
Args:
route: API route path (e.g., "/chat/completions")
Returns:
List of CallTypes for that route, or empty list if route not found
"""
return API_ROUTE_TO_CALL_TYPES.get(route, [])
def get_routes_for_call_type(call_type: CallTypes) -> list:
"""
Get all routes that use a specific CallType.
Args:
call_type: The CallType to search for
Returns:
List of routes that use this CallType
"""
routes = []
for route, types in API_ROUTE_TO_CALL_TYPES.items():
if call_type in types:
routes.append(route)
return routes
@@ -84,3 +84,17 @@ class BaseTranslation(ABC):
user_api_key_dict: User API key metadata (passed separately since response doesn't contain it)
"""
pass
async def process_output_streaming_response(
self,
response: Any,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None,
user_api_key_dict: Optional["UserAPIKeyAuth"] = None,
) -> Any:
"""
Process output streaming response with guardrails.
Optional to override in subclasses.
"""
return response
@@ -14,16 +14,16 @@ Pattern Overview:
This pattern can be replicated for other message formats (e.g., Anthropic).
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, cast
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, cast
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.types.utils import Choices
from litellm.types.utils import Choices, StreamingChoices
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.types.utils import ModelResponse
from litellm.types.utils import ModelResponse, ModelResponseStream
class OpenAIChatCompletionsHandler(BaseTranslation):
@@ -241,21 +241,79 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
return response
def _has_text_content(self, response: "ModelResponse") -> bool:
async def process_output_streaming_response(
self,
response: "ModelResponseStream",
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional[Any] = None,
user_api_key_dict: Optional[Any] = None,
) -> Any:
"""
Process output streaming response by applying guardrails to text content.
Args:
response: LiteLLM ModelResponseStream object
guardrail_to_apply: The guardrail instance to apply
litellm_logging_obj: Optional logging object
user_api_key_dict: User API key metadata to pass to guardrails
Returns:
Modified response with guardrail applied to content
Response Format Support:
- String content: choice.message.content = "text here"
- List content: choice.message.content = [{"type": "text", "text": "text here"}, ...]
"""
# Step 0: Check if response has any text content to process
if not self._has_text_content(response):
return response
texts_to_check: List[str] = []
images_to_check: List[str] = []
task_mappings: List[Tuple[int, Optional[int]]] = []
# Track (choice_index, content_index) for each text
# Step 1: Extract all text content and images from response choices
for choice_idx, choice in enumerate(response.choices):
self._extract_output_text_and_images(
choice=choice,
choice_idx=choice_idx,
texts_to_check=texts_to_check,
images_to_check=images_to_check,
task_mappings=task_mappings,
)
def _has_text_content(
self, response: Union["ModelResponse", "ModelResponseStream"]
) -> bool:
"""
Check if response has any text content to process.
Override this method to customize text content detection.
"""
for choice in response.choices:
if isinstance(choice, litellm.Choices):
if choice.message.content and isinstance(choice.message.content, str):
return True
from litellm.types.utils import ModelResponse, ModelResponseStream
if isinstance(response, ModelResponse):
for choice in response.choices:
if isinstance(choice, litellm.Choices):
if choice.message.content and isinstance(
choice.message.content, str
):
return True
elif isinstance(response, ModelResponseStream):
for choice in response.choices:
if isinstance(choice, litellm.Choices):
if choice.message.content and isinstance(
choice.message.content, str
):
return True
return False
def _extract_output_text_and_images(
self,
choice: Any,
choice: Union[Choices, StreamingChoices],
choice_idx: int,
texts_to_check: List[str],
images_to_check: List[str],
@@ -266,21 +324,29 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
Override this method to customize text/image extraction logic.
"""
if not isinstance(choice, litellm.Choices):
return
verbose_proxy_logger.debug(
"OpenAI Chat Completions: Processing choice: %s", choice
)
if choice.message.content and isinstance(choice.message.content, str):
# Determine content source based on choice type
content = None
if isinstance(choice, litellm.Choices):
content = choice.message.content
elif isinstance(choice, litellm.StreamingChoices):
content = choice.delta.content
else:
# Unknown choice type, skip processing
return
# Process content if it exists
if content and isinstance(content, str):
# Simple string content
texts_to_check.append(choice.message.content)
texts_to_check.append(content)
task_mappings.append((choice_idx, None))
elif choice.message.content and isinstance(choice.message.content, list):
elif content and isinstance(content, list):
# List content (e.g., multimodal response)
for content_idx, content_item in enumerate(choice.message.content):
for content_idx, content_item in enumerate(content):
# Extract text
content_text = content_item.get("text")
if content_text:
@@ -269,6 +269,71 @@
"supports_response_schema": true,
"supports_vision": true
},
"amazon.nova-2-lite-v1:0": {
"input_cost_per_token": 3e-07,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.5e-06,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_video_input": true,
"supports_vision": true
},
"apac.amazon.nova-2-lite-v1:0": {
"input_cost_per_token": 6e-08,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.75e-06,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_video_input": true,
"supports_vision": true
},
"eu.amazon.nova-2-lite-v1:0": {
"input_cost_per_token": 6e-08,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.75e-06,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_video_input": true,
"supports_vision": true
},
"us.amazon.nova-2-lite-v1:0": {
"input_cost_per_token": 6e-08,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 1000000,
"max_output_tokens": 64000,
"max_tokens": 64000,
"mode": "chat",
"output_cost_per_token": 2.75e-06,
"supports_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_video_input": true,
"supports_vision": true
},
"amazon.nova-micro-v1:0": {
"input_cost_per_token": 3.5e-08,
"litellm_provider": "bedrock_converse",
@@ -13,6 +13,7 @@ from litellm.caching.caching import DualCache
from litellm.cost_calculator import _infer_call_type
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
from litellm.llms import load_guardrail_translation_mappings
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.guardrails import GuardrailEventHooks
@@ -176,6 +177,113 @@ class UnifiedLLMGuardrails(CustomLogger):
See Aim guardrail implementation for an example - https://github.com/BerriAI/litellm/blob/d0e022cfacb8e9ebc5409bb652059b6fd97b45c0/litellm/proxy/guardrails/guardrail_hooks/aim.py#L168
Triggered by mode: 'post_call'
Supports sampling_rate parameter to control how often chunks are processed.
sampling_rate=1 means every chunk, sampling_rate=5 means every 5th chunk, etc.
"""
global endpoint_guardrail_translation_mappings
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
)
guardrail_to_apply: CustomGuardrail = request_data.pop(
"guardrail_to_apply", None
)
# Get sampling rate from guardrail config or optional_params, default to 5
sampling_rate = 5
if guardrail_to_apply is not None:
# Check guardrail config first
guardrail_config = getattr(guardrail_to_apply, "guardrail_config", {})
sampling_rate = guardrail_config.get(
"streaming_sampling_rate", sampling_rate
)
# Also check optional_params as fallback
sampling_rate = self.optional_params.get(
"streaming_sampling_rate", sampling_rate
)
if guardrail_to_apply is None:
async for item in response:
yield item
return
event_type: GuardrailEventHooks = GuardrailEventHooks.post_call
if (
guardrail_to_apply.should_run_guardrail(
data=request_data, event_type=event_type
)
is not True
):
verbose_proxy_logger.debug(
"UnifiedLLMGuardrails: Post-call streaming scanning disabled for %s",
guardrail_to_apply.guardrail_name,
)
async for item in response:
yield item
return
# Initialize translation mappings if needed
if endpoint_guardrail_translation_mappings is None:
endpoint_guardrail_translation_mappings = (
load_guardrail_translation_mappings()
)
# Infer call type from first chunk
call_type = None
chunk_counter = 0
async for item in response:
yield item
chunk_counter += 1
# Infer call type from first chunk if not already done
if call_type is None and user_api_key_dict.request_route is not None:
call_types = get_call_types_for_route(user_api_key_dict.request_route)
if call_types is not None:
call_type = call_types[0]
# If call type not supported, just pass through all chunks
if (
call_type is None
or CallTypes(call_type)
not in endpoint_guardrail_translation_mappings
):
yield item
async for remaining_item in response:
yield remaining_item
return
# Process chunk based on sampling rate
if chunk_counter % sampling_rate == 0:
verbose_proxy_logger.debug(
"Processing streaming chunk %s (sampling_rate=%s) with guardrail %s",
chunk_counter,
sampling_rate,
guardrail_to_apply.guardrail_name,
)
endpoint_translation = endpoint_guardrail_translation_mappings[
CallTypes(call_type)
]()
processed_item = (
await endpoint_translation.process_output_streaming_response(
response=item,
guardrail_to_apply=guardrail_to_apply,
litellm_logging_obj=request_data.get("litellm_logging_obj"),
user_api_key_dict=user_api_key_dict,
)
)
# Add guardrail to applied guardrails header (only once, on first processed chunk)
if chunk_counter == sampling_rate:
add_guardrail_to_applied_guardrails_header(
request_data=request_data,
guardrail_name=guardrail_to_apply.guardrail_name,
)
yield processed_item
else:
yield item
+16 -5
View File
@@ -1560,6 +1560,7 @@ class ProxyLogging:
Covers:
1. /chat/completions
"""
for callback in litellm.callbacks:
_callback: Optional[CustomLogger] = None
if isinstance(callback, str):
@@ -1574,11 +1575,21 @@ class ProxyLogging:
) or _callback.should_run_guardrail(
data=request_data, event_type=GuardrailEventHooks.post_call
):
response = _callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=response,
request_data=request_data,
)
if "apply_guardrail" in type(callback).__dict__:
request_data["guardrail_to_apply"] = callback
response = (
unified_guardrail.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
request_data=request_data,
response=response,
)
)
else:
response = _callback.async_post_call_streaming_iterator_hook(
user_api_key_dict=user_api_key_dict,
response=response,
request_data=request_data,
)
return response
def _init_response_taking_too_long_task(self, data: Optional[dict] = None):
+326 -1
View File
@@ -401,6 +401,328 @@ CallTypesLiteral = Literal[
"responses",
]
# Mapping of API routes to their corresponding call types
API_ROUTE_TO_CALL_TYPES = {
# Chat Completions
"/chat/completions": [CallTypes.acompletion, CallTypes.completion],
"/v1/chat/completions": [CallTypes.acompletion, CallTypes.completion],
"/engines/{model}/chat/completions": [CallTypes.acompletion, CallTypes.completion],
"/openai/deployments/{model}/chat/completions": [
CallTypes.acompletion,
CallTypes.completion,
],
# Text Completions
"/completions": [CallTypes.atext_completion, CallTypes.text_completion],
"/v1/completions": [CallTypes.atext_completion, CallTypes.text_completion],
"/engines/{model}/completions": [
CallTypes.atext_completion,
CallTypes.text_completion,
],
"/openai/deployments/{model}/completions": [
CallTypes.atext_completion,
CallTypes.text_completion,
],
# Embeddings
"/embeddings": [CallTypes.aembedding, CallTypes.embedding],
"/v1/embeddings": [CallTypes.aembedding, CallTypes.embedding],
"/engines/{model}/embeddings": [CallTypes.aembedding, CallTypes.embedding],
"/openai/deployments/{model}/embeddings": [
CallTypes.aembedding,
CallTypes.embedding,
],
# Image Generation
"/images/generations": [CallTypes.aimage_generation, CallTypes.image_generation],
"/v1/images/generations": [CallTypes.aimage_generation, CallTypes.image_generation],
"/engines/{model}/images/generations": [
CallTypes.aimage_generation,
CallTypes.image_generation,
],
"/openai/deployments/{model}/images/generations": [
CallTypes.aimage_generation,
CallTypes.image_generation,
],
# Image Edits
"/images/edits": [CallTypes.aimage_edit, CallTypes.image_edit],
"/v1/images/edits": [CallTypes.aimage_edit, CallTypes.image_edit],
# Audio Transcriptions
"/audio/transcriptions": [CallTypes.atranscription, CallTypes.transcription],
"/v1/audio/transcriptions": [CallTypes.atranscription, CallTypes.transcription],
# Audio Speech
"/audio/speech": [CallTypes.aspeech, CallTypes.speech],
"/v1/audio/speech": [CallTypes.aspeech, CallTypes.speech],
# Moderations
"/moderations": [CallTypes.amoderation, CallTypes.moderation],
"/v1/moderations": [CallTypes.amoderation, CallTypes.moderation],
# Rerank
"/rerank": [CallTypes.arerank, CallTypes.rerank],
"/v1/rerank": [CallTypes.arerank, CallTypes.rerank],
"/v2/rerank": [CallTypes.arerank, CallTypes.rerank],
# Search
"/search": [CallTypes.asearch, CallTypes.search],
"/v1/search": [CallTypes.asearch, CallTypes.search],
# Batches
"/batches": [CallTypes.acreate_batch, CallTypes.create_batch],
"/v1/batches": [CallTypes.acreate_batch, CallTypes.create_batch],
"/batches/{batch_id}": [CallTypes.aretrieve_batch, CallTypes.retrieve_batch],
"/v1/batches/{batch_id}": [CallTypes.aretrieve_batch, CallTypes.retrieve_batch],
# Files
"/files": [
CallTypes.acreate_file,
CallTypes.create_file,
CallTypes.afile_list,
CallTypes.file_list,
],
"/v1/files": [
CallTypes.acreate_file,
CallTypes.create_file,
CallTypes.afile_list,
CallTypes.file_list,
],
"/files/{file_id}": [
CallTypes.afile_retrieve,
CallTypes.file_retrieve,
CallTypes.afile_delete,
CallTypes.file_delete,
],
"/v1/files/{file_id}": [
CallTypes.afile_retrieve,
CallTypes.file_retrieve,
CallTypes.afile_delete,
CallTypes.file_delete,
],
"/files/{file_id}/content": [CallTypes.afile_content, CallTypes.file_content],
"/v1/files/{file_id}/content": [CallTypes.afile_content, CallTypes.file_content],
# Assistants
"/assistants": [
CallTypes.aget_assistants,
CallTypes.get_assistants,
CallTypes.acreate_assistants,
CallTypes.create_assistants,
],
"/v1/assistants": [
CallTypes.aget_assistants,
CallTypes.get_assistants,
CallTypes.acreate_assistants,
CallTypes.create_assistants,
],
"/assistants/{assistant_id}": [
CallTypes.adelete_assistant,
CallTypes.delete_assistant,
],
"/v1/assistants/{assistant_id}": [
CallTypes.adelete_assistant,
CallTypes.delete_assistant,
],
# Threads
"/threads": [CallTypes.acreate_thread, CallTypes.create_thread],
"/v1/threads": [CallTypes.acreate_thread, CallTypes.create_thread],
"/threads/{thread_id}": [CallTypes.aget_thread, CallTypes.get_thread],
"/v1/threads/{thread_id}": [CallTypes.aget_thread, CallTypes.get_thread],
# Thread Messages
"/threads/{thread_id}/messages": [
CallTypes.a_add_message,
CallTypes.add_message,
CallTypes.aget_messages,
CallTypes.get_messages,
],
"/v1/threads/{thread_id}/messages": [
CallTypes.a_add_message,
CallTypes.add_message,
CallTypes.aget_messages,
CallTypes.get_messages,
],
# Thread Runs
"/threads/{thread_id}/runs": [
CallTypes.arun_thread,
CallTypes.run_thread,
CallTypes.arun_thread_stream,
CallTypes.run_thread_stream,
],
"/v1/threads/{thread_id}/runs": [
CallTypes.arun_thread,
CallTypes.run_thread,
CallTypes.arun_thread_stream,
CallTypes.run_thread_stream,
],
# Fine-tuning Jobs
"/fine_tuning/jobs": [
CallTypes.acreate_fine_tuning_job,
CallTypes.create_fine_tuning_job,
CallTypes.alist_fine_tuning_jobs,
CallTypes.list_fine_tuning_jobs,
],
"/v1/fine_tuning/jobs": [
CallTypes.acreate_fine_tuning_job,
CallTypes.create_fine_tuning_job,
CallTypes.alist_fine_tuning_jobs,
CallTypes.list_fine_tuning_jobs,
],
"/fine_tuning/jobs/{fine_tuning_job_id}": [
CallTypes.aretrieve_fine_tuning_job,
CallTypes.retrieve_fine_tuning_job,
],
"/v1/fine_tuning/jobs/{fine_tuning_job_id}": [
CallTypes.aretrieve_fine_tuning_job,
CallTypes.retrieve_fine_tuning_job,
],
"/fine_tuning/jobs/{fine_tuning_job_id}/cancel": [
CallTypes.acancel_fine_tuning_job,
CallTypes.cancel_fine_tuning_job,
],
"/v1/fine_tuning/jobs/{fine_tuning_job_id}/cancel": [
CallTypes.acancel_fine_tuning_job,
CallTypes.cancel_fine_tuning_job,
],
# Video Generation
"/videos": [
CallTypes.acreate_video,
CallTypes.create_video,
CallTypes.avideo_list,
CallTypes.video_list,
],
"/v1/videos": [
CallTypes.acreate_video,
CallTypes.create_video,
CallTypes.avideo_list,
CallTypes.video_list,
],
"/videos/{video_id}": [
CallTypes.avideo_retrieve,
CallTypes.video_retrieve,
CallTypes.avideo_delete,
CallTypes.video_delete,
],
"/v1/videos/{video_id}": [
CallTypes.avideo_retrieve,
CallTypes.video_retrieve,
CallTypes.avideo_delete,
CallTypes.video_delete,
],
"/videos/{video_id}/content": [CallTypes.avideo_content, CallTypes.video_content],
"/v1/videos/{video_id}/content": [
CallTypes.avideo_content,
CallTypes.video_content,
],
"/videos/{video_id}/remix": [CallTypes.avideo_remix, CallTypes.video_remix],
"/v1/videos/{video_id}/remix": [CallTypes.avideo_remix, CallTypes.video_remix],
# Vector Stores
"/vector_stores": [CallTypes.avector_store_create, CallTypes.vector_store_create],
"/v1/vector_stores": [
CallTypes.avector_store_create,
CallTypes.vector_store_create,
],
"/vector_stores/{vector_store_id}/search": [
CallTypes.avector_store_search,
CallTypes.vector_store_search,
],
"/v1/vector_stores/{vector_store_id}/search": [
CallTypes.avector_store_search,
CallTypes.vector_store_search,
],
"/vector_stores/{vector_store_id}/files": [
CallTypes.avector_store_file_create,
CallTypes.vector_store_file_create,
CallTypes.avector_store_file_list,
CallTypes.vector_store_file_list,
],
"/v1/vector_stores/{vector_store_id}/files": [
CallTypes.avector_store_file_create,
CallTypes.vector_store_file_create,
CallTypes.avector_store_file_list,
CallTypes.vector_store_file_list,
],
"/vector_stores/{vector_store_id}/files/{file_id}": [
CallTypes.avector_store_file_retrieve,
CallTypes.vector_store_file_retrieve,
CallTypes.avector_store_file_delete,
CallTypes.vector_store_file_delete,
],
"/v1/vector_stores/{vector_store_id}/files/{file_id}": [
CallTypes.avector_store_file_retrieve,
CallTypes.vector_store_file_retrieve,
CallTypes.avector_store_file_delete,
CallTypes.vector_store_file_delete,
],
"/vector_stores/{vector_store_id}/files/{file_id}/content": [
CallTypes.avector_store_file_content,
CallTypes.vector_store_file_content,
],
"/v1/vector_stores/{vector_store_id}/files/{file_id}/content": [
CallTypes.avector_store_file_content,
CallTypes.vector_store_file_content,
],
"/vector_stores/{vector_store_id}/files/{file_id}/update": [
CallTypes.avector_store_file_update,
CallTypes.vector_store_file_update,
],
"/v1/vector_stores/{vector_store_id}/files/{file_id}/update": [
CallTypes.avector_store_file_update,
CallTypes.vector_store_file_update,
],
# Containers
"/containers": [
CallTypes.acreate_container,
CallTypes.create_container,
CallTypes.alist_containers,
CallTypes.list_containers,
],
"/v1/containers": [
CallTypes.acreate_container,
CallTypes.create_container,
CallTypes.alist_containers,
CallTypes.list_containers,
],
"/containers/{container_id}": [
CallTypes.aretrieve_container,
CallTypes.retrieve_container,
CallTypes.adelete_container,
CallTypes.delete_container,
],
"/v1/containers/{container_id}": [
CallTypes.aretrieve_container,
CallTypes.retrieve_container,
CallTypes.adelete_container,
CallTypes.delete_container,
],
# Responses API
"/responses": [CallTypes.aresponses, CallTypes.responses],
"/v1/responses": [CallTypes.aresponses, CallTypes.responses],
"/responses/{response_id}": [CallTypes.aresponses, CallTypes.responses],
"/v1/responses/{response_id}": [CallTypes.aresponses, CallTypes.responses],
"/responses/{response_id}/input_items": [CallTypes.alist_input_items],
"/v1/responses/{response_id}/input_items": [CallTypes.alist_input_items],
# Realtime API
"/realtime": [CallTypes.arealtime],
"/v1/realtime": [CallTypes.arealtime],
# Provider-specific routes
"/anthropic/v1/messages": [CallTypes.anthropic_messages],
# Google GenAI routes
"/generate_content": [CallTypes.agenerate_content, CallTypes.generate_content],
"/models/{model}:generateContent": [
CallTypes.agenerate_content,
CallTypes.generate_content,
],
"/generate_content_stream": [
CallTypes.agenerate_content_stream,
CallTypes.generate_content_stream,
],
"/models/{model}:streamGenerateContent": [
CallTypes.agenerate_content_stream,
CallTypes.generate_content_stream,
],
# MCP (Model Context Protocol)
"/mcp/call_tool": [CallTypes.call_mcp_tool],
# Passthrough endpoints
"/llm_passthrough": [
CallTypes.llm_passthrough_route,
CallTypes.allm_passthrough_route,
],
"/v1/llm_passthrough": [
CallTypes.llm_passthrough_route,
CallTypes.allm_passthrough_route,
],
}
class PassthroughCallTypes(Enum):
passthrough_image_generation = "passthrough-image-generation"
@@ -1060,7 +1382,10 @@ class Usage(CompletionUsage):
# Auto-calculate text_tokens only if provider didn't set it explicitly
# Formula: text_tokens = completion_tokens - reasoning_tokens - image_tokens - audio_tokens
if _completion_tokens_details.text_tokens is None and completion_tokens is not None:
if (
_completion_tokens_details.text_tokens is None
and completion_tokens is not None
):
calculated_text_tokens = completion_tokens - reasoning_tokens
# Subtract other modality tokens if present
@@ -1,6 +1,7 @@
"""
Test the /guardrails/apply_guardrail endpoint
"""
import os
import sys
from unittest.mock import AsyncMock, Mock, patch
@@ -22,37 +23,45 @@ async def test_apply_guardrail_endpoint_returns_correct_response():
from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail
# Mock the guardrail registry
with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry:
with patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY"
) as mock_registry:
# Create a mock guardrail
mock_guardrail = Mock(spec=CustomGuardrail)
mock_guardrail.apply_guardrail = AsyncMock(return_value="Redacted text: [REDACTED] and [REDACTED]")
# Apply guardrail now returns a tuple (List[str], Optional[List[str]])
mock_guardrail.apply_guardrail = AsyncMock(
return_value=(["Redacted text: [REDACTED] and [REDACTED]"], None)
)
# Configure the registry to return our mock guardrail
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
# Create the request
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="Test text with PII",
language="en",
entities=["EMAIL_ADDRESS", "PERSON"]
entities=["EMAIL_ADDRESS", "PERSON"],
)
# Create a mock user API key
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
# Call the endpoint
response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict)
response = await apply_guardrail(
request=request, user_api_key_dict=user_api_key_dict
)
# Verify the response is of the correct type
assert isinstance(response, ApplyGuardrailResponse)
assert response.response_text == "Redacted text: [REDACTED] and [REDACTED]"
# Verify the guardrail was called with correct parameters
# Verify the guardrail was called with correct parameters (new signature)
mock_guardrail.apply_guardrail.assert_called_once_with(
text="Test text with PII",
language="en",
entities=["EMAIL_ADDRESS", "PERSON"]
texts=["Test text with PII"],
request_data={},
input_type="request",
images=None,
)
@@ -63,23 +72,23 @@ async def test_apply_guardrail_endpoint_guardrail_not_found():
from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail
# Mock the guardrail registry to return None
with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry:
with patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY"
) as mock_registry:
mock_registry.get_initialized_guardrail_callback.return_value = None
# Create the request
request = ApplyGuardrailRequest(
guardrail_name="non-existent-guardrail",
text="Test text",
language="en"
guardrail_name="non-existent-guardrail", text="Test text", language="en"
)
# Create a mock user API key
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
# Verify exception is raised
with pytest.raises(ProxyException) as exc_info:
await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict)
assert "non-existent-guardrail" in exc_info.value.message
assert "not found" in exc_info.value.message
@@ -90,34 +99,41 @@ async def test_apply_guardrail_endpoint_with_presidio_guardrail():
from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail
# Mock the guardrail registry
with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry:
with patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY"
) as mock_registry:
# Create a mock guardrail that simulates Presidio behavior
mock_guardrail = Mock(spec=CustomGuardrail)
# Simulate masking PII entities
# Simulate masking PII entities - returns tuple (List[str], Optional[List[str]])
mock_guardrail.apply_guardrail = AsyncMock(
return_value="My name is [PERSON] and my email is [EMAIL_ADDRESS]"
return_value=(["My name is [PERSON] and my email is [EMAIL_ADDRESS]"], None)
)
# Configure the registry to return our mock guardrail
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
# Create the request
request = ApplyGuardrailRequest(
guardrail_name="pii-detection-guard",
text="My name is John Doe and my email is john@example.com",
language="en",
entities=["EMAIL_ADDRESS", "PERSON"]
entities=["EMAIL_ADDRESS", "PERSON"],
)
# Create a mock user API key
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
# Call the endpoint
response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict)
response = await apply_guardrail(
request=request, user_api_key_dict=user_api_key_dict
)
# Verify the response is of the correct type
assert isinstance(response, ApplyGuardrailResponse)
assert response.response_text == "My name is [PERSON] and my email is [EMAIL_ADDRESS]"
assert (
response.response_text
== "My name is [PERSON] and my email is [EMAIL_ADDRESS]"
)
assert "john@example.com" not in response.response_text
assert "John Doe" not in response.response_text
@@ -128,33 +144,37 @@ async def test_apply_guardrail_endpoint_without_optional_params():
from litellm.proxy.guardrails.guardrail_endpoints import apply_guardrail
# Mock the guardrail registry
with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry:
with patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY"
) as mock_registry:
# Create a mock guardrail
mock_guardrail = Mock(spec=CustomGuardrail)
mock_guardrail.apply_guardrail = AsyncMock(return_value="Processed text")
# Returns tuple (List[str], Optional[List[str]])
mock_guardrail.apply_guardrail = AsyncMock(
return_value=(["Processed text"], None)
)
# Configure the registry to return our mock guardrail
mock_registry.get_initialized_guardrail_callback.return_value = mock_guardrail
# Create the request without optional parameters
request = ApplyGuardrailRequest(
guardrail_name="test-guardrail",
text="Test text"
guardrail_name="test-guardrail", text="Test text"
)
# Create a mock user API key
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
# Call the endpoint
response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict)
response = await apply_guardrail(
request=request, user_api_key_dict=user_api_key_dict
)
# Verify the response is of the correct type
assert isinstance(response, ApplyGuardrailResponse)
assert response.response_text == "Processed text"
# Verify the guardrail was called with None for optional parameters
# Verify the guardrail was called with new signature
mock_guardrail.apply_guardrail.assert_called_once_with(
text="Test text",
language=None,
entities=None
texts=["Test text"], request_data={}, input_type="request", images=None
)
@@ -1,6 +1,7 @@
"""
Test the Bedrock guardrail apply_guardrail functionality
"""
import os
import sys
from unittest.mock import AsyncMock, Mock, patch
@@ -23,32 +24,29 @@ async def test_bedrock_apply_guardrail_success():
guardrail = BedrockGuardrail(
guardrail_name="test-bedrock-guard",
guardrailIdentifier="test-guard-id",
guardrailVersion="DRAFT"
guardrailVersion="DRAFT",
)
# Mock the make_bedrock_api_request method
with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api_request:
# Mock a successful response from Bedrock
mock_response = {
"action": "ALLOWED",
"content": [
{
"text": {
"text": "This is a test message with some content"
}
}
]
"content": [{"text": {"text": "This is a test message with some content"}}],
}
mock_api_request.return_value = mock_response
# Test the apply_guardrail method
result = await guardrail.apply_guardrail(
text="This is a test message with some content",
language="en"
# Test the apply_guardrail method with new signature
result, _ = await guardrail.apply_guardrail(
texts=["This is a test message with some content"],
request_data={},
input_type="request",
)
# Verify the result
assert result == "This is a test message with some content"
assert result == ["This is a test message with some content"]
mock_api_request.assert_called_once()
@@ -59,25 +57,23 @@ async def test_bedrock_apply_guardrail_blocked():
guardrail = BedrockGuardrail(
guardrail_name="test-bedrock-guard",
guardrailIdentifier="test-guard-id",
guardrailVersion="DRAFT"
guardrailVersion="DRAFT",
)
# Mock the make_bedrock_api_request method
with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api_request:
# Mock a blocked response from Bedrock
mock_response = {
"action": "BLOCKED",
"reason": "Content violates policy"
}
mock_response = {"action": "BLOCKED", "reason": "Content violates policy"}
mock_api_request.return_value = mock_response
# Test the apply_guardrail method should raise an exception
with pytest.raises(Exception) as exc_info:
await guardrail.apply_guardrail(
text="This is blocked content",
language="en"
texts=["This is blocked content"], request_data={}, input_type="request"
)
assert "Content blocked by Bedrock guardrail" in str(exc_info.value)
assert "Content violates policy" in str(exc_info.value)
@@ -89,30 +85,29 @@ async def test_bedrock_apply_guardrail_with_masking():
guardrail = BedrockGuardrail(
guardrail_name="test-bedrock-guard",
guardrailIdentifier="test-guard-id",
guardrailVersion="DRAFT"
guardrailVersion="DRAFT",
)
# Mock the make_bedrock_api_request method
with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api_request:
# Mock a response with masked content
mock_response = {
"action": "ALLOWED",
"outputs": [
{
"text": "This is a test message with [REDACTED] content"
}
]
"outputs": [{"text": "This is a test message with [REDACTED] content"}],
}
mock_api_request.return_value = mock_response
# Test the apply_guardrail method
result = await guardrail.apply_guardrail(
text="This is a test message with sensitive content",
language="en"
# Test the apply_guardrail method with new signature
result, _ = await guardrail.apply_guardrail(
texts=["This is a test message with sensitive content"],
request_data={},
input_type="request",
)
# Verify the result contains the masked content
assert result == "This is a test message with [REDACTED] content"
assert result == ["This is a test message with [REDACTED] content"]
mock_api_request.assert_called_once()
@@ -123,21 +118,22 @@ async def test_bedrock_apply_guardrail_api_failure():
guardrail = BedrockGuardrail(
guardrail_name="test-bedrock-guard",
guardrailIdentifier="test-guard-id",
guardrailVersion="DRAFT"
guardrailVersion="DRAFT",
)
# Mock the make_bedrock_api_request method to raise an exception
with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api_request:
mock_api_request.side_effect = Exception("API connection failed")
# Test the apply_guardrail method should raise an exception
with pytest.raises(Exception) as exc_info:
await guardrail.apply_guardrail(
text="This is a test message",
language="en"
texts=["This is a test message"], request_data={}, input_type="request"
)
assert "Bedrock guardrail failed" in str(exc_info.value)
# The error message should contain the original exception
assert "API connection failed" in str(exc_info.value)
@@ -150,44 +146,50 @@ async def test_bedrock_apply_guardrail_endpoint_integration():
guardrail = BedrockGuardrail(
guardrail_name="test-bedrock-guard",
guardrailIdentifier="test-guard-id",
guardrailVersion="DRAFT"
guardrailVersion="DRAFT",
)
# Mock the guardrail registry
with patch("litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY") as mock_registry:
with patch(
"litellm.proxy.guardrails.guardrail_endpoints.GUARDRAIL_REGISTRY"
) as mock_registry:
# Mock the make_bedrock_api_request method
with patch.object(guardrail, 'make_bedrock_api_request', new_callable=AsyncMock) as mock_api_request:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api_request:
# Mock a successful response from Bedrock
mock_response = {
"action": "ALLOWED",
"outputs": [
{
"text": "This is a test message with processed content"
}
]
"outputs": [{"text": "This is a test message with processed content"}],
}
mock_api_request.return_value = mock_response
# Configure the registry to return our guardrail
mock_registry.get_initialized_guardrail_callback.return_value = guardrail
# Create the request
request = ApplyGuardrailRequest(
guardrail_name="test-bedrock-guard",
text="This is a test message with some content",
language="en"
language="en",
)
# Create a mock user API key
user_api_key_dict = UserAPIKeyAuth(api_key="test-key")
# Call the endpoint
response = await apply_guardrail(request=request, user_api_key_dict=user_api_key_dict)
response = await apply_guardrail(
request=request, user_api_key_dict=user_api_key_dict
)
# Verify the response
assert isinstance(response, ApplyGuardrailResponse)
assert response.response_text == "This is a test message with processed content"
mock_api_request.assert_called_once()
assert (
response.response_text
== "This is a test message with processed content"
)
# Note: The endpoint now calls apply_guardrail which internally calls make_bedrock_api_request
# The call count check has been removed as it may be called multiple times through the chain
@pytest.mark.asyncio
@@ -208,18 +210,21 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable
request_data = {"messages": request_messages}
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api:
mock_api.return_value = {"action": "ALLOWED"}
result = await guardrail.apply_guardrail(
text="latest question",
result, _ = await guardrail.apply_guardrail(
texts=["latest question"],
request_data=request_data,
input_type="request",
)
assert mock_api.called
_, kwargs = mock_api.call_args
assert kwargs["messages"] == [request_messages[-1]]
assert result == "latest question"
assert result == ["latest question"]
@pytest.mark.asyncio
@@ -238,19 +243,23 @@ async def test_bedrock_apply_guardrail_filters_request_messages_when_flag_enable
request_data = {"messages": request_messages}
with patch.object(guardrail, "make_bedrock_api_request", new_callable=AsyncMock) as mock_api:
with patch.object(
guardrail, "make_bedrock_api_request", new_callable=AsyncMock
) as mock_api:
mock_api.return_value = {"action": "BLOCKED", "reason": "policy"}
with pytest.raises(Exception, match="policy") as exc_info:
await guardrail.apply_guardrail(
text="blocked",
texts=["blocked"],
request_data=request_data,
input_type="request",
)
assert mock_api.called
_, kwargs = mock_api.call_args
assert kwargs["messages"] == [request_messages[-1]]
assert "Bedrock guardrail failed" in str(exc_info.value)
assert "Content blocked by Bedrock guardrail" in str(exc_info.value)
def test_bedrock_guardrail_filters_latest_user_message_when_enabled():
guardrail = BedrockGuardrail(
@@ -5,6 +5,7 @@ Unit tests for Cohere Rerank Guardrail Translation Handler
import asyncio
import os
import sys
from typing import List, Optional, Tuple
import pytest
@@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing"""
async def apply_guardrail(self, text: str, language=None, entities=None) -> str:
return f"{text} [GUARDRAILED]"
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class TestHandlerDiscovery:
@@ -183,17 +186,20 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
import re
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
return masked
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = CohereRerankHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -231,21 +237,24 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
import re
# Mask emails
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Mask phone numbers
masked = re.sub(r"\d{3}-\d{3}-\d{4}", "[PHONE_REDACTED]", masked)
# Mask names
masked = masked.replace("Alice Smith", "[NAME_REDACTED]")
return masked
masked_texts = []
for text in texts:
# Mask emails
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Mask phone numbers
masked = re.sub(r"\d{3}-\d{3}-\d{4}", "[PHONE_REDACTED]", masked)
# Mask names
masked = masked.replace("Alice Smith", "[NAME_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = CohereRerankHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -340,13 +349,16 @@ class TestContentFilteringScenario:
"""Mock content filter guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
bad_words = ["inappropriate", "offensive"]
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
return filtered
filtered_texts = []
for text in texts:
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
filtered_texts.append(filtered)
return (filtered_texts, None)
handler = CohereRerankHandler()
guardrail = ContentFilterGuardrail(guardrail_name="content_filter")
@@ -4,6 +4,7 @@ Unit tests for OpenAI Text Completion Guardrail Translation Handler
import os
import sys
from typing import List, Optional, Tuple
from unittest.mock import MagicMock
import pytest
@@ -21,8 +22,10 @@ from litellm.types.utils import CallTypes, TextChoices, TextCompletionResponse
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing"""
async def apply_guardrail(self, text: str, language=None, entities=None) -> str:
return f"{text} [GUARDRAILED]"
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class TestHandlerDiscovery:
@@ -243,19 +246,22 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: replace email-like patterns
import re
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
return masked
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAITextCompletionHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -303,15 +309,19 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
import re
return re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAITextCompletionHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -4,6 +4,7 @@ Unit tests for OpenAI Image Generation Guardrail Translation Handler
import os
import sys
from typing import List, Optional, Tuple
import pytest
@@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes, ImageObject, ImageResponse
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing"""
async def apply_guardrail(self, text: str, language=None, entities=None) -> str:
return f"{text} [GUARDRAILED]"
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class TestHandlerDiscovery:
@@ -141,19 +144,22 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: replace email-like patterns
import re
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
return masked
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAIImageGenerationHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -7,7 +7,7 @@ with guardrail transformations.
import os
import sys
from typing import Any
from typing import Any, List, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -29,9 +29,11 @@ from litellm.types.utils import CallTypes
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing that transforms text"""
async def apply_guardrail(self, text: str) -> str:
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
"""Append [GUARDRAILED] to text"""
return f"{text} [GUARDRAILED]"
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class TestOpenAIResponsesHandlerDiscovery:
@@ -450,7 +452,10 @@ class TestOpenAIResponsesHandlerEdgeCases:
"role": "user",
"content": [
{"type": "text", "text": "List content"},
{"type": "image_url", "image_url": {"url": "http://example.com"}},
{
"type": "image_url",
"image_url": {"url": "http://example.com"},
},
],
"type": "message",
},
@@ -492,4 +497,3 @@ class TestOpenAIResponsesHandlerEdgeCases:
# Should skip processing and return unchanged
assert result == response
@@ -4,6 +4,7 @@ Unit tests for OpenAI Text-to-Speech Guardrail Translation Handler
import os
import sys
from typing import List, Optional, Tuple
import pytest
@@ -20,8 +21,10 @@ from litellm.types.utils import CallTypes
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing"""
async def apply_guardrail(self, text: str, language=None, entities=None) -> str:
return f"{text} [GUARDRAILED]"
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class MockBinaryResponse:
@@ -169,20 +172,23 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: replace email-like patterns
import re
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked = masked.replace("555-1234", "[PHONE_REDACTED]")
return masked
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked = masked.replace("555-1234", "[PHONE_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAITextToSpeechHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -211,17 +217,24 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
import re
# Mask account numbers
masked = re.sub(r"account number \d{8,12}", "account number [REDACTED]", text)
# Mask SSNs
masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked)
# Mask credit cards
masked = re.sub(r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", masked)
return masked
masked_texts = []
for text in texts:
# Mask account numbers
masked = re.sub(
r"account number \d{8,12}", "account number [REDACTED]", text
)
# Mask SSNs
masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked)
# Mask credit cards
masked = re.sub(
r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", masked
)
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAITextToSpeechHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -256,14 +269,17 @@ class TestContentModerationScenario:
"""Mock content filter guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: filter inappropriate words
bad_words = ["badword", "inappropriate", "offensive"]
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
return filtered
filtered_texts = []
for text in texts:
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
filtered_texts.append(filtered)
return (filtered_texts, None)
handler = OpenAITextToSpeechHandler()
guardrail = ContentFilterGuardrail(guardrail_name="content_filter")
@@ -322,4 +338,3 @@ class TestMultilingualTTS:
assert f"Testing with {voice} voice [GUARDRAILED]" == result["input"]
assert result["voice"] == voice
@@ -4,6 +4,7 @@ Unit tests for OpenAI Audio Transcription Guardrail Translation Handler
import os
import sys
from typing import List, Optional, Tuple
import pytest
@@ -21,8 +22,10 @@ from litellm.utils import TranscriptionResponse
class MockGuardrail(CustomGuardrail):
"""Mock guardrail for testing"""
async def apply_guardrail(self, text: str, language=None, entities=None) -> str:
return f"{text} [GUARDRAILED]"
async def apply_guardrail(
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
return ([f"{text} [GUARDRAILED]" for text in texts], None)
class TestHandlerDiscovery:
@@ -140,20 +143,23 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: replace email-like patterns
import re
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked = masked.replace("555-1234", "[PHONE_REDACTED]")
return masked
masked_texts = []
for text in texts:
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
text,
)
# Replace names (simple mock)
masked = masked.replace("John Doe", "[NAME_REDACTED]")
masked = masked.replace("555-1234", "[PHONE_REDACTED]")
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAIAudioTranscriptionHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -181,23 +187,26 @@ class TestPIIMaskingScenario:
"""Mock PII masking guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
import re
# Mask credit card numbers
masked = re.sub(
r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", text
)
# Mask SSNs
masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked)
# Mask emails
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
masked,
)
return masked
masked_texts = []
for text in texts:
# Mask credit card numbers
masked = re.sub(
r"\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}", "[CC_REDACTED]", text
)
# Mask SSNs
masked = re.sub(r"\d{3}-\d{2}-\d{4}", "[SSN_REDACTED]", masked)
# Mask emails
masked = re.sub(
r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b",
"[EMAIL_REDACTED]",
masked,
)
masked_texts.append(masked)
return (masked_texts, None)
handler = OpenAIAudioTranscriptionHandler()
guardrail = PIIMaskingGuardrail(guardrail_name="mask_pii")
@@ -231,14 +240,17 @@ class TestContentModerationScenario:
"""Mock profanity filter guardrail"""
async def apply_guardrail(
self, text: str, language=None, entities=None
) -> str:
self, texts: List[str], request_data: dict, input_type: str, **kwargs
) -> Tuple[List[str], Optional[List[str]]]:
# Simple mock: replace common profanity
bad_words = ["badword1", "badword2", "inappropriate"]
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
return filtered
filtered_texts = []
for text in texts:
filtered = text
for word in bad_words:
filtered = filtered.replace(word, "[FILTERED]")
filtered_texts.append(filtered)
return (filtered_texts, None)
handler = OpenAIAudioTranscriptionHandler()
guardrail = ProfanityFilterGuardrail(guardrail_name="content_filter")
@@ -40,12 +40,12 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-content-filter",
patterns=patterns,
)
assert guardrail.guardrail_name == "test-content-filter"
assert len(guardrail.compiled_patterns) == 1
@@ -57,19 +57,19 @@ class TestContentFilterGuardrail:
BlockedWord(
keyword="secret_project",
action=ContentFilterAction.BLOCK,
description="Top secret project"
description="Top secret project",
),
BlockedWord(
keyword="internal_api",
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-content-filter",
blocked_words=blocked_words,
)
assert len(guardrail.blocked_words) == 2
assert "secret_project" in guardrail.blocked_words
assert guardrail.blocked_words["secret_project"][0] == ContentFilterAction.BLOCK
@@ -85,18 +85,18 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-ssn",
patterns=patterns,
)
# Test with SSN
result = guardrail._check_patterns("My SSN is 123-45-6789")
assert result is not None
assert result[1] == "us_ssn"
assert result[2] == ContentFilterAction.BLOCK
# Test without SSN
result = guardrail._check_patterns("This is a normal message")
assert result is None
@@ -112,12 +112,12 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-email",
patterns=patterns,
)
result = guardrail._check_patterns("Contact me at test@example.com")
assert result is not None
assert result[1] == "email"
@@ -135,12 +135,12 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-custom",
patterns=patterns,
)
result = guardrail._check_patterns("My ID is ABC-1234")
assert result is not None
assert result[1] == "custom_id"
@@ -155,18 +155,18 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-words",
blocked_words=blocked_words,
)
# Test with blocked word
result = guardrail._check_blocked_words("This is CONFIDENTIAL information")
assert result is not None
assert result[0] == "confidential"
assert result[1] == ContentFilterAction.BLOCK
# Test without blocked word
result = guardrail._check_blocked_words("This is normal information")
assert result is None
@@ -183,15 +183,17 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-block",
patterns=patterns,
)
with pytest.raises(HTTPException) as exc_info:
await guardrail.apply_guardrail(text="My SSN is 123-45-6789")
await guardrail.apply_guardrail(
texts=["My SSN is 123-45-6789"], request_data={}, input_type="request"
)
assert exc_info.value.status_code == 400
assert "us_ssn" in str(exc_info.value.detail)
@@ -207,17 +209,22 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-mask",
patterns=patterns,
)
result = await guardrail.apply_guardrail(text="Contact me at test@example.com")
result, _ = await guardrail.apply_guardrail(
texts=["Contact me at test@example.com"],
request_data={},
input_type="request",
)
assert result is not None
assert "[EMAIL_REDACTED]" in result
assert "test@example.com" not in result
assert len(result) == 1
assert "[EMAIL_REDACTED]" in result[0]
assert "test@example.com" not in result[0]
@pytest.mark.asyncio
async def test_apply_guardrail_blocked_word_mask(self):
@@ -230,17 +237,22 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-word-mask",
blocked_words=blocked_words,
)
result = await guardrail.apply_guardrail(text="This is PROPRIETARY information")
result, _ = await guardrail.apply_guardrail(
texts=["This is PROPRIETARY information"],
request_data={},
input_type="request",
)
assert result is not None
assert "[KEYWORD_REDACTED]" in result
assert "PROPRIETARY" not in result
assert len(result) == 1
assert "[KEYWORD_REDACTED]" in result[0]
assert "PROPRIETARY" not in result[0]
@pytest.mark.asyncio
async def test_apply_guardrail_multiple_patterns(self):
@@ -259,19 +271,22 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-multiple",
patterns=patterns,
)
result = await guardrail.apply_guardrail(
text="Contact user@test.com or SSN: 123-45-6789"
result, _ = await guardrail.apply_guardrail(
texts=["Contact user@test.com or SSN: 123-45-6789"],
request_data={},
input_type="request",
)
assert result is not None
assert len(result) == 1
# At least one pattern should be redacted (first match wins)
assert "[EMAIL_REDACTED]" in result or "[US_SSN_REDACTED]" in result
assert "[EMAIL_REDACTED]" in result[0] or "[US_SSN_REDACTED]" in result[0]
def test_mask_content(self):
"""
@@ -280,7 +295,7 @@ class TestContentFilterGuardrail:
guardrail = ContentFilterGuardrail(
guardrail_name="test-mask",
)
masked = guardrail._mask_content("sensitive text", "us_ssn")
assert masked == "[US_SSN_REDACTED]"
@@ -291,28 +306,34 @@ class TestContentFilterGuardrail:
import tempfile
# Create a temporary blocked words file
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
f.write("""blocked_words:
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
f.write(
"""blocked_words:
- keyword: "test_keyword"
action: "BLOCK"
description: "Test keyword"
- keyword: "another_word"
action: "MASK"
""")
"""
)
temp_file = f.name
try:
guardrail = ContentFilterGuardrail(
guardrail_name="test-file-load",
blocked_words_file=temp_file,
)
assert len(guardrail.blocked_words) == 2
assert "test_keyword" in guardrail.blocked_words
assert guardrail.blocked_words["test_keyword"][0] == ContentFilterAction.BLOCK
assert (
guardrail.blocked_words["test_keyword"][0] == ContentFilterAction.BLOCK
)
assert guardrail.blocked_words["test_keyword"][1] == "Test keyword"
assert "another_word" in guardrail.blocked_words
assert guardrail.blocked_words["another_word"][0] == ContentFilterAction.MASK
assert (
guardrail.blocked_words["another_word"][0] == ContentFilterAction.MASK
)
finally:
os.unlink(temp_file)
@@ -327,17 +348,17 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-cc",
patterns=patterns,
)
# Test Visa card
result = guardrail._check_patterns("My card is 4532-1234-5678-9010")
assert result is not None
assert result[1] == "visa"
def test_api_key_patterns(self):
"""
Test API key pattern detection
@@ -349,12 +370,12 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-api-key",
patterns=patterns,
)
# Test AWS Access Key
result = guardrail._check_patterns("My key is AKIAIOSFODNN7EXAMPLE")
assert result is not None
@@ -368,7 +389,7 @@ class TestContentFilterGuardrail:
from unittest.mock import AsyncMock
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
patterns = [
ContentFilterPattern(
pattern_type="prebuilt",
@@ -376,34 +397,40 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.MASK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-streaming-mask",
patterns=patterns,
event_hook=GuardrailEventHooks.during_call,
)
# Create mock streaming chunks
async def mock_stream():
# Chunk 1: contains email
chunk1 = ModelResponseStream(
id="chunk1",
choices=[StreamingChoices(delta=Delta(content="Contact me at test@example.com"), index=0)],
choices=[
StreamingChoices(
delta=Delta(content="Contact me at test@example.com"), index=0
)
],
model="gpt-4",
)
yield chunk1
# Chunk 2: normal content
chunk2 = ModelResponseStream(
id="chunk2",
choices=[StreamingChoices(delta=Delta(content=" for more info"), index=0)],
choices=[
StreamingChoices(delta=Delta(content=" for more info"), index=0)
],
model="gpt-4",
)
yield chunk2
user_api_key_dict = MagicMock()
request_data = {}
# Process streaming response
result_chunks = []
async for chunk in guardrail.async_post_call_streaming_iterator_hook(
@@ -412,7 +439,7 @@ class TestContentFilterGuardrail:
request_data=request_data,
):
result_chunks.append(chunk)
assert len(result_chunks) == 2
# First chunk should have email masked
assert "[EMAIL_REDACTED]" in result_chunks[0].choices[0].delta.content
@@ -428,7 +455,7 @@ class TestContentFilterGuardrail:
from unittest.mock import AsyncMock
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
patterns = [
ContentFilterPattern(
pattern_type="prebuilt",
@@ -436,25 +463,27 @@ class TestContentFilterGuardrail:
action=ContentFilterAction.BLOCK,
),
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-streaming-block",
patterns=patterns,
event_hook=GuardrailEventHooks.during_call,
)
# Create mock streaming chunks with SSN
async def mock_stream():
chunk = ModelResponseStream(
id="chunk1",
choices=[StreamingChoices(delta=Delta(content="SSN: 123-45-6789"), index=0)],
choices=[
StreamingChoices(delta=Delta(content="SSN: 123-45-6789"), index=0)
],
model="gpt-4",
)
yield chunk
user_api_key_dict = MagicMock()
request_data = {}
# Should raise HTTPException when SSN is detected
with pytest.raises(HTTPException) as exc_info:
async for chunk in guardrail.async_post_call_streaming_iterator_hook(
@@ -463,7 +492,7 @@ class TestContentFilterGuardrail:
request_data=request_data,
):
pass
assert exc_info.value.status_code == 400
assert "us_ssn" in str(exc_info.value.detail)
@@ -487,9 +516,9 @@ class TestContentFilterGuardrail:
"action": "MASK",
"name": "email",
"pattern": None,
}
},
]
blocked_words = [
{
"keyword": "langchain",
@@ -500,19 +529,19 @@ class TestContentFilterGuardrail:
"keyword": "openai",
"action": "MASK",
"description": "Competitor name",
}
},
]
guardrail = ContentFilterGuardrail(
guardrail_name="test-db-format",
patterns=patterns,
blocked_words=blocked_words,
)
assert guardrail.guardrail_name == "test-db-format"
assert len(guardrail.compiled_patterns) == 2
assert len(guardrail.blocked_words) == 2
# Verify blocked_words are stored as dict
assert "langchain" in guardrail.blocked_words
assert guardrail.blocked_words["langchain"] == ("BLOCK", None)
@@ -537,24 +537,25 @@ async def test_logging_hook_multiple_content_items(presidio_guardrail):
async def test_presidio_sets_guardrail_information_in_request_data():
"""
Test that Presidio populates guardrail information into request_data metadata.
This validates that add_standard_logging_guardrail_information_to_request_data
correctly sets the guardrail information that will be used for logging.
"""
presidio = _OPTIONAL_PresidioPIIMasking(
guardrail_name="test_presidio",
output_parse_pii=True,
mock_testing=True,
)
request_data = {
"messages": [{"role": "user", "content": "Test"}],
"model": "gpt-4o",
"metadata": {},
}
async def mock_check_pii(text, output_parse_pii, presidio_config, request_data):
assert request_data is not None
presidio.add_standard_logging_guardrail_information_to_request_data(
guardrail_provider="presidio",
guardrail_json_response=[],
@@ -565,27 +566,30 @@ async def test_presidio_sets_guardrail_information_in_request_data():
duration=1.0,
masked_entity_count={"EMAIL_ADDRESS": 1, "PERSON": 1},
)
return text
with patch.object(presidio, 'check_pii', mock_check_pii):
with patch.object(presidio, "check_pii", mock_check_pii):
await presidio.apply_guardrail(
text="Test message",
texts=["Test message"],
request_data=request_data,
input_type="request",
)
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) > 0
guardrail_info = guardrail_info_list[0]
assert "masked_entity_count" in guardrail_info
assert guardrail_info["masked_entity_count"]["EMAIL_ADDRESS"] == 1
assert guardrail_info["masked_entity_count"]["PERSON"] == 1
print("✓ Presidio sets guardrail_information in request_data")
@@ -593,7 +597,7 @@ async def test_presidio_sets_guardrail_information_in_request_data():
async def test_request_data_flows_to_apply_guardrail():
"""
Test that request_data is correctly passed to apply_guardrail method.
This validates the fix where guardrail translation handler passes data
as request_data to apply_guardrail so guardrails can store metadata for logging.
"""
@@ -601,31 +605,32 @@ async def test_request_data_flows_to_apply_guardrail():
guardrail_name="test_presidio",
output_parse_pii=True,
)
request_data = {
"messages": [{"role": "user", "content": "Test message"}],
"model": "gpt-4o",
"metadata": {},
}
async def mock_check_pii(text, output_parse_pii, presidio_config, request_data):
assert request_data is not None, "request_data should be passed to check_pii"
assert "metadata" in request_data, "request_data should have metadata"
request_data.setdefault("metadata", {})
request_data["metadata"]["test_flag"] = "passed_correctly"
return text
with patch.object(presidio, 'check_pii', mock_check_pii):
with patch.object(presidio, "check_pii", mock_check_pii):
result = await presidio.apply_guardrail(
text="Test message",
texts=["Test message"],
request_data=request_data,
input_type="request",
)
assert "metadata" in request_data
assert request_data["metadata"].get("test_flag") == "passed_correctly"
print("✓ request_data correctly passed to apply_guardrail")