diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 6ed6f0013df..ae92adcb1ee 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -8,7 +8,7 @@ skip the other shapes — these helpers normalise that so every hook sees every text fragment. """ -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping from typing import Any, Final # Call types whose body carries free-form chat / prompt text that @@ -33,7 +33,9 @@ def is_text_content_call_type(call_type: str) -> bool: return call_type in TEXT_CONTENT_CALL_TYPES -TEXT_PART_TYPES: Final[frozenset[str]] = frozenset({"text", "input_text", "output_text"}) +TEXT_PART_TYPES: Final[frozenset[str]] = frozenset( + {"text", "input_text", "output_text", "summary_text", "reasoning_text"} +) # Responses-API item types whose ``output`` field carries user/tool text # that guardrails should inspect. ``function_call_output`` is the @@ -42,6 +44,16 @@ TEXT_PART_TYPES: Final[frozenset[str]] = frozenset({"text", "input_text", "outpu _OUTPUT_ITEM_TYPES: Final[frozenset[str]] = frozenset({"function_call_output", "custom_tool_call_output"}) +def _part_text(part: Mapping[str, object]) -> str | None: + """Return non-empty plaintext from any content part that carries ``text``.""" + if not isinstance(part, dict): + return None + text = part.get("text") + if isinstance(text, str) and text: + return text + return None + + def _iter_text_parts_in_content(content: Any) -> Iterator[str]: """Yield text fragments from a ``message.content`` value (string or multimodal list). Non-text parts (images, audio, …) are skipped.""" @@ -58,10 +70,9 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]: continue if not isinstance(part, dict): continue - if part.get("type") in TEXT_PART_TYPES: - text = part.get("text") - if isinstance(text, str) and text: - yield text + text = _part_text(part) + if text is not None: + yield text def _coerce_input_to_messages(input_value: Any) -> list[dict[str, Any]]: @@ -75,8 +86,23 @@ def _coerce_input_to_messages(input_value: Any) -> list[dict[str, Any]]: if isinstance(item, str): messages.append({"role": "user", "content": item}) elif isinstance(item, dict): - if item.get("type") in TEXT_PART_TYPES: + if _part_text(item) is not None: messages.append({"role": item.get("role") or "user", "content": [item]}) + elif item.get("type") == "reasoning": + if "content" in item: + messages.append( + { # mutable-ok: append reasoning content + "role": item.get("role") or "assistant", + "content": item["content"], + } + ) + if isinstance(item.get("summary"), list): + messages.append( + { # mutable-ok: append reasoning summary + "role": item.get("role") or "assistant", + "content": item["summary"], + } + ) elif "content" in item: messages.append({"role": item.get("role") or "user", "content": item["content"]}) elif item.get("type") in _OUTPUT_ITEM_TYPES and "output" in item: @@ -126,12 +152,7 @@ def walk_user_text(data: dict[str, Any], visit: Callable[[str], str]) -> int: if isinstance(part, str) and part: visited += 1 new_parts.append(visit(part)) - elif ( - isinstance(part, dict) - and part.get("type") in TEXT_PART_TYPES - and isinstance(part.get("text"), str) - and part["text"] - ): + elif isinstance(part, dict) and _part_text(part) is not None: visited += 1 new_parts.append({**part, "text": visit(part["text"])}) else: @@ -158,10 +179,14 @@ def walk_user_text(data: dict[str, Any], visit: Callable[[str], str]) -> int: visited += 1 input_value[idx] = visit(item) elif isinstance(item, dict): - if item.get("type") in TEXT_PART_TYPES: - if isinstance(item.get("text"), str) and item["text"]: - visited += 1 - input_value[idx] = {**item, "text": visit(item["text"])} + if _part_text(item) is not None: + visited += 1 + input_value[idx] = {**item, "text": visit(item["text"])} # mutable-ok: rewrite text part in place + elif item.get("type") == "reasoning": + if "content" in item: + item["content"] = _rewrite_content(item["content"]) + if isinstance(item.get("summary"), list): + item["summary"] = _rewrite_content(item["summary"]) elif "content" in item: item["content"] = _rewrite_content(item["content"]) elif item.get("type") in _OUTPUT_ITEM_TYPES and "output" in item: diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index d7b6b8c7b8f..c5f40242bfd 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -638,7 +638,7 @@ class LiteLLMCompletionResponsesConfig: # Not followed by an assistant message — keep the reasoning # standalone instead of dropping it. merged.extend( # mutable-ok: append reasoning messages - [ + [ # mutable-ok: append reasoning messages ChatCompletionResponseMessage( role="assistant", content=None, @@ -652,7 +652,7 @@ class LiteLLMCompletionResponsesConfig: merged.append(msg) merged.extend( # mutable-ok: append trailing reasoning - [ + [ # mutable-ok: append trailing reasoning ChatCompletionResponseMessage( role="assistant", content=None, @@ -1196,6 +1196,8 @@ class LiteLLMCompletionResponsesConfig: if text_parts: return "\n".join(text_parts) + # Guardrail traversal in litellm/proxy/guardrails/_content_utils.py + # inspects and rewrites these summary blocks before they are forwarded. summary: Final[object] = input_item.get("summary") if isinstance(summary, list): text_parts = [] # mutable-ok: text accumulator # rebind-ok: text accumulator diff --git a/tests/test_litellm/proxy/guardrails/test_content_utils.py b/tests/test_litellm/proxy/guardrails/test_content_utils.py index 3dfb98c12ea..d9e079c6d92 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_utils.py +++ b/tests/test_litellm/proxy/guardrails/test_content_utils.py @@ -149,6 +149,22 @@ def test_iter_message_text_responses_api_tool_call_taxonomy(): assert list(iter_message_text(data)) == ["hello", "sunny"] +def test_iter_message_text_inspects_reasoning_content_and_summary(): + """VERIA: reasoning items forwarded as ``reasoning_content`` must be + inspected, including ``summary`` blocks the bridge reads as a fallback.""" + data = { + "input": [ + { + "type": "reasoning", + "id": "rs_1", + "content": [{"type": "summary_text", "text": "content secret"}], + "summary": [{"type": "summary_text", "text": "summary secret"}], + } + ] + } + assert list(iter_message_text(data)) == ["content secret", "summary secret"] + + # ── walk_user_text ──────────────────────────────────────────────────────────── @@ -308,6 +324,27 @@ def test_walk_user_text_redacts_mixed_list_input(): assert data["input"][2] == {"type": "image_url", "image_url": {"url": "..."}} +def test_walk_user_text_redacts_reasoning_content_and_summary(): + """VERIA: in-place redaction must cover both plaintext shapes the bridge + forwards from a reasoning item.""" + data = { + "input": [ + { + "type": "reasoning", + "id": "rs_1", + "content": [{"type": "summary_text", "text": "AKIAEXAMPLE content"}], + "summary": [{"type": "summary_text", "text": "AKIAEXAMPLE summary"}], + } + ] + } + visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]")) + assert visited == 2 + item = data["input"][0] + assert item["content"][0]["text"] == "[REDACTED] content" + assert item["summary"][0]["text"] == "[REDACTED] summary" + assert item["id"] == "rs_1" + + # ── build_inspection_messages ───────────────────────────────────────────────── @@ -462,6 +499,23 @@ def test_build_inspection_messages_empty_data(): assert build_inspection_messages({"input": ""}) == [] +def test_build_inspection_messages_includes_reasoning_summary(): + """VERIA: remote guardrail APIs must see reasoning summaries even when + the reasoning item has no ``content`` field.""" + data = { + "input": [ + { + "type": "reasoning", + "id": "rs_1", + "summary": [{"type": "summary_text", "text": "secret summary"}], + } + ] + } + assert build_inspection_messages(data) == [ + {"role": "assistant", "content": "secret summary"} + ] + + # ── has_non_string_content ────────────────────────────────────────────────────