Skip to content
Merged
60 changes: 32 additions & 28 deletions litellm/proxy/guardrails/_content_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ def is_text_content_call_type(call_type: str) -> bool:
return call_type in TEXT_CONTENT_CALL_TYPES


TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"})
Comment thread
ryan-crabbe-berri marked this conversation as resolved.


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."""
Expand All @@ -48,7 +51,7 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]:
continue
if not isinstance(part, dict):
continue
if part.get("type") == "text":
if part.get("type") in TEXT_PART_TYPES:
text = part.get("text")
if isinstance(text, str) and text:
yield text
Expand All @@ -58,14 +61,20 @@ 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 isinstance(input_value, list):
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
return list(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 []
if not isinstance(input_value, list):
return []
messages: List[Dict[str, Any]] = []
Comment thread
ryan-crabbe-berri marked this conversation as resolved.
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"]})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cato analyze missing role coercion

Medium Severity

build_inspection_messages now emits role: "tool" for Responses function_call_output and no longer collapses non-chat roles, while Cato still POSTs that payload to /fw/v1/analyze without the AIM-local coercion. That can reproduce the bare-tool 422 AIM avoids on tool-calling /v1/responses requests.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 18d3a6f. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not a regression, Cato skipped check before.

return messages


def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]:
Expand Down Expand Up @@ -112,7 +121,7 @@ def _rewrite_content(content: Any) -> Any:
new_parts.append(visit(part))
elif (
isinstance(part, dict)
and part.get("type") == "text"
and part.get("type") in TEXT_PART_TYPES
and isinstance(part.get("text"), str)
and part["text"]
):
Expand All @@ -136,25 +145,20 @@ def _rewrite_content(content: Any) -> Any:
data["input"] = visit(input_value)
return visited
if isinstance(input_value, list):
# List of full messages: rewrite each message's content.
if input_value and all(isinstance(item, dict) and "role" in item for item in input_value):
for item in input_value:
if "content" in item:
item["content"] = _rewrite_content(item["content"])
return visited
# 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
input_value[idx] = visit(item)
elif (
isinstance(item, dict)
and item.get("type") == "text"
and isinstance(item.get("text"), str)
and item["text"]
):
visited += 1
input_value[idx] = {**item, "text": visit(item["text"])}
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
Expand Down
17 changes: 14 additions & 3 deletions litellm/proxy/guardrails/guardrail_hooks/aim/aim.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,10 @@ async def call_aim_guardrail(self, data: dict, hook: str, key_alias: Optional[st
user_email=user_email,
litellm_call_id=call_id,
)
# Covers multimodal list content + Responses-API input.
response = await self.async_handler.post(
f"{self.api_base}/fw/v1/analyze",
headers=headers,
json={"messages": build_inspection_messages(data)},
json={"messages": self._build_aim_inspection_messages(data)},
)
response.raise_for_status()
res = response.json()
Expand All @@ -116,6 +115,15 @@ async def call_aim_guardrail(self, data: dict, hook: str, key_alias: Optional[st
verbose_proxy_logger.error(f"Aim: {action_type} action")
return data

@staticmethod
def _build_aim_inspection_messages(data: dict) -> list[dict[str, str]]:
"""AIM validates against the OpenAI chat schema. Bare ``role: "tool"``
without ``tool_call_id`` and bare ``role: "function"`` without ``name``
are rejected; the flatten drops those fields, so any role outside
``{system, user, assistant}`` collapses to ``user`` for the AIM POST."""
safe_roles = {"system", "user", "assistant"}
return [{**m, "role": "user"} if m["role"] not in safe_roles else m for m in build_inspection_messages(data)]

@staticmethod
def _rejection(message: str, *, openai_code: str | None = None) -> ProxyException:
return ProxyException(
Expand Down Expand Up @@ -177,7 +185,10 @@ async def call_aim_guardrail_on_output(
user_email=user_email,
litellm_call_id=call_id,
),
json={"messages": build_inspection_messages(request_data) + [{"role": "assistant", "content": output}]},
json={
"messages": self._build_aim_inspection_messages(request_data)
+ [{"role": "assistant", "content": output}]
},
)
response.raise_for_status()
res = response.json()
Expand Down
88 changes: 88 additions & 0 deletions tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Tests for the AIM guardrail's inspection-payload construction."""

from litellm.proxy.guardrails.guardrail_hooks.aim.aim import AimGuardrail


def test_aim_inspection_messages_coerces_chat_completions_tool_role_to_user():
"""LIT-4294: A valid chat-completions ``role: "tool"`` message carries a
``tool_call_id``, but the inspection flatten drops every field except
``role`` and ``content``. A bare ``tool`` message without ``tool_call_id``
is schema-invalid per the OpenAI chat schema, and the customer's writeup
reproduced AIM's ``/fw/v1/analyze`` returning 422 on exactly that shape.
The AIM POST collapses the role to ``user``; the outbound request to the
LLM is untouched."""
data = {
"messages": [
{"role": "user", "content": "weather in SF"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "sunny"},
]
}
assert AimGuardrail._build_aim_inspection_messages(data) == [
{"role": "user", "content": "weather in SF"},
{"role": "user", "content": "sunny"},
]


def test_aim_inspection_messages_coerces_non_standard_caller_role_to_user():
"""LIT-4294: A caller-supplied role outside {system, user, assistant}
(e.g. ``developer``, ``function``) is coerced to ``user`` for the AIM
POST, since AIM validates the payload against the OpenAI chat schema
and rejects unknown roles the same way it rejects bare ``tool``."""
data = {
"messages": [
{"role": "developer", "content": "system-ish instruction"},
{"role": "user", "content": "normal user text"},
]
}
assert AimGuardrail._build_aim_inspection_messages(data) == [
{"role": "user", "content": "system-ish instruction"},
{"role": "user", "content": "normal user text"},
]


def test_aim_inspection_messages_coerces_responses_function_call_output_role():
"""LIT-4294: the shared helper synthesises ``role: "tool"`` for a
Responses ``function_call_output`` item (semantic equivalent of
chat-completions tool messages). AIM's schema-validating POST cannot
carry ``tool_call_id`` in the flat inspection payload, so AIM collapses
that ``tool`` role to ``user`` locally before POSTing."""
data = {
"input": [
{
"type": "function_call_output",
"call_id": "c1",
"output": [{"type": "input_text", "text": "sunny"}],
},
]
}
assert AimGuardrail._build_aim_inspection_messages(data) == [
{"role": "user", "content": "sunny"},
]


def test_aim_inspection_messages_preserves_safe_roles():
"""Safe roles pass through untouched — the coercion only fires for
roles the OpenAI chat schema flatten cannot represent standalone."""
data = {
"messages": [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
}
assert AimGuardrail._build_aim_inspection_messages(data) == [
{"role": "system", "content": "be helpful"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
Loading
Loading