mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-19 00:01:29 +00:00
* fix(guardrails): walk Responses-API text taxonomy in shared content helpers
Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently
drops all text on the /v1/responses path. AIM turns it into a loud 422 (
{"error":"No messages in the request"}); every other guardrail (Lakera v2,
Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret
detection) scans an empty payload and lets the request through unscanned.
Three defects, all in _content_utils.py:
1. _iter_text_parts_in_content recognised only part.type == "text", but the
Responses API uses input_text (request) and output_text (assistant).
2. _coerce_input_to_messages gated on "every item has a role key"; any
Responses input list containing a function_call or function_call_output
item failed the check and was wrapped as one opaque blob.
3. build_inspection_messages forwarded any role through, including a bare
tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze
reject with a schema error.
Fix walks the actual Responses item taxonomy (message, function_call,
function_call_output, bare content parts and strings), recognises
{text, input_text, output_text} everywhere, and coerces any role outside
{system, user, assistant} to user in the outbound inspection payload.
* style: ruff-format changed guardrail files
* test(guardrails): cover function_call_output string form; drop em-dash in new docstring
* fix(guardrails): map function_call_output straight to user role
Avoids ever materialising a schema-invalid bare tool message. The
downstream role-safety coercion in build_inspection_messages still
guards genuinely caller-supplied non-standard roles (developer,
function, custom values); add a regression test covering that path
so the coercion has real coverage after this simplification.
* test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages
* docs(test): soften AIM-specific claims in LIT-4294 test docstrings
Ryan's review flagged that several test docstrings assert AIM's
/fw/v1/analyze validates + rejects specific schema violations. That
behavior is customer-reported in the LIT-4294 writeup, not directly
verified by us. Rephrase to attribute the AIM 422 to the customer's
writeup and describe the underlying constraint as the OpenAI chat
schema; any downstream API that validates against that schema rejects
the same shape.
* refactor(guardrails): move unsupported-role coercion into AIM only
The generic coercion in build_inspection_messages collapsed any role
outside {system, user, assistant} to user for every caller of the
helper. Combined with the pre-existing apply_redacted_messages_back
write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400
on chat-completions tool-message masking into a silent semantic
corruption of the outbound request (role tool with tool_call_id got
rewritten to bare role user, dropping the assistant + tool_calls
sibling).
AIM specifically requires the coercion because its /fw/v1/analyze
validates the payload against the OpenAI chat schema; other guardrails
either do not validate roles or do their own reconstruction. Move the
coercion to AimGuardrail._build_aim_inspection_messages so the shared
helper keeps caller roles intact and no new cross-guardrail role
corruption is introduced. The pre-existing apply_redacted_messages_back
structural flatten remains as separate follow-up work.
function_call_output items still synthesise role user in the shared
helper because they have no natural role field, which is a different
concern from coercing a caller-supplied role.
* refactor(guardrails): preserve role fidelity in shared _content_utils
Shared inspection helpers should extract text and preserve semantic
role signals; role coercion for third-party schema safety stays inside
the guardrail that needs it (AIM).
Three shared-helper changes:
- Bare content-part dicts (input_text/output_text) with an explicit role
keep it; only role-less parts default to user.
- Responses message items already had their role preserved; the
behavior is now covered by an explicit test.
- function_call_output items default to role tool (semantic equivalent
of the chat-completions tool message shape) instead of role user, so
Responses and chat completions produce symmetric inspection payloads.
A caller-supplied role on the item is still preserved.
AIM's schema-safe coercion in _build_aim_inspection_messages already
handles the resulting role tool: it collapses to user before the POST
to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the
bare tool message (no tool_call_id can survive the flatten). Added a
regression test in test_aim.py covering that path.
229 lines
9.3 KiB
Python
229 lines
9.3 KiB
Python
"""
|
|
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
|
|
every text fragment.
|
|
"""
|
|
|
|
from typing import Any, Callable, Dict, FrozenSet, Iterator, List
|
|
|
|
# Call types whose body carries free-form chat / prompt text that
|
|
# text-content guardrails (banned keywords, content moderation, secret
|
|
# detection, …) should inspect. The proxy ingress passes ``route_type``
|
|
# straight through as ``call_type``, so the literal values here are
|
|
# what the guardrail dispatcher actually receives:
|
|
#
|
|
# /v1/chat/completions -> "acompletion"
|
|
# /v1/responses -> "aresponses"
|
|
#
|
|
# ``"completion"`` is included for SDK / internal callers that invoke
|
|
# ``pre_call_hook`` directly with the sync name. Embedding, moderation,
|
|
# audio, and transcription endpoints are deliberately excluded — text
|
|
# guardrails on those paths are a separate scope.
|
|
TEXT_CONTENT_CALL_TYPES: FrozenSet[str] = frozenset({"completion", "acompletion", "aresponses"})
|
|
|
|
|
|
def is_text_content_call_type(call_type: str) -> bool:
|
|
"""Return True if ``call_type`` carries free-form text that text
|
|
guardrails should inspect (Chat Completions or Responses API)."""
|
|
return call_type in TEXT_CONTENT_CALL_TYPES
|
|
|
|
|
|
TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"})
|
|
|
|
|
|
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."""
|
|
if isinstance(content, str):
|
|
if content:
|
|
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") in TEXT_PART_TYPES:
|
|
text = part.get("text")
|
|
if isinstance(text, str) and text:
|
|
yield text
|
|
|
|
|
|
def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]:
|
|
"""Coerce a Responses-API ``data["input"]`` value into chat-style messages."""
|
|
if isinstance(input_value, str):
|
|
return [{"role": "user", "content": input_value}]
|
|
if not isinstance(input_value, list):
|
|
return []
|
|
messages: List[Dict[str, Any]] = []
|
|
for item in input_value:
|
|
if isinstance(item, str):
|
|
messages.append({"role": "user", "content": item})
|
|
elif isinstance(item, dict):
|
|
if item.get("type") in TEXT_PART_TYPES:
|
|
messages.append({"role": item.get("role") or "user", "content": [item]})
|
|
elif "content" in item:
|
|
messages.append({"role": item.get("role") or "user", "content": item["content"]})
|
|
elif item.get("type") == "function_call_output" and "output" in item:
|
|
messages.append({"role": item.get("role") or "tool", "content": item["output"]})
|
|
return messages
|
|
|
|
|
|
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")
|
|
if isinstance(messages, list):
|
|
yield from messages
|
|
yield from _coerce_input_to_messages(data.get("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
|
|
yield from _iter_text_parts_in_content(message.get("content"))
|
|
|
|
|
|
def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int:
|
|
"""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
|
|
inspected.
|
|
"""
|
|
visited = 0
|
|
|
|
def _rewrite_content(content: Any) -> Any:
|
|
nonlocal visited
|
|
if isinstance(content, str):
|
|
if content:
|
|
visited += 1
|
|
return visit(content)
|
|
return content
|
|
if isinstance(content, list):
|
|
new_parts: List[Any] = []
|
|
for part in content:
|
|
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"]
|
|
):
|
|
visited += 1
|
|
new_parts.append({**part, "text": visit(part["text"])})
|
|
else:
|
|
new_parts.append(part)
|
|
return new_parts
|
|
return content
|
|
|
|
messages = data.get("messages")
|
|
if isinstance(messages, list):
|
|
for message in messages:
|
|
if isinstance(message, dict) and "content" in message:
|
|
message["content"] = _rewrite_content(message["content"])
|
|
|
|
input_value = data.get("input")
|
|
if isinstance(input_value, str):
|
|
if input_value:
|
|
visited += 1
|
|
data["input"] = visit(input_value)
|
|
return visited
|
|
if isinstance(input_value, list):
|
|
for idx, item in enumerate(input_value):
|
|
if isinstance(item, str):
|
|
if item:
|
|
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"])}
|
|
elif "content" in item:
|
|
item["content"] = _rewrite_content(item["content"])
|
|
elif item.get("type") == "function_call_output" and "output" in item:
|
|
item["output"] = _rewrite_content(item["output"])
|
|
return visited
|
|
|
|
return visited
|
|
|
|
|
|
def apply_redacted_messages_back(data: Dict[str, Any], redacted_messages: List[Dict[str, Any]]) -> None:
|
|
"""Write redacted messages back to whichever field(s) the caller used.
|
|
|
|
Mask/anonymize paths take a synthesised messages list (from
|
|
:func:`build_inspection_messages`), get a redacted version back from a
|
|
third-party guardrail, and need to rewrite the request body. Writing
|
|
only to ``data["messages"]`` leaves the Responses-API ``data["input"]``
|
|
field untouched, so the unredacted text still reaches the LLM.
|
|
|
|
This helper updates both fields when both are present.
|
|
"""
|
|
if "messages" in data:
|
|
data["messages"] = redacted_messages
|
|
if isinstance(data.get("input"), str):
|
|
text_parts: List[str] = []
|
|
for msg in redacted_messages:
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
text_parts.extend(_iter_text_parts_in_content(msg.get("content")))
|
|
data["input"] = "\n".join(text_parts)
|
|
|
|
|
|
def has_non_string_content(data: Dict[str, Any]) -> bool:
|
|
"""Return True if any inspected content is not a plain string.
|
|
|
|
Used by hooks whose mask/redact path operates on string offsets and
|
|
therefore cannot preserve multimodal non-text parts. Such hooks should
|
|
degrade to block-on-detect when this returns True so image/audio parts
|
|
are not silently stripped during in-place masking.
|
|
"""
|
|
messages = data.get("messages")
|
|
if isinstance(messages, list):
|
|
for message in messages:
|
|
if isinstance(message, dict) and not isinstance(message.get("content"), str):
|
|
if message.get("content") is not None:
|
|
return True
|
|
input_value = data.get("input")
|
|
if input_value is not None and not isinstance(input_value, str):
|
|
return True
|
|
return False
|
|
|
|
|
|
def build_inspection_messages(data: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
"""Synthesize a chat-style messages list for posting to a guardrail API.
|
|
|
|
Each returned message has a plain-string ``content`` — multimodal text
|
|
parts are joined with newlines and Responses-API ``input`` is lifted
|
|
into synthetic messages. Messages with no inspectable text are dropped.
|
|
|
|
Hooks that POST ``{"messages": [...]}`` to an external service should
|
|
call this instead of ``data.get("messages", [])`` so the Responses API
|
|
and multimodal content are covered.
|
|
"""
|
|
flattened: List[Dict[str, str]] = []
|
|
for message in _iter_inspection_messages(data):
|
|
if not isinstance(message, dict):
|
|
continue
|
|
text = "\n".join(_iter_text_parts_in_content(message.get("content")))
|
|
if not text:
|
|
continue
|
|
role = message.get("role", "user") or "user"
|
|
flattened.append({"role": role, "content": text})
|
|
return flattened
|