mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +00:00
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:
parent
4e5f585c2a
commit
0f68b9919c
4 changed files with 125 additions and 3 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue