mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-28 18:21:38 +00:00
fix(guardrails): close mixed-list gap, drop dead code, rename helper
Greptile P2 follow-ups on _content_utils.py: - Drop unreachable ``_resolve_messages``. The new ``_iter_inspection_messages`` walks ``messages`` AND ``input`` independently; leaving the old fallback-only variant around invited a future maintainer to wire it back up and silently narrow coverage. - Rename ``iter_user_text`` → ``iter_message_text``. The helper walks every role (user, assistant, system); the old name implied user-turn content only. Callers and tests updated. - Close mixed-list coverage gap. When ``data["input"]`` was a list mixing content-part dicts and bare strings, ``iter_message_text`` and ``build_inspection_messages`` only saw the dict parts while ``walk_user_text`` already inspected both. ``_iter_text_parts_in_content`` now treats bare strings inside a content list as text fragments, so read and write helpers agree on coverage. Adds two regression tests for the mixed-list shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ from typing import Literal
|
||||
import litellm
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_user_text
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from fastapi import HTTPException
|
||||
@@ -76,7 +76,7 @@ class _ENTERPRISE_BannedKeywords(CustomLogger):
|
||||
self.print_verbose("Inside Banned Keyword List Pre-Call Hook")
|
||||
if call_type == "completion":
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
for text in iter_user_text(data):
|
||||
for text in iter_message_text(data):
|
||||
self.test_violation(test_str=text)
|
||||
|
||||
except HTTPException as e:
|
||||
|
||||
@@ -12,7 +12,7 @@ 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_user_text
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ class _ENTERPRISE_GoogleTextModeration(CustomLogger):
|
||||
- Rejects request if it fails safety check
|
||||
"""
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
text = "".join(iter_user_text(data))
|
||||
text = "".join(iter_message_text(data))
|
||||
if text:
|
||||
document = self.language_document(content=text, type_=self.document_type)
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ 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_user_text
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class _ENTERPRISE_OpenAI_Moderation(CustomLogger):
|
||||
call_type: CallTypesLiteral,
|
||||
):
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
text = "".join(iter_user_text(data))
|
||||
text = "".join(iter_message_text(data))
|
||||
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Shared helpers for guardrail hooks: extract user-supplied text from a
|
||||
request body regardless of whether it uses Chat Completions ``messages``,
|
||||
Responses-API ``input``, or multimodal list-format ``content`` parts.
|
||||
Shared helpers for guardrail hooks: extract text from a request body
|
||||
regardless of whether it uses Chat Completions ``messages``, Responses-API
|
||||
``input``, or multimodal list-format ``content`` parts.
|
||||
|
||||
Hooks that only check ``data["messages"]`` for string content silently
|
||||
skip the other shapes — these helpers normalise that so every hook sees
|
||||
@@ -19,6 +19,12 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
|
||||
yield content
|
||||
elif isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, str):
|
||||
# A bare string in a content/input list is itself a text
|
||||
# fragment (Responses-API mixed-list shape).
|
||||
if part:
|
||||
yield part
|
||||
continue
|
||||
if not isinstance(part, dict):
|
||||
continue
|
||||
if part.get("type") == "text":
|
||||
@@ -36,20 +42,13 @@ def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]:
|
||||
isinstance(item, dict) and "role" in item for item in input_value
|
||||
):
|
||||
return list(input_value)
|
||||
if input_value and all(isinstance(item, str) for item in input_value):
|
||||
return [{"role": "user", "content": item} for item in input_value]
|
||||
# Mixed lists (content-part dicts + bare strings) and pure
|
||||
# string/dict lists all become a single user message; the content
|
||||
# iterator below handles each element type uniformly.
|
||||
return [{"role": "user", "content": input_value}]
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_messages(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Return the messages list to inspect, falling back to ``input``."""
|
||||
messages = data.get("messages")
|
||||
if isinstance(messages, list) and messages:
|
||||
return messages
|
||||
return _coerce_input_to_messages(data.get("input"))
|
||||
|
||||
|
||||
def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
|
||||
"""Yield every message-like dict, walking ``messages`` AND ``input``."""
|
||||
messages = data.get("messages")
|
||||
@@ -58,8 +57,12 @@ def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
|
||||
yield from _coerce_input_to_messages(data.get("input"))
|
||||
|
||||
|
||||
def iter_user_text(data: Dict[str, Any]) -> Iterator[str]:
|
||||
"""Yield every user-supplied text fragment from ``messages`` and ``input``."""
|
||||
def iter_message_text(data: Dict[str, Any]) -> Iterator[str]:
|
||||
"""Yield every text fragment from ``messages`` AND ``input``.
|
||||
|
||||
Walks every role (user, assistant, system, …) — guardrails inspect
|
||||
the entire conversation, not just user turns.
|
||||
"""
|
||||
for message in _iter_inspection_messages(data):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
@@ -67,7 +70,7 @@ def iter_user_text(data: Dict[str, Any]) -> Iterator[str]:
|
||||
|
||||
|
||||
def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
||||
"""Rewrite every user-supplied text fragment in place via ``visit``.
|
||||
"""Rewrite every text fragment in place via ``visit``.
|
||||
|
||||
Mutates ``data["messages"]`` and ``data["input"]``. Returns the number
|
||||
of fragments visited so callers can short-circuit when nothing was
|
||||
@@ -85,7 +88,10 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
||||
if isinstance(content, list):
|
||||
new_parts = []
|
||||
for part in content:
|
||||
if (
|
||||
if isinstance(part, str) and part:
|
||||
visited += 1
|
||||
new_parts.append(visit(part))
|
||||
elif (
|
||||
isinstance(part, dict)
|
||||
and part.get("type") == "text"
|
||||
and isinstance(part.get("text"), str)
|
||||
@@ -119,7 +125,7 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
||||
if "content" in item:
|
||||
item["content"] = _rewrite_content(item["content"])
|
||||
return visited
|
||||
# List of content parts or strings: rewrite in place.
|
||||
# List of content parts and/or bare strings: rewrite in place.
|
||||
for idx, item in enumerate(input_value):
|
||||
if isinstance(item, str) and item:
|
||||
visited += 1
|
||||
|
||||
@@ -20,7 +20,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
||||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_user_text
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.ibm import (
|
||||
IBMDetectorDetection,
|
||||
@@ -465,7 +465,7 @@ class IBMGuardrailDetector(CustomGuardrail):
|
||||
return data
|
||||
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
contents_to_check: List[str] = list(iter_user_text(data))
|
||||
contents_to_check: List[str] = list(iter_message_text(data))
|
||||
if contents_to_check:
|
||||
if self.is_detector_server:
|
||||
# Call detector server with all contents at once
|
||||
@@ -540,7 +540,7 @@ class IBMGuardrailDetector(CustomGuardrail):
|
||||
return
|
||||
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
contents_to_check: List[str] = list(iter_user_text(data))
|
||||
contents_to_check: List[str] = list(iter_message_text(data))
|
||||
if contents_to_check:
|
||||
if self.is_detector_server:
|
||||
# Call detector server with all contents at once
|
||||
|
||||
@@ -8,7 +8,7 @@ from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.proxy.guardrails._content_utils import iter_user_text
|
||||
from litellm.proxy.guardrails._content_utils import iter_message_text
|
||||
|
||||
|
||||
class _PROXY_AzureContentSafety(
|
||||
@@ -121,7 +121,7 @@ class _PROXY_AzureContentSafety(
|
||||
try:
|
||||
if call_type == "completion":
|
||||
# Covers multimodal list content + Responses-API input.
|
||||
for text in iter_user_text(data):
|
||||
for text in iter_message_text(data):
|
||||
await self.test_violation(content=text, source="input")
|
||||
|
||||
except HTTPException as e:
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
from litellm.proxy.guardrails._content_utils import (
|
||||
build_inspection_messages,
|
||||
iter_user_text,
|
||||
iter_message_text,
|
||||
walk_user_text,
|
||||
)
|
||||
|
||||
|
||||
# ── iter_user_text ────────────────────────────────────────────────────────────
|
||||
# ── iter_message_text ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_iter_user_text_string_messages():
|
||||
def test_iter_message_text_string_messages():
|
||||
data = {
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
]
|
||||
}
|
||||
assert list(iter_user_text(data)) == ["hello", "hi"]
|
||||
assert list(iter_message_text(data)) == ["hello", "hi"]
|
||||
|
||||
|
||||
def test_iter_user_text_multimodal_list_content():
|
||||
def test_iter_message_text_multimodal_list_content():
|
||||
"""VERIA-11: list-format content must be inspected, not silently skipped."""
|
||||
data = {
|
||||
"messages": [
|
||||
@@ -34,26 +34,26 @@ def test_iter_user_text_multimodal_list_content():
|
||||
}
|
||||
]
|
||||
}
|
||||
assert list(iter_user_text(data)) == ["AWS_KEY=AKIA...", "more secrets"]
|
||||
assert list(iter_message_text(data)) == ["AWS_KEY=AKIA...", "more secrets"]
|
||||
|
||||
|
||||
def test_iter_user_text_responses_api_string_input():
|
||||
def test_iter_message_text_responses_api_string_input():
|
||||
"""fniVO9-F: Responses-API ``input`` must be inspectable when ``messages`` absent."""
|
||||
data = {"input": "tell me a secret"}
|
||||
assert list(iter_user_text(data)) == ["tell me a secret"]
|
||||
assert list(iter_message_text(data)) == ["tell me a secret"]
|
||||
|
||||
|
||||
def test_iter_user_text_responses_api_list_input_messages():
|
||||
def test_iter_message_text_responses_api_list_input_messages():
|
||||
data = {
|
||||
"input": [
|
||||
{"role": "user", "content": "first"},
|
||||
{"role": "user", "content": "second"},
|
||||
]
|
||||
}
|
||||
assert list(iter_user_text(data)) == ["first", "second"]
|
||||
assert list(iter_message_text(data)) == ["first", "second"]
|
||||
|
||||
|
||||
def test_iter_user_text_responses_api_list_input_content_parts():
|
||||
def test_iter_message_text_responses_api_list_input_content_parts():
|
||||
data = {
|
||||
"input": [
|
||||
{"type": "text", "text": "alpha"},
|
||||
@@ -61,23 +61,42 @@ def test_iter_user_text_responses_api_list_input_content_parts():
|
||||
{"type": "text", "text": "beta"},
|
||||
]
|
||||
}
|
||||
assert list(iter_user_text(data)) == ["alpha", "beta"]
|
||||
assert list(iter_message_text(data)) == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_iter_user_text_walks_messages_and_input_independently():
|
||||
def test_iter_message_text_responses_api_list_input_mixed_dicts_and_strings():
|
||||
"""Greptile P2: mixed-list ``input`` with content-part dicts AND bare
|
||||
strings must yield every text fragment — read helpers used to truncate
|
||||
the bare strings."""
|
||||
data = {
|
||||
"input": [
|
||||
{"type": "text", "text": "from-dict"},
|
||||
"from-bare-string",
|
||||
{"type": "image_url", "image_url": {"url": "..."}},
|
||||
"another-bare-string",
|
||||
]
|
||||
}
|
||||
assert list(iter_message_text(data)) == [
|
||||
"from-dict",
|
||||
"from-bare-string",
|
||||
"another-bare-string",
|
||||
]
|
||||
|
||||
|
||||
def test_iter_message_text_walks_messages_and_input_independently():
|
||||
"""When both are present (rare), every fragment from either field is
|
||||
inspected — a stricter guarantee than "first one wins"."""
|
||||
data = {
|
||||
"messages": [{"role": "user", "content": "msg-content"}],
|
||||
"input": "input-content",
|
||||
}
|
||||
assert list(iter_user_text(data)) == ["msg-content", "input-content"]
|
||||
assert list(iter_message_text(data)) == ["msg-content", "input-content"]
|
||||
|
||||
|
||||
def test_iter_user_text_empty_data():
|
||||
assert list(iter_user_text({})) == []
|
||||
assert list(iter_user_text({"messages": []})) == []
|
||||
assert list(iter_user_text({"input": ""})) == []
|
||||
def test_iter_message_text_empty_data():
|
||||
assert list(iter_message_text({})) == []
|
||||
assert list(iter_message_text({"messages": []})) == []
|
||||
assert list(iter_message_text({"input": ""})) == []
|
||||
|
||||
|
||||
# ── walk_user_text ────────────────────────────────────────────────────────────
|
||||
@@ -139,6 +158,23 @@ def test_walk_user_text_redacts_responses_api_list_input():
|
||||
assert data["input"][1] == {"type": "image_url", "image_url": {"url": "..."}}
|
||||
|
||||
|
||||
def test_walk_user_text_redacts_mixed_list_input():
|
||||
"""Read and write helpers must agree on coverage — bare strings inside
|
||||
a mixed ``input`` list are inspected by both."""
|
||||
data = {
|
||||
"input": [
|
||||
{"type": "text", "text": "secret-one"},
|
||||
"secret-two",
|
||||
{"type": "image_url", "image_url": {"url": "..."}},
|
||||
]
|
||||
}
|
||||
visited = walk_user_text(data, lambda s: f"<{s}>")
|
||||
assert visited == 2
|
||||
assert data["input"][0] == {"type": "text", "text": "<secret-one>"}
|
||||
assert data["input"][1] == "<secret-two>"
|
||||
assert data["input"][2] == {"type": "image_url", "image_url": {"url": "..."}}
|
||||
|
||||
|
||||
# ── build_inspection_messages ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user