diff --git a/enterprise/enterprise_hooks/google_text_moderation.py b/enterprise/enterprise_hooks/google_text_moderation.py index 7dbbc9ed6ce..ddf092a143e 100644 --- a/enterprise/enterprise_hooks/google_text_moderation.py +++ b/enterprise/enterprise_hooks/google_text_moderation.py @@ -15,7 +15,10 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.guardrails._content_utils import iter_message_text +from litellm.proxy.guardrails._content_utils import ( + is_non_conversational_request, + iter_message_text, +) from litellm.types.utils import CallTypesLiteral _THRESHOLD_VALUE: Final = TypeAdapter(object) @@ -94,7 +97,7 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger): async def async_moderation_hook( self, - data: dict, + data: dict[str, object], user_api_key_dict: UserAPIKeyAuth, call_type: CallTypesLiteral, ): @@ -102,6 +105,9 @@ class ENTERPRISE_GoogleTextModeration(CustomLogger): - Calls Google's Text Moderation API - Rejects request if it fails safety check """ + if is_non_conversational_request(call_type, data): + verbose_proxy_logger.debug("Google text moderation: skipping non-conversational call type %s", call_type) + return # Covers multimodal list content + Responses-API input. text = "".join(iter_message_text(data)) if text: diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 9bea22acd71..51934c99db6 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -62,6 +62,20 @@ def is_non_conversational_call_type(call_type: str) -> bool: return call_type in NON_CONVERSATIONAL_CALL_TYPES +_PASSTHROUGH_EMBEDDINGS_ENDPOINT: Final = re.compile(r"/?(?:[\w-]+/)*embeddings/?") + + +def is_non_conversational_request(call_type: str, data: Mapping[str, object]) -> bool: + if is_non_conversational_call_type(call_type): + return True + endpoint: Final = data.get("endpoint") + return ( + call_type == "allm_passthrough_route" + and isinstance(endpoint, str) + and _PASSTHROUGH_EMBEDDINGS_ENDPOINT.fullmatch(endpoint) is not None + ) + + TEXT_PART_TYPES: Final[frozenset[str]] = frozenset( {"text", "input_text", "output_text", "summary_text", "reasoning_text"} ) diff --git a/tests/unit/proxy/guardrails/test_content_utils.py b/tests/unit/proxy/guardrails/test_content_utils.py index 21d96a3ea97..c50646d233d 100644 --- a/tests/unit/proxy/guardrails/test_content_utils.py +++ b/tests/unit/proxy/guardrails/test_content_utils.py @@ -12,6 +12,7 @@ from litellm.proxy.guardrails._content_utils import ( build_inspection_messages, has_non_string_content, is_non_conversational_call_type, + is_non_conversational_request, is_string_batch_input, iter_message_text, iter_tool_json_text, @@ -819,6 +820,30 @@ def test_is_non_conversational_call_type_defaults_to_inspecting_unknown_call_typ assert is_non_conversational_call_type("some_future_call_type") is False +@pytest.mark.parametrize( + ("call_type", "endpoint", "expected"), + [ + ("aembedding", None, True), + ("allm_passthrough_route", "embeddings", True), + ("allm_passthrough_route", "/v1/embeddings/", True), + ("allm_passthrough_route", "chat/completions", False), + ("allm_passthrough_route", "model/amazon.titan-embed-text-v2:0/invoke", False), + ("allm_passthrough_route", "fine_tuned_embeddings_jobs", False), + ("allm_passthrough_route", "chat/completions#embeddings", False), + ("allm_passthrough_route", "chat/completions?x=embeddings", False), + ("allm_passthrough_route", "chat/completions;embeddings", False), + ("allm_passthrough_route", None, False), + ("acompletion", "embeddings", False), + ], +) +def test_is_non_conversational_request_skips_only_embeddings_requests( + call_type: str, endpoint: str | None, expected: bool +): + data: Final[dict[str, object]] = {"endpoint": endpoint} if endpoint is not None else {} + + assert is_non_conversational_request(call_type, data) is expected + + @pytest.mark.parametrize( "tool_input", ( diff --git a/tests/unit/proxy/guardrails/test_guardrail_coverage.py b/tests/unit/proxy/guardrails/test_guardrail_coverage.py index 1feae213eba..493eac430d0 100644 --- a/tests/unit/proxy/guardrails/test_guardrail_coverage.py +++ b/tests/unit/proxy/guardrails/test_guardrail_coverage.py @@ -12,17 +12,19 @@ inspection by the relevant guardrail hook: not just ``choices[0]``. """ +from types import SimpleNamespace from typing import Any, Dict, Final from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from httpx import Request, Response import litellm from litellm import DualCache from litellm.constants import DEFAULT_OPENAI_MODERATIONS_MODEL from litellm.proxy._types import UserAPIKeyAuth -from litellm.types.utils import Choices, Message, ModelResponse +from litellm.types.utils import CallTypesLiteral, Choices, Message, ModelResponse @pytest.fixture @@ -905,3 +907,78 @@ async def test_google_text_moderation_inspects_multimodal_content(user_api_key): ) assert seen_documents == ["hello world"] + + +class _FinanceFlaggingLanguageClient: + def __init__(self) -> None: + self.moderated_texts: Final[list[str]] = [] # mutable-ok: records what reached Google + + def moderate_text(self, request: SimpleNamespace) -> SimpleNamespace: + self.moderated_texts.append(request.document.content) + return SimpleNamespace(moderation_categories=[SimpleNamespace(name="Finance", confidence=0.885)]) + + +def _google_text_moderation_flagging_finance(): + from enterprise.enterprise_hooks.google_text_moderation import ( + ENTERPRISE_GoogleTextModeration, + ) + + guard: Final = ENTERPRISE_GoogleTextModeration.__new__(ENTERPRISE_GoogleTextModeration) + guard.client = _FinanceFlaggingLanguageClient() + guard.language_document = lambda content, type_: SimpleNamespace(content=content) + guard.moderate_text_request = lambda document: SimpleNamespace(document=document) + guard.document_type = "PLAIN_TEXT" + guard.finance_confidence_threshold = 0.8 + return guard + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("call_type", "data"), + [ + ("aembedding", {"model": "text-embedding-ada-002", "input": "stocks"}), + ("embedding", {"model": "text-embedding-ada-002", "input": ["stocks", "mortgage"]}), + ("allm_passthrough_route", {"model": "gigachat-embeddings", "endpoint": "embeddings", "input": ["stocks"]}), + ], +) +async def test_google_text_moderation_leaves_embedding_input_unmoderated( + user_api_key: UserAPIKeyAuth, call_type: CallTypesLiteral, data: dict[str, object] +): + guard: Final = _google_text_moderation_flagging_finance() + + await guard.async_moderation_hook(data=data, user_api_key_dict=user_api_key, call_type=call_type) + + assert guard.client.moderated_texts == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("call_type", "data"), + [ + ("acompletion", {"model": "gpt-4o", "messages": [{"role": "user", "content": "stocks"}]}), + ("anthropic_messages", {"model": "claude", "messages": [{"role": "user", "content": "stocks"}]}), + ("aresponses", {"model": "gpt-4o", "input": "stocks"}), + ( + "allm_passthrough_route", + {"model": "gigachat", "endpoint": "chat/completions", "messages": [{"role": "user", "content": "stocks"}]}, + ), + ( + "allm_passthrough_route", + { + "model": "gigachat", + "endpoint": "chat/completions#embeddings", + "messages": [{"role": "user", "content": "stocks"}], + }, + ), + ], +) +async def test_google_text_moderation_still_blocks_conversations( + user_api_key: UserAPIKeyAuth, call_type: CallTypesLiteral, data: dict[str, object] +): + guard: Final = _google_text_moderation_flagging_finance() + + with pytest.raises(HTTPException) as exc_info: + await guard.async_moderation_hook(data=data, user_api_key_dict=user_api_key, call_type=call_type) + + assert exc_info.value.status_code == 400 + assert guard.client.moderated_texts == ["stocks"]