diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 1d31e33c77c..3bdb6a93ee8 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -25,6 +25,15 @@ from typing import Any, Callable, Dict, FrozenSet, Iterator, List # guardrails on those paths are a separate scope. TEXT_CONTENT_CALL_TYPES: FrozenSet[str] = frozenset({"completion", "acompletion", "aresponses"}) +# Roles that only make sense alongside tool-call metadata (``tool_call_id``, +# ``name``, …). ``build_inspection_messages`` flattens each message down to a +# plain ``{role, content}`` pair, dropping that metadata, so emitting these +# roles produces payloads that remote guardrail APIs reject (e.g. AIM returns +# 422 "Tool Call ID required on tool calls"). Their content is intermediate +# tool plumbing, not user input or model output, so it is excluded from +# guardrail inspection. +INSPECTION_EXCLUDED_ROLES: FrozenSet[str] = frozenset({"tool", "function"}) + def is_text_content_call_type(call_type: str) -> bool: """Return True if ``call_type`` carries free-form text that text @@ -207,7 +216,9 @@ def build_inspection_messages(data: Dict[str, Any]) -> List[Dict[str, str]]: 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. + into synthetic messages. Messages with no inspectable text are dropped, + as are ``tool``/``function`` role messages (see + :data:`INSPECTION_EXCLUDED_ROLES`). Hooks that POST ``{"messages": [...]}`` to an external service should call this instead of ``data.get("messages", [])`` so the Responses API @@ -217,9 +228,11 @@ def build_inspection_messages(data: Dict[str, Any]) -> List[Dict[str, str]]: for message in _iter_inspection_messages(data): if not isinstance(message, dict): continue + role = message.get("role", "user") or "user" + if role in INSPECTION_EXCLUDED_ROLES: + 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 diff --git a/tests/test_litellm/proxy/guardrails/test_content_utils.py b/tests/test_litellm/proxy/guardrails/test_content_utils.py index 099fca78a62..e9c733a73ae 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_utils.py +++ b/tests/test_litellm/proxy/guardrails/test_content_utils.py @@ -239,6 +239,41 @@ def test_build_inspection_messages_empty_data(): assert build_inspection_messages({"input": ""}) == [] +def test_build_inspection_messages_drops_tool_and_function_roles(): + """AIM (and other remote guardrail APIs) validate against an + OpenAI-compatible schema that requires ``tool_call_id`` on ``tool`` role + messages. ``build_inspection_messages`` flattens messages to plain + ``{role, content}`` and discards that metadata, so passing tool/function + role messages through produced a 422 "Tool Call ID required on tool calls" + and made AIM non-functional for any conversation with tool calls. They + must be filtered out entirely.""" + data = { + "messages": [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "show me milk"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search_products", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "milk $3.99"}, + {"role": "function", "name": "search_products", "content": "eggs $2.50"}, + {"role": "assistant", "content": "Here is the milk you asked for"}, + ] + } + assert build_inspection_messages(data) == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "show me milk"}, + {"role": "assistant", "content": "Here is the milk you asked for"}, + ] + + # ── has_non_string_content ────────────────────────────────────────────────────