diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 5b6c8f7780a..031f124bbcb 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -469,8 +469,6 @@ def _write_back_tool_use_block(block: object, shape: _ToolCallShape, rewritten_i def _redact_output_safeguard_results(response: object, unchanged_tool_use_ids: frozenset[str]) -> None: - """The response's safeguard verdicts judged the tool inputs as the model wrote them, so only those - for unchanged ``tool_use`` blocks survive, and no explanation can quote an input the guardrail rewrote.""" fields: Final = as_str_mapping(response) raw: Final = fields.get("safeguard_results") if fields is not None else None if raw is None or not isinstance(response, dict): @@ -1768,14 +1766,9 @@ class AnthropicMessagesHandler(BaseTranslation): returned_tool_calls: Sequence[object] | None, guardrail_name: str | None, ) -> frozenset[str] | None: - """The nth returned call belongs to the nth ``tool_use`` block, the order the calls were scanned - in. A returned list of another length cannot be placed, so the scanned calls stand, as on the - request side. Rewritten arguments that are not a JSON object cannot become a ``tool_use`` input, - so the response fails instead of carrying the arguments the guardrail meant to replace. Returns the - ids of the ``tool_use`` blocks left unchanged when any block was rewritten, else None.""" post_guardrail_tool_calls: Final = _tool_call_shapes( returned_tool_calls - if returned_tool_calls is not None and len(returned_tool_calls) == len(pre_guardrail_tool_calls) + if isinstance(returned_tool_calls, list) and len(returned_tool_calls) == len(pre_guardrail_tool_calls) else scanned_tool_calls ) tool_use_blocks: Final = tuple( diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py index 2e1b366cef8..f378205f0ba 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/generic_guardrail_api.py @@ -196,9 +196,6 @@ class GuardrailToolCallFunction(TypedDict): @with_config(ConfigDict(extra="allow")) class GuardrailToolCall(TypedDict): - """A rewritten tool call, one per call the guardrail received. The call's own ``id`` and - ``type`` are kept by the proxy, so ``function`` is all a guardrail has to send back.""" - function: ReadOnly[GuardrailToolCallFunction] @@ -207,9 +204,7 @@ _SENT_FUNCTION: Final = TypeAdapter(Mapping[str, object]) _RESPONSE_FIELDS: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(Mapping[str, object]) -def tool_calls_from_response(value: object) -> tuple[GuardrailToolCall, ...] | None: - """A malformed ``tool_calls`` raises, so the guardrail's ``fail_on_error`` setting decides the - request's fate instead of the unrewritten calls going through.""" +def _tool_calls_from_response(value: object) -> tuple[GuardrailToolCall, ...] | None: return None if value is None else _TOOL_CALLS.validate_python(value) @@ -223,12 +218,9 @@ def _with_returned_function(sent_call: Mapping[str, object], returned_call: Guar def tool_calls_to_write_back( - sent_calls: Sequence[Mapping[str, object]] | None, returned_calls: Sequence[GuardrailToolCall] | None + sent_calls: Sequence[Mapping[str, object]] | None, returned: object ) -> tuple[ChatCompletionToolCallChunk, ...] | None: - """The calls as the guardrail received them, carrying the ``function`` it handed back, so every - endpoint keeps each call's own ``id`` and ``type``. The guardrail answers one call per call received, - in the same order, and an empty list is no rewrite. Any other length cannot be matched to the calls - by position, which is a guardrail error rather than a reason to send the calls unrewritten.""" + returned_calls: Final = _tool_calls_from_response(returned) if not returned_calls: return None sent_count: Final = 0 if sent_calls is None else len(sent_calls) @@ -246,7 +238,7 @@ class GenericGuardrailAPIResponse: texts: list[str] | None images: list[str] | None tools: list[GuardrailToolParam] | None - tool_calls: Sequence[GuardrailToolCall] | None + tool_calls: object structured_messages: Sequence[AllMessageValues] | None action: str blocked_reason: str | None @@ -261,7 +253,7 @@ class GenericGuardrailAPIResponse: tools: list[GuardrailToolParam] | None = None, stream_holdback_chars: list[int] | None = None, structured_messages: Sequence[AllMessageValues] | None = None, - tool_calls: Sequence[GuardrailToolCall] | None = None, + tool_calls: object = None, ) -> None: self.action = action self.blocked_reason = blocked_reason @@ -290,5 +282,5 @@ class GenericGuardrailAPIResponse: tools=data.get("tools"), stream_holdback_chars=stream_holdback_chars, structured_messages=structured_messages_from_response(data.get("structured_messages")), - tool_calls=tool_calls_from_response(fields.get("tool_calls")), + tool_calls=fields.get("tool_calls"), ) diff --git a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py index 7961ad4c7c9..fde95f66278 100644 --- a/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py +++ b/tests/unit/llms/anthropic/chat/guardrail_translation/test_anthropic_guardrail_handler.py @@ -277,42 +277,12 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing: @staticmethod def _ended_sse_chunks() -> list: events = [ - ( - "message_start", - { - "type": "message_start", - "message": { - "id": "msg_1", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-5", - "content": [], - "stop_reason": None, - "usage": {"input_tokens": 1, "output_tokens": 0}, - }, - }, - ), - ( - "content_block_start", - {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, - ), - ( - "content_block_delta", - {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello "}}, - ), - ( - "content_block_delta", - {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "world"}}, - ), + ("message_start", {"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}), + ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "hello "}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "world"}}), ("content_block_stop", {"type": "content_block_stop", "index": 0}), - ( - "message_delta", - { - "type": "message_delta", - "delta": {"stop_reason": "end_turn", "stop_sequence": None}, - "usage": {"output_tokens": 2}, - }, - ), + ("message_delta", {"type": "message_delta", "delta": {"stop_reason": "end_turn", "stop_sequence": None}, "usage": {"output_tokens": 2}}), ("message_stop", {"type": "message_stop"}), ] return [f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() for name, payload in events] @@ -409,58 +379,13 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing: @staticmethod def _ended_tool_use_sse_chunks() -> list: events = [ - ( - "message_start", - { - "type": "message_start", - "message": { - "id": "msg_1", - "type": "message", - "role": "assistant", - "model": "claude-sonnet-4-5", - "content": [], - "stop_reason": None, - "usage": {"input_tokens": 1, "output_tokens": 0}, - }, - }, - ), - ( - "content_block_start", - { - "type": "content_block_start", - "index": 0, - "content_block": {"type": "tool_use", "id": "toolu_1", "name": "lookup_fruit", "input": {}}, - }, - ), - ( - "content_block_delta", - {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}, - ), - ( - "content_block_delta", - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "input_json_delta", "partial_json": '{"fruit": "persim'}, - }, - ), - ( - "content_block_delta", - { - "type": "content_block_delta", - "index": 0, - "delta": {"type": "input_json_delta", "partial_json": 'mon"}'}, - }, - ), + ("message_start", {"type": "message_start", "message": {"id": "msg_1", "type": "message", "role": "assistant", "model": "claude-sonnet-4-5", "content": [], "stop_reason": None, "usage": {"input_tokens": 1, "output_tokens": 0}}}), + ("content_block_start", {"type": "content_block_start", "index": 0, "content_block": {"type": "tool_use", "id": "toolu_1", "name": "lookup_fruit", "input": {}}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": ""}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": '{"fruit": "persim'}}), + ("content_block_delta", {"type": "content_block_delta", "index": 0, "delta": {"type": "input_json_delta", "partial_json": 'mon"}'}}), ("content_block_stop", {"type": "content_block_stop", "index": 0}), - ( - "message_delta", - { - "type": "message_delta", - "delta": {"stop_reason": "tool_use", "stop_sequence": None}, - "usage": {"output_tokens": 2}, - }, - ), + ("message_delta", {"type": "message_delta", "delta": {"stop_reason": "tool_use", "stop_sequence": None}, "usage": {"output_tokens": 2}}), ("message_stop", {"type": "message_stop"}), ] return [f"event: {name}\ndata: {json.dumps(payload)}\n\n".encode() for name, payload in events] @@ -481,8 +406,7 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing: json.loads(line[len("data:") :].strip())["delta"]["partial_json"] for chunk in chunks for line in chunk.decode().split("\n") - if line.startswith("data:") - and json.loads(line[len("data:") :].strip()).get("type") == "content_block_delta" + if line.startswith("data:") and json.loads(line[len("data:") :].strip()).get("type") == "content_block_delta" ] @pytest.mark.asyncio @@ -575,10 +499,7 @@ class TestAnthropicMessagesHandlerStreamingOutputProcessing: logging_obj: Optional[LiteLLMLoggingObj] = None, ) -> GenericGuardrailAPIInputs: for tool_call in inputs.get("tool_calls", []): - if ( - isinstance(tool_call, ChatCompletionMessageToolCall) - and "persimmon" in tool_call.function.arguments - ): + if isinstance(tool_call, ChatCompletionMessageToolCall) and "persimmon" in tool_call.function.arguments: tool_call.function.arguments = '{"fruit": "[MASKED]"}' return inputs @@ -2684,7 +2605,9 @@ class TestAnthropicMessagesScanOnlyToolResults: {"role": "user", "content": "weather in Paris?"}, { "role": "assistant", - "content": [{"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {"city": "Paris"}}], + "content": [ + {"type": "tool_use", "id": "tu1", "name": "get_weather", "input": {"city": "Paris"}} + ], }, { "role": "user", @@ -3029,7 +2952,9 @@ class TestStructuredWriteBackKeepsToolResults: ], ids=["tool_reference", "empty"], ) - async def test_tool_result_stays_right_after_its_tool_use(self, tool_result_content, expected_written_back_content): + async def test_tool_result_stays_right_after_its_tool_use( + self, tool_result_content, expected_written_back_content + ): handler = AnthropicMessagesHandler() data = {"model": "claude-fable-5", "messages": self._claude_code_tool_search_turns(tool_result_content)} @@ -3699,6 +3624,18 @@ class TestOutputToolCallRewrites: ) assert "SECRET-123" not in json.dumps(response) + @pytest.mark.asyncio + async def test_tool_calls_handed_back_as_a_mapping_leave_the_response_as_scanned(self) -> None: + response: Final = _dict_tool_use_response() + + await AnthropicMessagesHandler().process_output_response( + response=cast(Any, response), + guardrail_to_apply=_tool_call_returning_guardrail(lambda tool_calls: {"verdict": "ok"}), + litellm_logging_obj=MagicMock(), + ) + + assert _tool_use_block(response)["input"] == {"cmd": "echo SECRET-123"} + @pytest.mark.asyncio async def test_rewritten_arguments_that_are_not_a_json_object_fail_the_response(self) -> None: response: Final = _dict_tool_use_response() diff --git a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py index 995a98612c6..23d03c71cb7 100644 --- a/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py +++ b/tests/unit/proxy/guardrails/guardrail_hooks/test_generic_guardrail_api.py @@ -2790,6 +2790,24 @@ class TestToolCallsInResponse: with pytest.raises(Exception, match="Generic Guardrail API failed"): await _tool_calls_after(guardrail) + @pytest.mark.asyncio + @pytest.mark.parametrize("fail_on_error", [True, False]) + @pytest.mark.parametrize( + "tool_calls", + ["not a list", [{"function": {"name": "run_shell", "arguments": {"cmd": "echo [REDACTED]"}}}]], + ids=["not_a_list", "object_arguments"], + ) + async def test_a_blocked_answer_blocks_even_when_its_tool_calls_are_malformed( + self, tool_calls: object, fail_on_error: bool + ) -> None: + guardrail: Final = _guardrail_answering( + {"action": "BLOCKED", "blocked_reason": "dangerous command", "tool_calls": tool_calls}, + fail_on_error=fail_on_error, + ) + + with pytest.raises(GuardrailRaisedException, match="dangerous command"): + await _tool_calls_after(guardrail) + @pytest.mark.asyncio async def test_fail_on_error_off_sends_the_calls_as_received_when_the_answer_cannot_be_applied(self) -> None: guardrail: Final = _guardrail_answering(