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 <noreply@anthropic.com>
This commit is contained in:
sharziki 2026-11-02 15:50:10 -05:00
parent a4de720144
commit d46a22ec7d
2 changed files with 82 additions and 1 deletions

View file

@ -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(

View file

@ -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