diff --git a/enterprise/enterprise_hooks/banned_keywords.py b/enterprise/enterprise_hooks/banned_keywords.py index aafbb8cf40..a86b59daf5 100644 --- a/enterprise/enterprise_hooks/banned_keywords.py +++ b/enterprise/enterprise_hooks/banned_keywords.py @@ -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: diff --git a/enterprise/enterprise_hooks/google_text_moderation.py b/enterprise/enterprise_hooks/google_text_moderation.py index 75829c41ca..5b2d71c5cc 100644 --- a/enterprise/enterprise_hooks/google_text_moderation.py +++ b/enterprise/enterprise_hooks/google_text_moderation.py @@ -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) diff --git a/enterprise/enterprise_hooks/openai_moderation.py b/enterprise/enterprise_hooks/openai_moderation.py index a98d5b30d7..2162370804 100644 --- a/enterprise/enterprise_hooks/openai_moderation.py +++ b/enterprise/enterprise_hooks/openai_moderation.py @@ -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 diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 97a022768e..576fefe85a 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -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 diff --git a/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py b/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py index 435bb383df..36d70a37c7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py +++ b/litellm/proxy/guardrails/guardrail_hooks/ibm_guardrails/ibm_detector.py @@ -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 diff --git a/litellm/proxy/hooks/azure_content_safety.py b/litellm/proxy/hooks/azure_content_safety.py index e66ada4208..a21f0566cc 100644 --- a/litellm/proxy/hooks/azure_content_safety.py +++ b/litellm/proxy/hooks/azure_content_safety.py @@ -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: diff --git a/tests/test_litellm/proxy/guardrails/test_content_utils.py b/tests/test_litellm/proxy/guardrails/test_content_utils.py index 2491363330..e26dc7aa2d 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_utils.py +++ b/tests/test_litellm/proxy/guardrails/test_content_utils.py @@ -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": ""} + assert data["input"][1] == "" + assert data["input"][2] == {"type": "image_url", "image_url": {"url": "..."}} + + # ── build_inspection_messages ─────────────────────────────────────────────────