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:
user
2026-05-01 04:10:04 +00:00
co-authored by Claude Opus 4.7
parent d767708571
commit 7514bb4740
7 changed files with 89 additions and 47 deletions
@@ -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
+24 -18
View File
@@ -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
+2 -2
View File
@@ -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 ─────────────────────────────────────────────────