fix(guardrails): inspect responses reasoning content and summary text

This commit is contained in:
HarryZhou 2026-08-18 21:11:29 +08:00
parent de95372dfb
commit 2d4e6afe1c
3 changed files with 100 additions and 19 deletions

View file

@ -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:

View file

@ -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

View file

@ -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 ────────────────────────────────────────────────────