diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 4b6617fbeac..2ea4816f3b7 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -768,7 +768,7 @@ def translate_anthropic_tools_to_openai( name=truncated_name, ) if "input_schema" in tool: - function_chunk["parameters"] = tool["input_schema"] # type: ignore + function_chunk["parameters"] = copy.deepcopy(tool["input_schema"]) # type: ignore if "description" in tool: function_chunk["description"] = tool["description"] # type: ignore diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index dfe7e0c3a51..0a2c4bccde7 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1,3 +1,4 @@ +import copy import os import sys from typing import Any, cast @@ -1807,6 +1808,38 @@ def test_translate_anthropic_tools_to_openai_fills_missing_tool_name(): assert result[1]["function"]["name"] == "litellm_unnamed_tool_1" +def test_translate_anthropic_tools_to_openai_does_not_mutate_caller_input_schema(): + """Translation must be a pure read: the caller's ``input_schema`` must not be mutated. + + Regression test for the tool-translation path aliasing the caller's ``input_schema`` + dict and then merging extra top-level tool kwargs (e.g. a ``computer`` tool's + ``display_width_px``) into it in place. Because callers reuse the same in-memory tool + list (a pre-call guardrail translates the tools, then the real call translates them + again), the second pass forwarded a schema polluted with non-schema keys. + """ + tool = { + "name": "computer", + "type": "computer_20241022", + "input_schema": {"type": "object", "properties": {"action": {"type": "string"}}}, + "display_width_px": 1024, + "display_height_px": 768, + } + input_schema_before = copy.deepcopy(tool["input_schema"]) + + adapter = LiteLLMAnthropicMessagesAdapter() + result, _ = adapter.translate_anthropic_tools_to_openai(tools=[tool], model="claude-3-5-sonnet") + + assert tool["input_schema"] == input_schema_before + assert "display_width_px" not in tool["input_schema"] + assert "display_height_px" not in tool["input_schema"] + + translated_parameters = result[0]["function"]["parameters"] + assert translated_parameters["display_width_px"] == 1024 + assert translated_parameters["display_height_px"] == 768 + assert translated_parameters["properties"] == {"action": {"type": "string"}} + assert translated_parameters is not tool["input_schema"] + + def test_translate_openai_content_to_anthropic_reasoning_content_without_thinking_blocks(): """ Test that reasoning_content is converted to thinking block when thinking_blocks is not present.