diff --git a/litellm/llms/anthropic/chat/guardrail_translation/handler.py b/litellm/llms/anthropic/chat/guardrail_translation/handler.py index 3ab21099d3a..e38ff7bf777 100644 --- a/litellm/llms/anthropic/chat/guardrail_translation/handler.py +++ b/litellm/llms/anthropic/chat/guardrail_translation/handler.py @@ -52,6 +52,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, @@ -720,6 +721,12 @@ class AnthropicMessagesHandler(BaseTranslation): *anthropic_tools, ] ) + if not scan_only_tool_results: + data_dict: Final[dict[str, object]] = cast(dict[str, object], data) # cast-ok: request dict + retained_names: Final = frozenset( # comprehension-ok: flattens tool names across tools + name for t in list_field(data_dict, "tools") for name in anthropic_tool_names(t) + ) + self._strip_disallowed_tool_additions(data_dict, retained_names) guardrailed_structured_messages: Final = guardrailed_inputs.get("structured_messages") if ( @@ -1000,8 +1007,72 @@ 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.""" + top_names: Final = [ # comprehension-ok: flattens tool names across tools + name for tool in list_field(data, "tools") for name in anthropic_tool_names(tool) + ] + messages: Final = list_field(data, "messages") + if not messages: + return top_names + extra_names: Final[list[str]] = [] # mutable-ok: accumulator + for m in messages: + m_map = as_str_mapping(m) + if m_map is not None: + for block in list_field(m_map, "content"): + block_map = as_str_mapping(block) + if block_map is not None and block_map.get("type") == "tool_addition": + tool = extract_tool_from_addition_block(block_map) + if tool is not None: + for name in anthropic_tool_names(tool): + if name not in top_names and name not in extra_names: + extra_names.append(name) + return [*top_names, *extra_names] + + @staticmethod + def _is_disallowed_addition_block(block: object, retained_names: frozenset[str]) -> bool: + block_map: Final = as_str_mapping(block) + if block_map is None or block_map.get("type") != "tool_addition": + return False + tool: Final = extract_tool_from_addition_block(block_map) + if tool is None: + return True + tool_names: Final = anthropic_tool_names(tool) + if not tool_names: + return True + return any(name not in retained_names for name in tool_names) + + @classmethod + def _strip_disallowed_tool_additions(cls, data: Mapping[str, object], retained_names: frozenset[str]) -> None: + if not isinstance(data, dict): + return + messages_val: Final = data.get("messages") + if not isinstance(messages_val, list): + return + messages: Final = cast(Sequence[object], messages_val) # cast-ok: messages list + cleaned_messages: Final[list[object]] = [] # mutable-ok: accumulator + for m in messages: + m_map = as_str_mapping(m) + if m_map is None: + cleaned_messages.append(m) + continue + content_val = m_map.get("content") + if not isinstance(content_val, list): + cleaned_messages.append(m) + continue + content: Sequence[object] = cast(Sequence[object], content_val) # cast-ok: content list + had_disallowed_addition = any(cls._is_disallowed_addition_block(block, retained_names) for block in content) + if not had_disallowed_addition: + cleaned_messages.append(m) + continue + remaining_blocks: list[object] = [ # mutable-ok: accumulator + block for block in content if not cls._is_disallowed_addition_block(block, retained_names) + ] + if remaining_blocks: + new_m: dict[str, object] = dict(m_map) # mutable-ok: message clone + new_m["content"] = remaining_blocks + cleaned_messages.append(new_m) + data["messages"] = cleaned_messages # rebind-ok: update request in place @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 9b1297dbd75..328ba31d59e 100644 --- a/litellm/llms/anthropic/pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/pass_through/adapters/transformation.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypeVar, cast from pydantic import JsonValue, TypeAdapter import litellm -from litellm.litellm_core_utils.core_helpers import normalize_drop_params +from litellm.litellm_core_utils.core_helpers import as_str_mapping, normalize_drop_params from litellm.litellm_core_utils.hidden_params import get_hidden_params from litellm.llms.anthropic.pass_through.convertible_blocks import tool_result_text_part from litellm.llms.anthropic.pass_through.utils import ( @@ -16,6 +16,10 @@ 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, + list_field, +) from litellm.responses.utils import auto_reasoning_summary # OpenAI has a 64-character limit for function/tool names @@ -474,7 +478,10 @@ class LiteLLMAnthropicMessagesAdapter: preserve_midturn_system: bool = False, ) -> list[AllMessageValues]: new_messages: Final[list[AllMessageValues]] = [] - replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages(messages) + _, cleaned_input_messages = self._extract_and_apply_midturn_tool_changes(messages) + replayable_messages: Final = strip_encrypted_reasoning_blocks_from_anthropic_messages( + cast(Sequence[dict[str, object]], cleaned_input_messages) # cast-ok: anthropic messages shape + ) leading_count: Final = next( (i for i, m in enumerate(replayable_messages) if not is_system_role_message(m)), len(replayable_messages), @@ -1230,6 +1237,102 @@ class LiteLLMAnthropicMessagesAdapter: if k not in translatable_params: # pass remaining params as is new_kwargs[k] = v + @staticmethod + def _adapter_tool_name(tool: object) -> str | None: + tool_map: Final = as_str_mapping(tool) + if tool_map is not None: + name_val: Final = tool_map.get("name") + if isinstance(name_val, str) and name_val: + return name_val + return None + + def _extract_and_apply_midturn_tool_changes( + self, + messages: Sequence[Mapping[str, object] | AllAnthropicPassThroughMessageValues], + initial_tools: Sequence[object] | None = None, + ) -> tuple[tuple[AllAnthropicToolsValues, ...] | None, tuple[AllAnthropicPassThroughMessageValues, ...]]: + """ + Extract and apply mid-conversation tool changes (tool_addition, tool_removal) + such as those sent by Claude Code under mid-conversation-tool-changes beta. + + Returns: + Tuple of (active_tools, cleaned_messages) + - active_tools: Updated tools tuple with additions applied and removals removed, + or None if no tools exist and none were added. + - cleaned_messages: Messages tuple with tool_addition and tool_removal blocks stripped, + and carrier messages left without content dropped. + """ + active_tools: tuple[AllAnthropicToolsValues, ...] = tuple( # rebind-ok: tool updates + cast( # cast-ok: initial tools + AllAnthropicToolsValues, t + ) + for t in (initial_tools or ()) + ) + had_tool_changes: bool = False # rebind-ok: state flag + cleaned_messages: Final[list[AllAnthropicPassThroughMessageValues]] = [] # mutable-ok: accumulator + + for m in messages: + m_map = as_str_mapping(m) + if m_map is None: + cleaned_messages.append(cast(AllAnthropicPassThroughMessageValues, m)) # cast-ok: msg + continue + content = m_map.get("content") + if not isinstance(content, Sequence) or isinstance(content, (str, bytes)): + msg_val = cast(AllAnthropicPassThroughMessageValues, m) # cast-ok: msg + cleaned_messages.append(msg_val) + continue + + content_seq: Sequence[object] = cast(Sequence[object], content) # cast-ok: content blocks + remaining_blocks: list[object] = [] # mutable-ok: accumulator + for block in content_seq: + block_map = as_str_mapping(block) + if block_map is None: + remaining_blocks.append(block) + continue + btype = block_map.get("type") + if btype == "tool_addition": + had_tool_changes = True + raw_tool = extract_tool_from_addition_block(block_map) + if raw_tool is not None: + tool_name = self._adapter_tool_name(raw_tool) + if tool_name is not None and not any( + self._adapter_tool_name(t) == tool_name for t in active_tools + ): + added_tool = cast(AllAnthropicToolsValues, dict(raw_tool)) # cast-ok: tool + active_tools = (*active_tools, added_tool) + elif btype == "tool_removal": + had_tool_changes = True + tool_obj = block_map.get("tool") + tool_map = as_str_mapping(tool_obj) + rem_name = ( + block_map.get("tool_name") + or block_map.get("name") + or (tool_map.get("name") if tool_map is not None else None) + or (tool_obj if isinstance(tool_obj, str) else None) + ) + if rem_name: + rem_name_str = str(rem_name) + active_tools = tuple(t for t in active_tools if self._adapter_tool_name(t) != rem_name_str) + else: + remaining_blocks.append(block) + + if remaining_blocks: + new_m: dict[str, object] = dict(m_map) # mutable-ok: message clone + new_m["content"] = remaining_blocks + new_msg = cast(AllAnthropicPassThroughMessageValues, new_m) # cast-ok: msg + cleaned_messages.append(new_msg) + elif not had_tool_changes: + clean_msg = cast(AllAnthropicPassThroughMessageValues, m) # cast-ok: msg + cleaned_messages.append(clean_msg) + + if not had_tool_changes and initial_tools is None: + return None, tuple(cleaned_messages) + + return ( + active_tools if active_tools else (() if initial_tools is not None or had_tool_changes else None), + tuple(cleaned_messages), + ) + def translate_anthropic_to_openai( self, anthropic_message_request: AnthropicMessagesRequest, @@ -1246,8 +1349,6 @@ class LiteLLMAnthropicMessagesAdapter: - tool_name_mapping maps truncated tool names back to original names for tools that exceeded OpenAI's 64-char limit """ - # Debug: Processing Anthropic message request - new_messages: list[AllMessageValues] = [] tool_name_mapping: dict[str, str] = {} ## CONVERT ANTHROPIC MESSAGES TO OPENAI @@ -1255,14 +1356,32 @@ class LiteLLMAnthropicMessagesAdapter: list[AllAnthropicPassThroughMessageValues], anthropic_message_request["messages"], ) - new_messages = self.translate_anthropic_messages_to_openai( + request_dict: Final[dict[str, object]] = dict(anthropic_message_request) # mutable-ok: clone + initial_tools_val: Final = list_field(request_dict, "tools") if "tools" in request_dict else None + ( + active_tools, + cleaned_messages, + ) = self._extract_and_apply_midturn_tool_changes( messages=messages_list, + initial_tools=initial_tools_val, + ) + if active_tools is not None: + if active_tools: + request_dict["tools"] = list(active_tools) + else: + request_dict.pop("tools", None) + request_for_translation: Final[AnthropicMessagesRequest] = cast( # cast-ok: anthropic request type + AnthropicMessagesRequest, request_dict + ) + + new_messages: Final = self.translate_anthropic_messages_to_openai( + messages=list(cleaned_messages), model=anthropic_message_request.get("model"), custom_llm_provider=custom_llm_provider, preserve_midturn_system=preserve_midturn_system, ) ## ADD SYSTEM MESSAGE TO MESSAGES - self._add_system_message_to_messages(new_messages, anthropic_message_request) + self._add_system_message_to_messages(new_messages, request_for_translation) new_kwargs: Final[ChatCompletionRequest] = { "model": anthropic_message_request["model"], @@ -1281,7 +1400,7 @@ class LiteLLMAnthropicMessagesAdapter: ) ## CONVERT TOOLS tool_name_mapping = self._translate_tools_to_openai( - anthropic_message_request=anthropic_message_request, + anthropic_message_request=request_for_translation, new_kwargs=new_kwargs, ) ## CONVERT THINKING diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 725146fe894..fdcf0ea8aea 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -6,6 +6,7 @@ from typing import Final, TypeVar, cast # noqa: TID251 # a rebuilt chat row ha from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError +from litellm.litellm_core_utils.core_helpers import as_str_mapping from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage from litellm.types.llms.openai import AllMessageValues, ResponseAPIUsage @@ -321,6 +322,27 @@ def anthropic_tool_name(tool: object) -> str | None: return names[0] if names else None +def extract_tool_from_addition_block(block: object) -> Mapping[str, object] | None: + """Extract raw tool dict from an Anthropic tool_addition block.""" + mapping: Final = as_str_mapping(block) + if mapping is None: + return None + tool_data: Final = as_str_mapping(mapping.get("tool")) + if tool_data is not None: + if ( + tool_data.get("type") == "tool_definition" + and "definition" in tool_data + and (definition := as_str_mapping(tool_data.get("definition"))) is not None + ): + return definition + return tool_data + if "definition" in mapping and (definition := as_str_mapping(mapping.get("definition"))) is not None: + return definition + if (tool_def := as_str_mapping(mapping.get("tool_definition"))) is not None: + return tool_def + 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 658aa009683..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 == [ @@ -5419,3 +5314,322 @@ async def test_chat_bridge_tool_result_text_block_without_text_reaches_the_model ], } ] + + +def test_translate_anthropic_to_openai_midturn_tool_addition(): + """ + Test that mid-conversation tool_addition blocks (such as Claude Code's mid-conversation-tool-changes) + are hoisted into the request's tools and stripped from the translated messages. + """ + messages = [ + {"role": "user", "content": "What is the weather in Dallas?"}, + { + "role": "system", + "content": [ + {"type": "text", "text": "A new tool is available."}, + { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "get_weather", + "description": "Get the weather for a city", + "input_schema": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + }, + }, + }, + ], + }, + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + req = {"model": "openai/some-open-model", "messages": messages} + translated_req, _ = adapter.translate_anthropic_to_openai(req) + + # Verify tool was added to tools + assert "tools" in translated_req + assert len(translated_req["tools"]) == 1 + tool = translated_req["tools"][0] + assert tool["type"] == "function" + assert tool["function"]["name"] == "get_weather" + assert tool["function"]["description"] == "Get the weather for a city" + assert tool["function"]["parameters"]["properties"]["city"]["type"] == "string" + + # Verify tool_addition was stripped and text preserved + assert len(translated_req["messages"]) == 2 + assert "get_weather" not in json.dumps(translated_req["messages"]) + + +def test_translate_anthropic_to_openai_midturn_tool_addition_carrier_only(): + """ + Test that when a system message contains only tool_addition, the tool is added + and the carrier message is dropped so no empty operator note is sent. + """ + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool": { + "type": "tool_definition", + "definition": { + "name": "calc", + "description": "Calculator", + "input_schema": {"type": "object"}, + }, + }, + } + ], + }, + {"role": "user", "content": "Calculate 2+2"}, + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + req = {"model": "openai/some-open-model", "messages": messages} + translated_req, _ = adapter.translate_anthropic_to_openai(req) + + assert "tools" in translated_req + assert len(translated_req["tools"]) == 1 + assert translated_req["tools"][0]["function"]["name"] == "calc" + + # Carrier message should be dropped entirely + assert len(translated_req["messages"]) == 2 + assert translated_req["messages"][0]["content"] == "Hello" + assert translated_req["messages"][1]["content"] == "Calculate 2+2" + + +def test_translate_anthropic_to_openai_midturn_tool_removal(): + """ + Test that mid-conversation tool_removal drops the tool from the request. + """ + messages = [ + {"role": "user", "content": "Start"}, + {"role": "system", "content": [{"type": "tool_removal", "tool_name": "calc"}]}, + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + req = { + "model": "openai/some-open-model", + "messages": messages, + "tools": [ + {"name": "calc", "input_schema": {}}, + {"name": "search", "input_schema": {}}, + ], + } + translated_req, _ = adapter.translate_anthropic_to_openai(req) + + assert "tools" in translated_req + tool_names = [t["function"]["name"] for t in translated_req["tools"]] + assert tool_names == ["search"] + + +def test_translate_anthropic_to_openai_midturn_tool_addition_deduplication(): + """ + Test that adding a tool that is already in top-level tools skips duplicate definition. + """ + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "system", + "content": [ + { + "type": "tool_addition", + "tool": { + "name": "calc", + "description": "Calculator", + "input_schema": {"type": "object"}, + }, + } + ], + }, + ] + + adapter = LiteLLMAnthropicMessagesAdapter() + req = { + "model": "openai/some-open-model", + "messages": messages, + "tools": [{"name": "calc", "input_schema": {}}], + } + translated_req, _ = adapter.translate_anthropic_to_openai(req) + + 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"