diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index aa2bee1f31d..9babc37c869 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -50,6 +50,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import ( effective_scan_only_tool_results_for_guardrail, effective_skip_system_message_for_guardrail, effective_skip_tool_message_for_guardrail, + extract_tool_from_addition_block, list_field, merge_guardrailed_scoped_messages, merge_returned_tools_into_request_tools, @@ -659,6 +660,11 @@ class AnthropicMessagesHandler(BaseTranslation): *anthropic_tools, ] ) + if not scan_only_tool_results: + retained_names: Final = frozenset( + name for t in (data.get("tools") or []) for name in anthropic_tool_names(t) + ) + self._strip_disallowed_tool_additions(data, retained_names) guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages") if ( @@ -932,8 +938,57 @@ class AnthropicMessagesHandler(BaseTranslation): def extract_request_tool_names(self, data: Mapping[str, object]) -> list[str]: """Extract every tool name in an Anthropic messages request: tools[].name, plus - tools[].function.name for OpenAI-format tools the bridge forwards verbatim.""" - return [name for tool in list_field(data, "tools") for name in anthropic_tool_names(tool)] + tools[].function.name for OpenAI-format tools the bridge forwards verbatim, + as well as mid-conversation tool additions in messages.""" + names = [name for tool in list_field(data, "tools") for name in anthropic_tool_names(tool)] + messages = data.get("messages") + if isinstance(messages, Sequence): + for m in messages: + if isinstance(m, Mapping): + content = m.get("content") + if isinstance(content, Sequence) and not isinstance(content, (str, bytes)): + for block in content: + if isinstance(block, Mapping) and block.get("type") == "tool_addition": + tool = extract_tool_from_addition_block(block) + if tool is not None: + for name in anthropic_tool_names(tool): + if name not in names: + names.append(name) + return names + + @classmethod + def _strip_disallowed_tool_additions(cls, data: dict, retained_names: frozenset[str]) -> None: + messages = data.get("messages") + if not isinstance(messages, list): + return + cleaned_messages: list[object] = [] + for m in messages: + if not isinstance(m, dict): + cleaned_messages.append(m) + continue + content = m.get("content") + if not isinstance(content, list): + cleaned_messages.append(m) + continue + had_disallowed_addition = False + remaining_blocks: list[object] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_addition": + tool = extract_tool_from_addition_block(block) + tool_names = anthropic_tool_names(tool) if tool else () + if tool_names and all(name in retained_names for name in tool_names): + remaining_blocks.append(block) + else: + had_disallowed_addition = True + else: + remaining_blocks.append(block) + if remaining_blocks: + new_m = dict(m) + new_m["content"] = remaining_blocks + cleaned_messages.append(new_m) + elif not had_disallowed_addition: + cleaned_messages.append(m) + data["messages"] = cleaned_messages @classmethod def _extract_input_text_and_images( diff --git a/litellm/llms/anthropic/pass_through/adapters/transformation.py b/litellm/llms/anthropic/pass_through/adapters/transformation.py index 44e4476aeb5..8db9e9155b6 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -16,6 +16,7 @@ from litellm.llms.anthropic.pass_through.utils import ( json_copy_or_original, prompt_cache_key_from_user_id, ) +from litellm.llms.base_llm.guardrail_translation.utils import extract_tool_from_addition_block from litellm.responses.utils import auto_reasoning_summary # OpenAI has a 64-character limit for function/tool names @@ -1268,23 +1269,7 @@ class LiteLLMAnthropicMessagesAdapter: btype = block.get("type") if btype == "tool_addition": had_tool_changes = True - tool_data = block.get("tool") - if isinstance(tool_data, dict): - if ( - tool_data.get("type") == "tool_definition" - and "definition" in tool_data - and isinstance(tool_data["definition"], dict) - ): - raw_tool = tool_data["definition"] - else: - raw_tool = tool_data - elif "definition" in block and isinstance(block["definition"], dict): - raw_tool = block["definition"] - elif isinstance(block.get("tool_definition"), dict): - raw_tool = block["tool_definition"] - else: - raw_tool = block - + raw_tool = extract_tool_from_addition_block(block) if isinstance(raw_tool, dict) and raw_tool.get("name"): tool_name = str(raw_tool["name"]) if not any(isinstance(t, dict) and t.get("name") == tool_name for t in active_tools): @@ -1300,8 +1285,7 @@ class LiteLLMAnthropicMessagesAdapter: if rem_name: rem_name_str = str(rem_name) active_tools = [ - t for t in active_tools - if not (isinstance(t, dict) and t.get("name") == rem_name_str) + t for t in active_tools if not (isinstance(t, dict) and t.get("name") == rem_name_str) ] else: remaining_blocks.append(block) @@ -1316,7 +1300,9 @@ class LiteLLMAnthropicMessagesAdapter: if not had_tool_changes and initial_tools is None: return None, cleaned_messages - return active_tools if active_tools else ([] if initial_tools is not None or had_tool_changes else None), cleaned_messages + return active_tools if active_tools else ( + [] if initial_tools is not None or had_tool_changes else None + ), cleaned_messages def translate_anthropic_to_openai( self, diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 55a4b3da0b3..53e89ff7fb4 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -2,7 +2,7 @@ from __future__ import annotations import json from collections.abc import Callable, Iterator, Mapping, Sequence -from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row has no typed constructor across roles +from typing import Any, Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row has no typed constructor across roles from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError @@ -319,6 +319,26 @@ def anthropic_tool_name(tool: object) -> str | None: return names[0] if names else None +def extract_tool_from_addition_block(block: Mapping[str, Any] | Any) -> dict[str, Any] | None: + """Extract raw tool dict from an Anthropic tool_addition block.""" + if not isinstance(block, Mapping): + return None + tool_data = block.get("tool") + if isinstance(tool_data, dict): + if ( + tool_data.get("type") == "tool_definition" + and "definition" in tool_data + and isinstance(tool_data["definition"], dict) + ): + return tool_data["definition"] + return tool_data + if "definition" in block and isinstance(block["definition"], dict): + return block["definition"] + if isinstance(block.get("tool_definition"), dict): + return block["tool_definition"] + return None + + def merge_returned_tools_into_request_tools( request_tools: Sequence[ToolT] | None, returned_tools: Sequence[ToolT], diff --git a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 7d800cbce1c..00f8358cf3a 100644 --- a/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/unit/llms/anthropic/pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -12,7 +12,6 @@ import respx import litellm - from litellm.litellm_core_utils.prompt_templates.common_utils import ( TOOL_RESULT_IMAGE_PLACEHOLDER, encrypted_reasoning_signature, @@ -66,10 +65,17 @@ def test_translate_openai_response_to_anthropic_empty_choices() -> None: assert result["usage"]["input_tokens"] == 10 -@pytest.mark.parametrize("text,count,expected_stop", [ - ("", 1, "compaction"), (None, 1, "compaction"), ("Answer", 1, "max_tokens"), - (" ", 1, "max_tokens"), ("", 2, "max_tokens"), ("", 0, "max_tokens"), -]) +@pytest.mark.parametrize( + "text,count,expected_stop", + [ + ("", 1, "compaction"), + (None, 1, "compaction"), + ("Answer", 1, "max_tokens"), + (" ", 1, "max_tokens"), + ("", 2, "max_tokens"), + ("", 0, "max_tokens"), + ], +) def test_native_compaction_response_roundtrip(text: str | None, count: int, expected_stop: str) -> None: block: Final = {"type": "compaction", "content": "Exact summary", "signature": "opaque-signature"} message: Final = Message(content=text, provider_specific_fields={"compaction_blocks": [block] * count}) @@ -178,9 +184,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_content_block(): tool_calls=[ ChatCompletionDeltaToolCall( id="call_d581d130-e234-4315-94e8-27e7ff7c4e55", - function=Function( - arguments='{"location": "Boston"}', name="get_weather" - ), + function=Function(arguments='{"location": "Boston"}', name="get_weather"), type="function", index=0, ) @@ -224,9 +228,7 @@ def test_translate_streaming_openai_chunk_strips_gemini_thought_from_tool_call_i tool_calls=[ ChatCompletionDeltaToolCall( id=combined, - function=Function( - arguments='{"a": 17, "b": 25}', name="add_numbers" - ), + function=Function(arguments='{"a": 17, "b": 25}', name="add_numbers"), type="function", index=0, ) @@ -463,10 +465,7 @@ def test_translate_anthropic_messages_to_openai_thinking_blocks(): assert "thinking_blocks" in result[1] assert len(result[1]["thinking_blocks"]) == 2 assert result[1]["thinking_blocks"][0]["type"] == "thinking" - assert ( - result[1]["thinking_blocks"][0]["thinking"] - == "I will call the get_weather tool." - ) + assert result[1]["thinking_blocks"][0]["thinking"] == "I will call the get_weather tool." assert result[1]["thinking_blocks"][0]["signature"] == "sigsig" assert result[1]["thinking_blocks"][1]["type"] == "redacted_thinking" assert result[1]["thinking_blocks"][1]["data"] == "REDACTED" @@ -606,9 +605,7 @@ def test_translate_anthropic_messages_to_openai_tool_message_placement(): assert tool_message_idx is not None, "Tool message not found" assert user_message_idx is not None, "User message not found" - assert ( - tool_message_idx < user_message_idx - ), "Tool message should be placed before user message" + assert tool_message_idx < user_message_idx, "Tool message should be placed before user message" @pytest.mark.parametrize( @@ -1074,9 +1071,7 @@ def test_translate_anthropic_to_openai_skips_prompt_cache_key_when_provider_lack def test_translate_anthropic_to_openai_skips_prompt_cache_key_for_chained_litellm_proxy(): - assert "prompt_cache_key" in litellm.get_supported_openai_params( - model="xai", custom_llm_provider="litellm_proxy" - ) + assert "prompt_cache_key" in litellm.get_supported_openai_params(model="xai", custom_llm_provider="litellm_proxy") openai_request = _translate_with_metadata("litellm_proxy/xai", {"user_id": CLAUDE_CODE_USER_ID}, "litellm_proxy") assert openai_request["user"] == CLAUDE_CODE_USER_ID assert "prompt_cache_key" not in openai_request @@ -1121,7 +1116,8 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): id="call_empty_args", type="function", function=Function( - name="test_function", arguments="" # empty arguments string + name="test_function", + arguments="", # empty arguments string ), ) ], @@ -1136,9 +1132,7 @@ def test_translate_openai_content_to_anthropic_empty_function_arguments(): assert result[0]["type"] == "tool_use" assert result[0]["id"] == "call_empty_args" assert result[0]["name"] == "test_function" - assert ( - result[0]["input"] == {} - ), "Empty function arguments should result in empty dict" + assert result[0]["input"] == {}, "Empty function arguments should result in empty dict" assert "provider_specific_fields" not in result[0] @@ -1262,9 +1256,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls(): ChatCompletionAssistantToolCall( id="call_tool_combo", type="function", - function=Function( - name="get_weather", arguments='{"location": "Paris"}' - ), + function=Function(name="get_weather", arguments='{"location": "Paris"}'), ) ], ), @@ -1274,9 +1266,7 @@ def test_translate_openai_response_to_anthropic_text_and_tool_calls(): ) adapter = LiteLLMAnthropicMessagesAdapter() - anthropic_response = adapter.translate_openai_response_to_anthropic( - response=openai_response - ) + anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response) anthropic_content = anthropic_response.get("content") assert anthropic_content is not None @@ -1317,9 +1307,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_partial_json(): ( type_of_content, content_block_delta, - ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic( - choices=choices - ) + ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices) print("Type of content:", type_of_content) print("Content block delta:", content_block_delta) @@ -1428,9 +1416,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_thinking_delta(): ( type_of_content, content_block_delta, - ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic( - choices=choices - ) + ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices) assert type_of_content == "thinking_delta" assert content_block_delta["type"] == "thinking_delta" @@ -1473,9 +1459,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_with_thinking(): ( type_of_content, content_block_delta, - ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic( - choices=choices - ) + ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices) assert type_of_content == "signature_delta" assert content_block_delta["type"] == "signature_delta" @@ -1579,9 +1563,7 @@ def test_translate_anthropic_messages_to_openai_user_message_with_base64_image() # Check image content assert result[0]["content"][1]["type"] == "image_url" assert "image_url" in result[0]["content"][1] - assert result[0]["content"][1]["image_url"]["url"].startswith( - "data:image/png;base64," - ) + assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,") assert ( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" in result[0]["content"][1]["image_url"]["url"] @@ -1619,18 +1601,14 @@ def test_translate_anthropic_messages_to_openai_user_message_with_url_image(): # Check image content assert result[0]["content"][1]["type"] == "image_url" assert "image_url" in result[0]["content"][1] - assert ( - result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg" - ) + assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/forest.jpg" def test_translate_anthropic_messages_to_openai_tool_result_with_base64_image(): """Test that base64 images in tool results are correctly translated to OpenAI format.""" anthropic_messages = [ - AnthropicMessagesUserMessageParam( - role="user", content=[{"type": "text", "text": "Take a screenshot"}] - ), + AnthropicMessagesUserMessageParam(role="user", content=[{"type": "text", "text": "Take a screenshot"}]), AnthopicMessagesAssistantMessageParam( role="assistant", content=[ @@ -1782,9 +1760,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): # Check first image (base64) assert result[0]["content"][1]["type"] == "image_url" - assert result[0]["content"][1]["image_url"]["url"].startswith( - "data:image/png;base64," - ) + assert result[0]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,") # Check middle text assert result[0]["content"][2]["type"] == "text" @@ -1792,9 +1768,7 @@ def test_translate_anthropic_messages_to_openai_mixed_content_with_image(): # Check second image (URL) assert result[0]["content"][3]["type"] == "image_url" - assert ( - result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg" - ) + assert result[0]["content"][3]["image_url"]["url"] == "https://example.com/image2.jpg" # Check final text assert result[0]["content"][4]["type"] == "text" @@ -1840,10 +1814,7 @@ def test_translate_anthropic_messages_to_openai_tool_use_with_signature(): assert tool_call["id"] == "call_386f67af31f9415781bc35071405" assert "function" in tool_call assert "provider_specific_fields" in tool_call["function"] - assert ( - tool_call["function"]["provider_specific_fields"]["thought_signature"] - == test_signature - ) + assert tool_call["function"]["provider_specific_fields"]["thought_signature"] == test_signature def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_content_items(): @@ -1901,9 +1872,7 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) # Count how many tool messages have the same tool_call_id - tool_messages = [ - msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool" - ] + tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"] tool_call_ids = [msg.get("tool_call_id") for msg in tool_messages] # The critical assertion: each tool_call_id should appear only ONCE @@ -1919,12 +1888,8 @@ def test_translate_anthropic_messages_to_openai_tool_result_with_multiple_conten # The content should be a list with all items combined tool_message = tool_messages[0] assert tool_message["tool_call_id"] == "toolu_016hYHBkTf4JDF3p22UoYk5C" - assert isinstance( - tool_message["content"], list - ), "Multiple content items should be combined into a list" - assert ( - len(tool_message["content"]) == 3 - ), f"Expected 3 content items, got {len(tool_message['content'])}" + assert isinstance(tool_message["content"], list), "Multiple content items should be combined into a list" + assert len(tool_message["content"]) == 3, f"Expected 3 content items, got {len(tool_message['content'])}" # Verify content types assert tool_message["content"][0]["type"] == "text" @@ -1973,17 +1938,14 @@ def test_translate_anthropic_messages_to_openai_tool_result_single_item_backward adapter = LiteLLMAnthropicMessagesAdapter() result = adapter.translate_anthropic_messages_to_openai(messages=anthropic_messages) - tool_messages = [ - msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool" - ] + tool_messages = [msg for msg in result if isinstance(msg, dict) and msg.get("role") == "tool"] assert len(tool_messages) == 1 tool_message = tool_messages[0] # Single item should be a string for backward compatibility assert isinstance(tool_message["content"], str), ( - f"Single content item should be a string for backward compatibility, " - f"got {type(tool_message['content'])}" + f"Single content item should be a string for backward compatibility, got {type(tool_message['content'])}" ) assert tool_message["content"] == "72°F and sunny" @@ -2087,15 +2049,12 @@ def test_streaming_chunk_with_text_and_empty_tool_calls_returns_text_delta(): # ============================================================================ # Model constant for cache control tests -CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = ( - "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0" -) +CACHE_CONTROL_BEDROCK_CONVERSE_MODEL = "bedrock/converse/global.anthropic.claude-opus-4-5-20251101-v1:0" CACHE_CONTROL_NON_ANTHROPIC_MODEL = "gpt-4" # Bedrock Application Inference Profile ARN: the string contains neither # "anthropic" nor "claude", so the model can only be recognized via its ARN shape CACHE_CONTROL_BEDROCK_ARN_MODEL = ( - "bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:" - "application-inference-profile/abcdef123456" + "bedrock/converse/arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/abcdef123456" ) @@ -2111,9 +2070,7 @@ def test_should_add_cache_control_for_anthropic_model(): "vertex_ai/claude-3-sonnet@20240229", ]: target = {} - adapter._add_cache_control_if_applicable( - {"cache_control": cache_control}, target, model - ) + adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) assert "cache_control" in target assert target["cache_control"] == cache_control @@ -2128,9 +2085,7 @@ def test_should_not_add_cache_control_for_non_anthropic_model(): "openai/gpt-4-turbo", ]: target = {} - adapter._add_cache_control_if_applicable( - {"cache_control": cache_control}, target, model - ) + adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) assert "cache_control" not in target @@ -2145,9 +2100,7 @@ def test_should_add_cache_control_for_gemini_model(): "vertex_ai/gemini-2.5-pro", ]: target = {} - adapter._add_cache_control_if_applicable( - {"cache_control": cache_control}, target, model - ) + adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) assert target.get("cache_control") == cache_control @@ -2185,9 +2138,7 @@ def test_should_not_add_cache_control_when_none(): {}, ]: target = {} - adapter._add_cache_control_if_applicable( - source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL - ) + adapter._add_cache_control_if_applicable(source, target, CACHE_CONTROL_BEDROCK_CONVERSE_MODEL) assert "cache_control" not in target @@ -2198,9 +2149,7 @@ def test_should_not_add_cache_control_when_model_none(): for model in [None, ""]: target = {} - adapter._add_cache_control_if_applicable( - {"cache_control": cache_control}, target, model - ) + adapter._add_cache_control_if_applicable({"cache_control": cache_control}, target, model) assert "cache_control" not in target @@ -2306,12 +2255,7 @@ def test_cache_control_fix_does_not_broaden_claude_detection(): make is_anthropic_claude_model treat ARN profiles as Claude, which would route thinking params through unmodified and break non-Claude Bedrock profiles. """ - assert ( - LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model( - CACHE_CONTROL_BEDROCK_ARN_MODEL - ) - is False - ) + assert LiteLLMAnthropicMessagesAdapter.is_anthropic_claude_model(CACHE_CONTROL_BEDROCK_ARN_MODEL) is False def test_thinking_preserved_for_bedrock_arn_inference_profile(): @@ -2940,9 +2884,7 @@ def test_translate_streaming_openai_chunk_to_anthropic_reasoning_content_without ( type_of_content, content_block_delta, - ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic( - choices=choices - ) + ) = LiteLLMAnthropicMessagesAdapter()._translate_streaming_openai_chunk_to_anthropic(choices=choices) assert type_of_content == "thinking_delta" assert content_block_delta["type"] == "thinking_delta" @@ -2974,9 +2916,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only(): ) adapter = LiteLLMAnthropicMessagesAdapter() - anthropic_response = adapter.translate_openai_response_to_anthropic( - response=openai_response - ) + anthropic_response = adapter.translate_openai_response_to_anthropic(response=openai_response) anthropic_content = anthropic_response.get("content") assert anthropic_content is not None @@ -2989,9 +2929,7 @@ def test_translate_openai_response_to_anthropic_with_reasoning_content_only(): # Second block should be text assert anthropic_content[1]["type"] == "text" - assert ( - anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.' - ) + assert anthropic_content[1]["text"] == 'There are **3** "r"s in the word strawberry.' assert anthropic_response.get("stop_reason") == "end_turn" @@ -3043,9 +2981,7 @@ def test_truncate_tool_name_deterministic(): def test_truncate_tool_name_avoids_collisions(): """Similar long names should produce different truncated names.""" name1 = "process_user_data_with_validation_and_error_handling_for_production_environment" - name2 = ( - "process_user_data_with_validation_and_error_handling_for_staging_environment" - ) + name2 = "process_user_data_with_validation_and_error_handling_for_staging_environment" result1 = truncate_tool_name(name1) result2 = truncate_tool_name(name2) @@ -3065,9 +3001,7 @@ def test_create_tool_name_mapping_no_long_names(): def test_create_tool_name_mapping_with_long_names(): """Mapping should contain entries for truncated names.""" - long_name = ( - "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai" - ) + long_name = "a_very_long_tool_name_that_exceeds_the_64_character_limit_imposed_by_openai" tools = [ {"name": "short_name"}, {"name": long_name}, @@ -3092,9 +3026,7 @@ def test_translate_anthropic_tools_with_long_names(): ] adapter = LiteLLMAnthropicMessagesAdapter() - result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai( - tools=tools, model="gpt-4" - ) + result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4") assert len(result) == 1 # The tool name should be truncated @@ -3116,9 +3048,7 @@ def test_translate_anthropic_tools_mixed_names(): ] adapter = LiteLLMAnthropicMessagesAdapter() - result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai( - tools=tools, model="gpt-4" - ) + result, tool_name_mapping = adapter.translate_anthropic_tools_to_openai(tools=tools, model="gpt-4") assert len(result) == 2 # Short name unchanged @@ -3132,9 +3062,7 @@ def test_translate_anthropic_tools_mixed_names(): def test_translate_openai_response_restores_tool_names(): """Tool names in responses should be restored to original.""" - original_name = ( - "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility" - ) + original_name = "a_very_long_tool_name_that_needs_truncation_for_openai_api_compatibility" truncated_name = truncate_tool_name(original_name) tool_name_mapping = {truncated_name: original_name} @@ -3166,9 +3094,7 @@ def test_translate_openai_response_restores_tool_names(): ) adapter = LiteLLMAnthropicMessagesAdapter() - result = adapter.translate_openai_response_to_anthropic( - response=response, tool_name_mapping=tool_name_mapping - ) + result = adapter.translate_openai_response_to_anthropic(response=response, tool_name_mapping=tool_name_mapping) # Find the tool_use block in the response tool_use_blocks = [c for c in result["content"] if c.get("type") == "tool_use"] @@ -3589,9 +3515,7 @@ def test_translate_streaming_openai_response_to_anthropic_cache_tokens_with_appl assert message_delta["usage"]["output_tokens"] == 50 assert message_delta["usage"]["cache_read_input_tokens"] == 30 assert message_delta["usage"]["cache_creation_input_tokens"] == 20 - assert message_delta["context_management"]["applied_edits"][0]["type"] == ( - "compact_20260112" - ) + assert message_delta["context_management"]["applied_edits"][0]["type"] == ("compact_20260112") # ===================================================================== @@ -3640,9 +3564,7 @@ def test_translate_anthropic_client_web_search_preserves_schema_and_choice(schem translated, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai(request) assert "web_search_options" not in translated - assert translated["tools"] == [ - {"type": "function", "function": {"name": "web_search", "parameters": schema}} - ] + assert translated["tools"] == [{"type": "function", "function": {"name": "web_search", "parameters": schema}}] assert translated["tool_choice"] == {"type": "function", "function": {"name": "web_search"}} @@ -3787,15 +3709,8 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert schema["required"] == ["user"] assert schema["properties"]["user"]["additionalProperties"] is False assert schema["properties"]["user"]["required"] == ["name", "address"] - assert ( - schema["properties"]["user"]["properties"]["address"][ - "additionalProperties" - ] - is False - ) - assert schema["properties"]["user"]["properties"]["address"]["required"] == [ - "city" - ] + assert schema["properties"]["user"]["properties"]["address"]["additionalProperties"] is False + assert schema["properties"]["user"]["properties"]["address"]["required"] == ["city"] def test_array_items_object_adds_additional_properties_false(self): output_format = { @@ -3870,19 +3785,9 @@ class TestTranslateAnthropicOutputFormatToOpenAI: assert sorted(schema["required"]) == ["age", "email", "name"] def test_invalid_output_format_returns_none(self): - assert ( - self.adapter.translate_anthropic_output_format_to_openai("invalid") is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) - is None - ) - assert ( - self.adapter.translate_anthropic_output_format_to_openai( - {"type": "json_schema"} - ) - is None - ) + assert self.adapter.translate_anthropic_output_format_to_openai("invalid") is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "text"}) is None + assert self.adapter.translate_anthropic_output_format_to_openai({"type": "json_schema"}) is None class TestAnthropicStreamWrapperToolArgs: @@ -4086,9 +3991,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_compaction_block() ) response = _make_simple_openai_response(text="Hello after compaction.") adapter = LiteLLMAnthropicMessagesAdapter() - result = adapter.translate_openai_response_to_anthropic( - response=response, polyfill_result=polyfill - ) + result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill) content = result.get("content") assert content is not None @@ -4120,9 +4023,7 @@ def test_translate_openai_response_to_anthropic_with_polyfill_iterations_usage() ) response = _make_simple_openai_response(prompt_tokens=100, completion_tokens=30) adapter = LiteLLMAnthropicMessagesAdapter() - result = adapter.translate_openai_response_to_anthropic( - response=response, polyfill_result=polyfill - ) + result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill) usage = result.get("usage") assert usage is not None @@ -4177,13 +4078,9 @@ def test_translate_openai_response_to_anthropic_with_polyfill_both_compaction_an {"type": "compaction", "input_tokens": 300, "output_tokens": 75}, ], ) - response = _make_simple_openai_response( - text="After compaction.", prompt_tokens=120, completion_tokens=40 - ) + response = _make_simple_openai_response(text="After compaction.", prompt_tokens=120, completion_tokens=40) adapter = LiteLLMAnthropicMessagesAdapter() - result = adapter.translate_openai_response_to_anthropic( - response=response, polyfill_result=polyfill - ) + result = adapter.translate_openai_response_to_anthropic(response=response, polyfill_result=polyfill) # compaction block must come first content = result.get("content") @@ -4285,7 +4182,9 @@ def test_translate_anthropic_tools_to_openai_omits_unset_strict(): assert function["parameters"]["required"] == ["query"] -TOOL_RESULT_IMAGE_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +TOOL_RESULT_IMAGE_B64 = ( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" +) TOOL_RESULT_IMAGE_URL = "https://example.com/screenshot.png" @@ -4293,8 +4192,7 @@ def _anthropic_tool_use_turn(*tool_use_ids): return AnthopicMessagesAssistantMessageParam( role="assistant", content=[ - {"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}} - for tid in tool_use_ids + {"type": "tool_use", "id": tid, "name": "read_file", "input": {"path": "img.png"}} for tid in tool_use_ids ], ) @@ -4414,9 +4312,7 @@ def test_tool_result_parallel_tool_calls_keep_tool_message_adjacency(): result = _run_chat_completions_pipeline( [ _anthropic_tool_use_turn("toolu_01", "toolu_02"), - _anthropic_tool_result_turn( - {"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]} - ), + _anthropic_tool_result_turn({"toolu_01": [_base64_image_block()], "toolu_02": [_url_image_block()]}), ] ) @@ -4607,10 +4503,7 @@ def test_tool_result_text_and_document_reach_bedrock_converse_tool_result(): ) tool_results = [ - block["toolResult"] - for message in converse_messages - for block in message["content"] - if "toolResult" in block + block["toolResult"] for message in converse_messages for block in message["content"] if "toolResult" in block ] assert len(tool_results) == 1 documents = [part["document"] for part in tool_results[0]["content"] if "document" in part] @@ -4669,7 +4562,9 @@ def test_translate_anthropic_to_openai_without_prompt_cache_breakpoint_adds_noth def test_translate_anthropic_messages_to_openai_carries_midturn_system_prompt_cache_breakpoint(): explicit = {"mode": "explicit"} result = LiteLLMAnthropicMessagesAdapter().translate_anthropic_messages_to_openai( - messages=[{"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]}], + messages=[ + {"role": "system", "content": [{"type": "text", "text": "fix", "prompt_cache_breakpoint": explicit}]} + ], model="gpt-5.6", ) assert result == [ @@ -5565,3 +5460,176 @@ def test_translate_anthropic_to_openai_midturn_tool_addition_deduplication(): assert len(translated_req["tools"]) == 1 assert translated_req["tools"][0]["function"]["name"] == "calc" + + +def test_translate_anthropic_to_openai_midturn_tool_addition_and_removal_shapes(): + """ + Test alternative payload shapes for tool_addition and tool_removal. + """ + adapter = LiteLLMAnthropicMessagesAdapter() + + # 1. block with "definition" directly + req1 = { + "model": "openai/some-open-model", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "definition": {"name": "def_tool", "input_schema": {}}, + } + ], + } + ], + } + t_req1, _ = adapter.translate_anthropic_to_openai(req1) + assert any(t["function"]["name"] == "def_tool" for t in t_req1["tools"]) + + # 2. block with "tool_definition" directly + req2 = { + "model": "openai/some-open-model", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool_definition": {"name": "tool_def_tool", "input_schema": {}}, + } + ], + } + ], + } + t_req2, _ = adapter.translate_anthropic_to_openai(req2) + assert any(t["function"]["name"] == "tool_def_tool" for t in t_req2["tools"]) + + # 3. block with "tool" as dict directly + req3 = { + "model": "openai/some-open-model", + "messages": [ + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool": {"name": "raw_tool", "input_schema": {}}, + } + ], + } + ], + } + t_req3, _ = adapter.translate_anthropic_to_openai(req3) + assert any(t["function"]["name"] == "raw_tool" for t in t_req3["tools"]) + + # 4. removal variations: "name", "tool.name", "tool" as string + req4 = { + "model": "openai/some-open-model", + "tools": [ + {"name": "rem1", "input_schema": {}}, + {"name": "rem2", "input_schema": {}}, + {"name": "rem3", "input_schema": {}}, + {"name": "keep", "input_schema": {}}, + ], + "messages": [ + { + "role": "system", + "content": [ + {"type": "tool_removal", "name": "rem1"}, + {"type": "tool_removal", "tool": {"name": "rem2"}}, + {"type": "tool_removal", "tool": "rem3"}, + ], + } + ], + } + t_req4, _ = adapter.translate_anthropic_to_openai(req4) + names = [t["function"]["name"] for t in t_req4["tools"]] + assert names == ["keep"] + + +def test_extract_request_tool_names_includes_midturn_additions(): + """ + Test that AnthropicMessagesHandler.extract_request_tool_names discovers tools + defined inside mid-conversation tool_addition blocks so allowlists are enforced. + """ + from litellm.llms.anthropic.chat.guardrail_translation.handler import ( + AnthropicMessagesHandler, + ) + + handler = AnthropicMessagesHandler() + data = { + "model": "claude-3-5-sonnet-20241022", + "tools": [{"name": "allowed_tool", "input_schema": {}}], + "messages": [ + {"role": "user", "content": "Hello"}, + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "smuggled_tool", + "input_schema": {}, + }, + }, + } + ], + }, + ], + } + + extracted_names = handler.extract_request_tool_names(data) + assert "allowed_tool" in extracted_names + assert "smuggled_tool" in extracted_names + + +def test_strip_disallowed_tool_additions_when_guardrail_filters_tools(): + """ + Test that _strip_disallowed_tool_additions strips any tool_addition block + for tools that were removed by guardrails. + """ + from litellm.llms.anthropic.chat.guardrail_translation.handler import ( + AnthropicMessagesHandler, + ) + + handler = AnthropicMessagesHandler() + data = { + "model": "claude-3-5-sonnet-20241022", + "tools": [{"name": "allowed_tool", "input_schema": {}}], + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Do something"}, + { + "type": "tool_addition", + "tool": {"name": "disallowed_tool", "input_schema": {}}, + }, + ], + }, + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool": {"name": "allowed_tool", "input_schema": {}}, + } + ], + }, + ], + } + + retained_names = frozenset({"allowed_tool"}) + handler._strip_disallowed_tool_additions(data, retained_names) + + # Disallowed tool_addition was stripped from user message, but text remains + user_content = data["messages"][0]["content"] + assert len(user_content) == 1 + assert user_content[0]["text"] == "Do something" + + # Allowed tool_addition remains + sys_content = data["messages"][1]["content"] + assert len(sys_content) == 1 + assert sys_content[0]["tool"]["name"] == "allowed_tool"