Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 36 additions & 32 deletions litellm/proxy/guardrails/_content_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,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"})


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 @@ -50,7 +53,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 @@ -60,16 +63,24 @@ 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]] = []
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]]:
Expand Down Expand Up @@ -116,7 +127,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 @@ -140,27 +151,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 @@ -105,11 +105,10 @@ async def call_aim_guardrail(
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 @@ -128,6 +127,18 @@ async def call_aim_guardrail(
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 @@ -192,7 +203,7 @@ async def call_aim_guardrail_on_output(
litellm_call_id=call_id,
),
json={
"messages": build_inspection_messages(request_data)
"messages": self._build_aim_inspection_messages(request_data)
+ [{"role": "assistant", "content": output}]
},
)
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.90.3"
version = "1.90.4"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.14"
Expand Down Expand Up @@ -272,7 +272,7 @@ source-exclude = [
profile = "black"

[tool.commitizen]
version = "1.90.3"
version = "1.90.4"
version_files = [
"pyproject.toml:^version",
]
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