mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-14 09:03:16 +00:00
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:
parent
a4de720144
commit
d46a22ec7d
2 changed files with 82 additions and 1 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue