mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(guardrails): inspect responses reasoning content and summary text
This commit is contained in:
parent
de95372dfb
commit
2d4e6afe1c
3 changed files with 100 additions and 19 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue