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>
This commit is contained in:
simonp9 2026-10-28 20:05:03 -07:00 • committed by GitHub
parent 4e5f585c2a
commit 0f68b9919c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 125 additions and 3 deletions

View file

@ -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:

View file

@ -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"}
)

View file

@ -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",
(

View file

@ -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"]