From 0f68b9919ca31df448bcc89e3dfe344b6ff039b9 Mon Sep 17 00:00:00 2001 From: simonp9 <349053457+simonp9@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:05:03 -0700 Subject: [PATCH] fix(guardrails): skip embeddings input in google_text_moderation (#48934) * fix(guardrails): skip embeddings input in google_text_moderation and openai_moderations Since v1.103.0 the proxy dispatches these legacy moderation callbacks on every route, and they read the embeddings input field as text, so benign embedding inputs were blocked. Return early for embedding call types, matching the Aim and Cato guardrails. * test(guardrails): type the embeddings moderation test parameters * fix(guardrails): keep openai_moderations moderating embeddings, skip only google_text_moderation Google's moderation scores topic categories like Finance and Health, so it blocks documents being indexed by subject. OpenAI's moderation scores harm categories only, so it keeps moderating embedding input as it has since v1.103.0 * fix(guardrails): skip Gigachat passthrough embeddings in google_text_moderation Treat an allm_passthrough_route call whose endpoint ends in embeddings as non-conversational, log the skip, return None from the early exit, and type the hook's data so the basedpyright gate does not grow * fix(guardrails): match only plain embeddings passthrough paths in google_text_moderation --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../google_text_moderation.py | 10 ++- litellm/proxy/guardrails/_content_utils.py | 14 ++++ .../proxy/guardrails/test_content_utils.py | 25 ++++++ .../guardrails/test_guardrail_coverage.py | 79 ++++++++++++++++++- 4 files changed, 125 insertions(+), 3 deletions(-) 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"]