mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-02 04:21:34 +00:00
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:
@@ -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
@@ -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[
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
+68
-48
@@ -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
|
||||
)
|
||||
|
||||
+85
-76
@@ -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")
|
||||
|
||||
+29
-19
@@ -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")
|
||||
|
||||
+18
-12
@@ -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
|
||||
|
||||
|
||||
+46
-34
@@ -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")
|
||||
|
||||
+101
-72
@@ -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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user