From 1e08938b14cd2fdea9de33738b67bf21afa46e5e Mon Sep 17 00:00:00 2001 From: Scott Jacobsen Date: Mon, 27 Jul 2026 21:25:15 -0500 Subject: [PATCH] fix(guardrails): inspect Anthropic /v1/messages native fields for Akamai FAI --- .../akamai_firewall_for_ai.py | 55 +++++++++++++++--- .../test_akamai_firewall_for_ai.py | 58 +++++++++++++++++++ 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/akamai_firewall_for_ai/akamai_firewall_for_ai.py b/litellm/proxy/guardrails/guardrail_hooks/akamai_firewall_for_ai/akamai_firewall_for_ai.py index 44743bc556c..d8001fd6807 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/akamai_firewall_for_ai/akamai_firewall_for_ai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/akamai_firewall_for_ai/akamai_firewall_for_ai.py @@ -14,6 +14,7 @@ from typing import ( AsyncGenerator, Iterator, TypedDict, + cast, ) from fastapi import HTTPException @@ -41,11 +42,13 @@ from litellm.types.utils import ( ) if TYPE_CHECKING: + from litellm.types.llms.anthropic import AnthropicMessagesRequest from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel DEFAULT_API_BASE = "https://aisec.akamai.com" BLOCKING_ACTIONS = frozenset({"deny", "block"}) +ANTHROPIC_MESSAGES_CALL_TYPES = frozenset({"anthropic_messages", "aanthropic_messages"}) def _item_get(item: Any, key: str) -> Any: @@ -132,6 +135,37 @@ def _iter_request_tool_definition_text(data: dict) -> Iterator[str]: yield json.dumps(parameters, sort_keys=True) +def _translate_anthropic_to_openai_request(data: dict) -> dict: + """Translate an Anthropic ``/v1/messages`` request into Chat-Completions shape. + + Hook-based guardrails receive the provider-native body, so the top-level + ``system`` prompt, ``tool_use`` / ``tool_result`` content blocks and tool + ``input_schema`` never match the OpenAI-shaped iterators. Reusing the shared + Anthropic adapter lifts ``system`` into a system message, ``tool_use`` / + ``tool_result`` into ``tool_calls`` / tool messages and ``input_schema`` into + ``tools[].function.parameters`` so the standard extraction inspects them all. + On a translation failure the raw body is returned so text content is still + inspected rather than the whole request being dropped. + """ + from litellm.llms.anthropic.experimental_pass_through.adapters.transformation import ( + LiteLLMAnthropicMessagesAdapter, + ) + + try: + body = cast("AnthropicMessagesRequest", data.copy()) # cast-ok: dict passed to adapter TypedDict param + openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + anthropic_message_request=body + ) + except Exception as exc: + verbose_proxy_logger.warning( + "Akamai Firewall for AI: could not translate Anthropic /v1/messages request for inspection; " + "falling back to raw extraction: %s", + exc, + ) + return data + return dict(openai_request) + + def _iter_responses_api_output_text(response: ResponsesAPIResponse) -> Iterator[str]: """Yield text and function-call arguments from a Responses API result. @@ -222,12 +256,13 @@ class AkamaiFirewallForAIGuardrail(CustomGuardrail): return f"{self.api_base}/fai/v1/fai-configurations/{self.fai_configuration_id}/detect" @staticmethod - def _input_text(data: dict) -> str: + def _input_text(data: dict, call_type: str) -> str: + request = _translate_anthropic_to_openai_request(data) if call_type in ANTHROPIC_MESSAGES_CALL_TYPES else data fragments = chain( - iter_message_text(data), - _iter_request_tool_call_text(data), - _iter_request_tool_definition_text(data), - _iter_request_prompt_text(data), + iter_message_text(request), + _iter_request_tool_call_text(request), + _iter_request_tool_definition_text(request), + _iter_request_prompt_text(request), ) return "\n".join(fragment for fragment in fragments if fragment) @@ -321,7 +356,10 @@ class AkamaiFirewallForAIGuardrail(CustomGuardrail): ) -> Exception | str | dict | None: if self.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) is not True: return data - await self._detect(client_request_id=self._client_request_id(data), llm_input=self._input_text(data)) + await self._detect( + client_request_id=self._client_request_id(data), + llm_input=self._input_text(data, call_type), + ) return data @log_guardrail_information @@ -333,7 +371,10 @@ class AkamaiFirewallForAIGuardrail(CustomGuardrail): ) -> Exception | str | dict | None: if self.should_run_guardrail(data=data, event_type=GuardrailEventHooks.during_call) is not True: return data - await self._detect(client_request_id=self._client_request_id(data), llm_input=self._input_text(data)) + await self._detect( + client_request_id=self._client_request_id(data), + llm_input=self._input_text(data, call_type), + ) return data @log_guardrail_information diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_akamai_firewall_for_ai.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_akamai_firewall_for_ai.py index 3b49665f4a8..d15b4daf2fe 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_akamai_firewall_for_ai.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_akamai_firewall_for_ai.py @@ -596,6 +596,64 @@ async def test_input_hook_inspects_tool_definitions(): assert "responses-api-shaped tool" in llm_input +@pytest.mark.asyncio +@pytest.mark.parametrize("call_type", ["anthropic_messages", "aanthropic_messages"]) +async def test_input_hook_inspects_anthropic_messages_native_fields(call_type: str): + """Regression: /v1/messages reaches this hook as the native Anthropic body. + + Hook-based guardrails do not go through the unified translation layer, so + the native payload arrives with an Anthropic ``system`` prompt, ``tool_use`` + / ``tool_result`` content blocks and tool ``input_schema`` - none of which + the OpenAI-shaped iterators match. The guardrail must translate the request + via the shared adapter so all of those fields are sent to Akamai; before the + fix each payload below reached the model uninspected. + """ + guardrail = _init("pre_call") + data = { + "litellm_call_id": "req-1", + "guardrails": ["akamai-guard"], + "model": "claude-sonnet-4-6", + "max_tokens": 100, + "system": "SYSTEM_INJECTION_PAYLOAD", + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "benign question"}]}, + { + "role": "assistant", + "content": [ + {"type": "tool_use", "id": "t1", "name": "lookup", "input": {"q": "TOOL_USE_PAYLOAD"}} + ], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t1", "content": "TOOL_RESULT_PAYLOAD"}], + }, + ], + "tools": [ + { + "name": "lookup", + "description": "TOOL_DESCRIPTION_PAYLOAD", + "input_schema": { + "type": "object", + "properties": {"q": {"type": "string", "description": "INPUT_SCHEMA_PAYLOAD"}}, + }, + } + ], + } + with patch( + "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", + new=AsyncMock(return_value=_response(CLEAN_BODY)), + ) as mock_post: + await guardrail.async_pre_call_hook( + data=data, cache=DualCache(), user_api_key_dict=UserAPIKeyAuth(), call_type=call_type + ) + llm_input = mock_post.call_args.kwargs["json"]["llmInput"] + assert "SYSTEM_INJECTION_PAYLOAD" in llm_input + assert "TOOL_USE_PAYLOAD" in llm_input + assert "TOOL_RESULT_PAYLOAD" in llm_input + assert "INPUT_SCHEMA_PAYLOAD" in llm_input + assert "benign question" in llm_input + + @pytest.mark.asyncio async def test_input_hook_inspects_responses_input_function_call(): """Responses-API ``input`` function_call items must be inspected too."""