From d46a22ec7d0aa4dbf5b0b5dfb072de10e8bd5376 Mon Sep 17 00:00:00 2001 From: sharziki Date: Mon, 2 Nov 2026 15:50:10 -0500 Subject: [PATCH] fix(guardrails): keep Anthropic-only tool fields through the guardrail round trip The /v1/messages guardrail translation converts request tools to OpenAI format for the guardrail and rebuilds them with map_tool_helper on the way back. That rebuild only knows name/description/schema/cache_control, so defer_loading, strict, input_examples and allowed_callers were silently dropped whenever a guardrail returned tools, breaking tool search behind a default-on guardrail. Restore each returned tool's missing fields from the request tool of the same name; fields the guardrail returned still win. Fixes #49253 Co-Authored-By: Claude Opus 5.5 --- .../chat/guardrail_translation/handler.py | 27 ++++++++- .../test_anthropic_guardrail_handler.py | 56 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 7f688ae733b..93e296fe0dc 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -382,6 +382,28 @@ def _restore_tool_use_names(messages: Sequence[object], original_names: Mapping[ block["name"] = original_names[name] +def _with_original_tool_fields( + tool: AllAnthropicToolsValues, request_tools: Sequence[object] +) -> AllAnthropicToolsValues: + """Restore Anthropic-only fields (defer_loading, strict, input_examples, allowed_callers, ...) + that the OpenAI round trip drops, from the request tool of the same name. Fields the + guardrail returned still win.""" + name: Final = anthropic_tool_name(tool) + original: Final = next( + ( + fields + for request_tool in request_tools + if (fields := as_str_mapping(request_tool)) is not None + and "function" not in fields + and fields.get("name") == name + ), + None, + ) + if name is None or original is None: + return tool + return cast(AllAnthropicToolsValues, {**original, **tool}) # cast-ok: superset of an Anthropic tool + + def _with_original_name(tool: ChatCompletionToolParam, original_names: Mapping[str, str]) -> ChatCompletionToolParam: if "function" not in tool: return tool @@ -904,6 +926,9 @@ class AnthropicMessagesHandler(BaseTranslation): original_tool_names: Final = _original_tool_names( cast(Mapping[str, object], data) # cast-ok: Anthropic request body ) + original_request_tools: Final = tuple( + list_field(cast(Mapping[str, object], data), "tools") # cast-ok: Anthropic request body + ) guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( inputs=inputs, request_data=data, @@ -922,7 +947,7 @@ class AnthropicMessagesHandler(BaseTranslation): _with_original_name(tool, original_tool_names.declared) ) if converted_tool is not None: - anthropic_tools.append(converted_tool) + anthropic_tools.append(_with_original_tool_fields(converted_tool, original_request_tools)) # Note: MCP servers are handled separately in the main transformation data["tools"] = ( merge_returned_tools_into_request_tools( 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 c2ee58b67ed..696813ca985 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 @@ -2026,6 +2026,62 @@ class TestAnthropicMessagesHandlerToolInjection: assert [t.get("name") for t in result["tools"]] == ["injected_tool"] + @staticmethod + def _deferred_tool() -> dict[str, Any]: + return { + "name": "get_weather", + "description": "Get the weather at a specific location", + "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}}, + "defer_loading": True, + "strict": True, + "cache_control": {"type": "ephemeral"}, + "input_examples": [{"city": "Paris"}], + "allowed_callers": ["code_execution_20250825"], + } + + @pytest.mark.asyncio + async def test_anthropic_only_tool_fields_survive_a_passthrough_guardrail(self): + """#49253: the OpenAI round trip dropped defer_loading, strict, input_examples + and allowed_callers, which broke tool search behind a default-on guardrail.""" + data = { + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": "hi"}], + "tools": [ + {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"}, + self._deferred_tool(), + ], + } + + await AnthropicMessagesHandler().process_input_messages( + data=data, guardrail_to_apply=MockPassThroughGuardrail(guardrail_name="passthru") + ) + + assert data["tools"][0] == {"type": "tool_search_tool_regex_20251119", "name": "tool_search_tool_regex"} + assert {k: v for k, v in data["tools"][1].items() if k != "type"} == self._deferred_tool() + + @pytest.mark.asyncio + async def test_guardrail_edits_to_a_tool_win_over_the_original_fields(self): + class DescriptionRewritingGuardrail(CustomGuardrail): + async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + inputs["tools"] = [ + {**tool, "function": {**tool["function"], "description": "[redacted]"}} + for tool in inputs.get("tools") or [] + ] + return inputs + + data = { + "model": "claude-sonnet-4-5", + "messages": [{"role": "user", "content": "hi"}], + "tools": [self._deferred_tool()], + } + + await AnthropicMessagesHandler().process_input_messages( + data=data, guardrail_to_apply=DescriptionRewritingGuardrail(guardrail_name="rewrite") + ) + + assert data["tools"][0]["description"] == "[redacted]" + assert data["tools"][0]["defer_loading"] is True + if __name__ == "__main__": # Run the tests