From 125cc1f0be4204b497b7ffd6d65d5c2abb9b297b Mon Sep 17 00:00:00 2001 From: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Date: Wed, 8 Apr 2026 16:09:29 -0700 Subject: [PATCH] fix(bedrock): preserve redacted_thinking blocks in converse multi-turn (#25375) * fix(bedrock): handle redacted_thinking blocks in translate_thinking_blocks_to_reasoning_content_blocks Converts redacted_thinking blocks to BedrockConverseReasoningContentBlock with redactedContent field instead of creating an empty reasoningText block. Fixes the root cause of conversation-breaking 400 errors when Bedrock returns redacted_thinking in extended thinking responses. * test(bedrock): add regression tests for redacted_thinking block handling in converse path Tests cover: - translate_thinking_blocks_to_reasoning_content_blocks with redacted_thinking input - async and sync _bedrock_converse_messages_pt preserving redacted_thinking blocks - regular thinking blocks still work (no regression) --- .../prompt_templates/factory.py | 101 ++++--- ...llm_core_utils_prompt_templates_factory.py | 282 +++++++++++++----- 2 files changed, 274 insertions(+), 109 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d29ca1649ff..c745ebfd84b 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1393,10 +1393,10 @@ def convert_to_gemini_tool_call_invoke( if tool_calls is not None: for idx, tool in enumerate(tool_calls): if "function" in tool: - gemini_function_call: Optional[ - VertexFunctionCall - ] = _gemini_tool_call_invoke_helper( - function_call_params=tool["function"] + gemini_function_call: Optional[VertexFunctionCall] = ( + _gemini_tool_call_invoke_helper( + function_call_params=tool["function"] + ) ) if gemini_function_call is not None: part_dict: VertexPartType = { @@ -1574,9 +1574,7 @@ def convert_to_gemini_tool_call_result( # noqa: PLR0915 file_data = ( file_content.get("file_data", "") if isinstance(file_content, dict) - else file_content - if isinstance(file_content, str) - else "" + else file_content if isinstance(file_content, str) else "" ) if file_data: @@ -2081,9 +2079,9 @@ def _sanitize_empty_text_content( if isinstance(content, str): if not content or not content.strip(): message = cast(AllMessageValues, dict(message)) # Make a copy - message[ - "content" - ] = "[System: Empty message content sanitised to satisfy protocol]" + message["content"] = ( + "[System: Empty message content sanitised to satisfy protocol]" + ) verbose_logger.debug( f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message" ) @@ -2423,9 +2421,9 @@ def anthropic_messages_pt( # noqa: PLR0915 # Convert ChatCompletionImageUrlObject to dict if needed image_url_value = m["image_url"] if isinstance(image_url_value, str): - image_url_input: Union[ - str, dict[str, Any] - ] = image_url_value + image_url_input: Union[str, dict[str, Any]] = ( + image_url_value + ) else: # ChatCompletionImageUrlObject or dict case - convert to dict image_url_input = { @@ -2452,9 +2450,9 @@ def anthropic_messages_pt( # noqa: PLR0915 ) if "cache_control" in _content_element: - _anthropic_content_element[ - "cache_control" - ] = _content_element["cache_control"] + _anthropic_content_element["cache_control"] = ( + _content_element["cache_control"] + ) user_content.append(_anthropic_content_element) elif m.get("type", "") == "text": m = cast(ChatCompletionTextObject, m) @@ -2514,9 +2512,9 @@ def anthropic_messages_pt( # noqa: PLR0915 ) if "cache_control" in _content_element: - _anthropic_content_text_element[ - "cache_control" - ] = _content_element["cache_control"] + _anthropic_content_text_element["cache_control"] = ( + _content_element["cache_control"] + ) user_content.append(_anthropic_content_text_element) @@ -2649,9 +2647,9 @@ def anthropic_messages_pt( # noqa: PLR0915 original_content_element=dict(assistant_content_block), ) if "cache_control" in _content_element: - _anthropic_text_content_element[ - "cache_control" - ] = _content_element["cache_control"] + _anthropic_text_content_element["cache_control"] = ( + _content_element["cache_control"] + ) text_element = _anthropic_text_content_element # Interleave: each thinking block precedes its server tool group. @@ -2769,6 +2767,9 @@ def anthropic_messages_pt( # noqa: PLR0915 AnthropicMessagesTextParam, ] = cast(ChatCompletionThinkingBlock, m) assistant_content.append(anthropic_message) + # handle redacted_thinking blocks - pass through as-is + elif m.get("type", "") == "redacted_thinking": + assistant_content.append(m) # type: ignore # handle text elif ( m.get("type", "") == "text" and len(text_block) > 0 @@ -2811,9 +2812,9 @@ def anthropic_messages_pt( # noqa: PLR0915 ) if "cache_control" in _content_element: - _anthropic_text_content_element[ - "cache_control" - ] = _content_element["cache_control"] + _anthropic_text_content_element["cache_control"] = ( + _content_element["cache_control"] + ) assistant_content.append(_anthropic_text_content_element) @@ -4585,6 +4586,16 @@ class BedrockConverseMessagesProcessor: thinking_blocks=thinking_block, assistant_parts=assistants_parts, ) + elif element["type"] == "redacted_thinking": + redacted_block = BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks( + thinking_blocks=[ + cast(ChatCompletionThinkingBlock, element) + ] + ) + assistants_parts = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content( + thinking_blocks=redacted_block, + assistant_parts=assistants_parts, + ) elif element["type"] == "text": # Skip completely empty strings to avoid blank content blocks if element.get("text", "").strip(): @@ -4663,16 +4674,22 @@ class BedrockConverseMessagesProcessor: ) -> List[BedrockContentBlock]: reasoning_content_blocks: List[BedrockContentBlock] = [] for thinking_block in thinking_blocks: - reasoning_text = thinking_block.get("thinking") - reasoning_signature = thinking_block.get("signature") - text_block = BedrockConverseReasoningTextBlock( - text=reasoning_text or "", - ) - if reasoning_signature is not None: - text_block["signature"] = reasoning_signature - reasoning_content_block = BedrockConverseReasoningContentBlock( - reasoningText=text_block, - ) + block_type = thinking_block.get("type", "thinking") + if block_type == "redacted_thinking": + reasoning_content_block = BedrockConverseReasoningContentBlock( + redactedContent=thinking_block.get("data", ""), + ) + else: + reasoning_text = thinking_block.get("thinking") + reasoning_signature = thinking_block.get("signature") + text_block = BedrockConverseReasoningTextBlock( + text=reasoning_text or "", + ) + if reasoning_signature is not None: + text_block["signature"] = reasoning_signature + reasoning_content_block = BedrockConverseReasoningContentBlock( + reasoningText=text_block, + ) bedrock_content_block = BedrockContentBlock( reasoningContent=reasoning_content_block ) @@ -4953,6 +4970,16 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915 thinking_blocks=thinking_block, assistant_parts=assistants_parts, ) + elif element["type"] == "redacted_thinking": + redacted_block = BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks( + thinking_blocks=[ + cast(ChatCompletionThinkingBlock, element) + ] + ) + assistants_parts = BedrockConverseMessagesProcessor.add_thinking_blocks_to_assistant_content( + thinking_blocks=redacted_block, + assistant_parts=assistants_parts, + ) elif element["type"] == "text": # AWS Bedrock doesn't allow empty or whitespace-only text content # Skip completely empty strings to avoid blank content blocks @@ -5255,9 +5282,7 @@ def default_response_schema_prompt(response_schema: dict) -> str: prompt_str = """Use this JSON schema: ```json {} - ```""".format( - response_schema - ) + ```""".format(response_schema) return prompt_str diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 30b47a853ef..a4e9c958c67 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -10,6 +10,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( BedrockConverseMessagesProcessor, BedrockImageProcessor, anthropic_messages_pt, + _bedrock_converse_messages_pt, _convert_to_bedrock_tool_call_invoke, convert_to_gemini_tool_call_result, ollama_pt, @@ -52,6 +53,117 @@ def test_ollama_pt_consecutive_user_messages(): assert result["prompt"] == expected_prompt +def test_translate_thinking_blocks_redacted_thinking(): + """ + Test that translate_thinking_blocks_to_reasoning_content_blocks correctly converts + redacted_thinking blocks to BedrockConverseReasoningContentBlock with redactedContent. + Regression test for: redacted_thinking blocks being silently dropped in Bedrock converse path. + """ + redacted_block = {"type": "redacted_thinking", "data": "abc123encrypteddata"} + result = BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks( + thinking_blocks=[redacted_block] # type: ignore + ) + assert len(result) == 1 + reasoning_content = result[0].get("reasoningContent", {}) + assert reasoning_content.get("redactedContent") == "abc123encrypteddata" + assert "reasoningText" not in reasoning_content + + +def test_translate_thinking_blocks_regular_thinking(): + """ + Test that translate_thinking_blocks_to_reasoning_content_blocks still correctly converts + regular thinking blocks (regression check). + """ + thinking_block = { + "type": "thinking", + "thinking": "Let me reason through this.", + "signature": "sig-xyz", + } + result = BedrockConverseMessagesProcessor.translate_thinking_blocks_to_reasoning_content_blocks( + thinking_blocks=[thinking_block] # type: ignore + ) + assert len(result) == 1 + reasoning_content = result[0].get("reasoningContent", {}) + assert ( + reasoning_content.get("reasoningText", {}).get("text") + == "Let me reason through this." + ) + assert reasoning_content.get("reasoningText", {}).get("signature") == "sig-xyz" + assert "redactedContent" not in reasoning_content + + +@pytest.mark.asyncio +async def test_bedrock_converse_redacted_thinking_blocks_preserved_async(): + """ + Test that redacted_thinking blocks in assistant content are preserved (not dropped) + in the async Bedrock converse message processor. + Regression test for: conversation breaking when Bedrock returns redacted_thinking blocks. + """ + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "encryptedthinkingdata"}, + {"type": "text", "text": "Here is my response."}, + ], + }, + {"role": "user", "content": "Follow-up question"}, + ] + + result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="us.anthropic.claude-opus-4-6-20251101-v1:0", + llm_provider="bedrock", + ) + + # Find the assistant message + assistant_msg = next(m for m in result if m["role"] == "assistant") + content_types = [ + block.get("reasoningContent", {}).get("redactedContent") or block.get("text") + for block in assistant_msg["content"] + ] + # redactedContent block must be present + assert ( + "encryptedthinkingdata" in content_types + ), "redacted_thinking block was dropped from assistant content — this breaks the next Bedrock turn" + assert "Here is my response." in content_types + + +def test_bedrock_converse_redacted_thinking_blocks_preserved_sync(): + """ + Test that redacted_thinking blocks in assistant content are preserved (not dropped) + in the sync Bedrock converse message processor. + """ + messages = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "encryptedthinkingdata"}, + {"type": "text", "text": "Here is my response."}, + ], + }, + {"role": "user", "content": "Follow-up question"}, + ] + + result = _bedrock_converse_messages_pt( + messages=messages, + model="us.anthropic.claude-opus-4-6-20251101-v1:0", + llm_provider="bedrock", + ) + + assistant_msg = next(m for m in result if m["role"] == "assistant") + content_types = [ + block.get("reasoningContent", {}).get("redactedContent") or block.get("text") + for block in assistant_msg["content"] + ] + assert ( + "encryptedthinkingdata" in content_types + ), "redacted_thinking block was dropped from assistant content — this breaks the next Bedrock turn" + assert "Here is my response." in content_types + + @pytest.mark.asyncio async def test_anthropic_bedrock_thinking_blocks_with_none_content(): """ @@ -543,7 +655,12 @@ def test_convert_gemini_tool_call_result_with_image_url(): message_dict_format = ChatCompletionToolMessage( role="tool", tool_call_id="call_456", - content=[{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ"}}], + content=[ + { + "type": "image_url", + "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ"}, + } + ], ) last_message_with_tool_calls["tool_calls"][0]["id"] = "call_456" @@ -617,11 +734,19 @@ def test_convert_gemini_tool_call_result_with_multiple_anthropic_image_blocks(): {"type": "text", "text": "here are two images"}, { "type": "image", - "source": {"type": "base64", "media_type": "image/png", "data": png_b64}, + "source": { + "type": "base64", + "media_type": "image/png", + "data": png_b64, + }, }, { "type": "image", - "source": {"type": "base64", "media_type": "image/jpeg", "data": jpeg_b64}, + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": jpeg_b64, + }, }, ], ) @@ -644,7 +769,9 @@ def test_convert_gemini_tool_call_result_with_multiple_anthropic_image_blocks(): ) assert isinstance(result, list), "expected a list of parts" inline_parts = [p for p in result if "inline_data" in p] - assert len(inline_parts) == 2, f"expected 2 inline_data parts, got {len(inline_parts)}" + assert ( + len(inline_parts) == 2 + ), f"expected 2 inline_data parts, got {len(inline_parts)}" mime_types = {p["inline_data"]["mime_type"] for p in inline_parts} assert mime_types == {"image/png", "image/jpeg"} @@ -681,7 +808,9 @@ def test_convert_gemini_tool_call_result_with_data_url_string(): ) assert isinstance(result, list), "expected a list of parts" inline_parts = [p for p in result if "inline_data" in p] - assert len(inline_parts) == 1, "data-URL image string was not converted to inline_data" + assert ( + len(inline_parts) == 1 + ), "data-URL image string was not converted to inline_data" assert inline_parts[0]["inline_data"]["mime_type"] == "image/png" assert inline_parts[0]["inline_data"]["data"] == tiny_png_b64 @@ -718,9 +847,9 @@ def test_convert_gemini_tool_call_result_with_data_url_extra_params(): assert isinstance(result, list), "expected a list of parts" inline_parts = [p for p in result if "inline_data" in p] assert len(inline_parts) == 1 - assert inline_parts[0]["inline_data"]["mime_type"] == "image/png", ( - f"expected clean 'image/png', got '{inline_parts[0]['inline_data']['mime_type']}'" - ) + assert ( + inline_parts[0]["inline_data"]["mime_type"] == "image/png" + ), f"expected clean 'image/png', got '{inline_parts[0]['inline_data']['mime_type']}'" def test_bedrock_tools_unpack_defs(): @@ -1007,8 +1136,14 @@ def test_bedrock_image_processor_content_type_document_formats(): test_cases = [ ("https://example.com/doc.pdf", "application/pdf"), ("https://example.com/sheet.csv", "text/csv"), - ("https://example.com/doc.docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"), - ("https://example.com/sheet.xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + ( + "https://example.com/doc.docx", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ), + ( + "https://example.com/sheet.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ), ("https://example.com/page.html", "text/html"), ("https://example.com/readme.txt", "text/plain"), ] @@ -1017,7 +1152,9 @@ def test_bedrock_image_processor_content_type_document_formats(): _, content_type = BedrockImageProcessor._post_call_image_processing( mock_response, url ) - assert content_type == expected_mime, f"Expected {expected_mime} for {url}, got {content_type}" + assert ( + content_type == expected_mime + ), f"Expected {expected_mime} for {url}, got {content_type}" def test_bedrock_image_processor_content_type_s3_pdf_with_query(): @@ -1084,6 +1221,7 @@ def test_bedrock_tools_pt_empty_description(): assert tool_spec.get("name") == "get_weather" assert tool_spec.get("description") == "get_weather" + def test_bedrock_create_bedrock_block_deterministic_document_hash(): """ Test that _create_bedrock_block generates deterministic document names @@ -1283,7 +1421,9 @@ def test_bedrock_create_bedrock_block_document_name_format(): # Check format: DocumentPDFmessages_{16_hex_chars}_{format} pattern = r"^DocumentPDFmessages_[0-9a-f]{16}_pdf$" - assert re.match(pattern, document_name), f"Document name format mismatch: {document_name}" + assert re.match( + pattern, document_name + ), f"Document name format mismatch: {document_name}" def test_bedrock_create_bedrock_block_different_document_formats(): @@ -1313,6 +1453,7 @@ def test_bedrock_create_bedrock_block_different_document_formats(): assert block["document"]["name"].endswith(f"_{format_type}") assert block["document"]["format"] == format_type + def test_bedrock_nova_web_search_options_mapping(): """ Test that web_search_options is correctly mapped to Nova grounding. @@ -1336,8 +1477,7 @@ def test_bedrock_nova_web_search_options_mapping(): # Test with search_context_size (should be ignored for Nova) result2 = config._map_web_search_options( - {"search_context_size": "high"}, - "us.amazon.nova-premier-v1:0" + {"search_context_size": "high"}, "us.amazon.nova-premier-v1:0" ) assert result2 is not None @@ -1346,6 +1486,7 @@ def test_bedrock_nova_web_search_options_mapping(): assert system_tool2["name"] == "nova_grounding" # Nova doesn't support search_context_size, so it's just ignored + def test_bedrock_tools_pt_does_not_handle_system_tool(): """ Verify that _bedrock_tools_pt does NOT handle system_tool format. @@ -1365,12 +1506,10 @@ def test_bedrock_tools_pt_does_not_handle_system_tool(): "description": "Get the current weather", "parameters": { "type": "object", - "properties": { - "location": {"type": "string"} - }, - "required": ["location"] - } - } + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, } ] @@ -1381,6 +1520,7 @@ def test_bedrock_tools_pt_does_not_handle_system_tool(): assert tool_spec is not None assert tool_spec["name"] == "get_weather" + def test_convert_to_anthropic_tool_result_image_with_cache_control(): """ Test that cache_control is properly applied to image content in tool results. @@ -1545,6 +1685,8 @@ def test_convert_to_anthropic_tool_result_image_url_as_http(): assert result["content"][0]["source"]["type"] == "url" assert result["content"][0]["source"]["url"] == "https://example.com/image.jpg" assert result["content"][0]["cache_control"]["type"] == "ephemeral" + + def test_anthropic_messages_pt_server_tool_use_passthrough(): """ Test that anthropic_messages_pt passes through server_tool_use and @@ -1555,13 +1697,12 @@ def test_anthropic_messages_pt_server_tool_use_passthrough(): Fixes: https://github.com/BerriAI/litellm/issues/XXXXX """ - from litellm.litellm_core_utils.prompt_templates.factory import anthropic_messages_pt + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) messages = [ - { - "role": "user", - "content": "I need help with time information." - }, + {"role": "user", "content": "I need help with time information."}, { "role": "assistant", "content": [ @@ -1569,7 +1710,7 @@ def test_anthropic_messages_pt_server_tool_use_passthrough(): "type": "server_tool_use", "id": "srvtoolu_01ABC123", "name": "tool_search_tool_regex", - "input": {"query": ".*time.*"} + "input": {"query": ".*time.*"}, }, { "type": "tool_search_tool_result", @@ -1578,19 +1719,13 @@ def test_anthropic_messages_pt_server_tool_use_passthrough(): "type": "tool_search_tool_search_result", "tool_references": [ {"type": "tool_reference", "tool_name": "get_time"} - ] - } + ], + }, }, - { - "type": "text", - "text": "I found the time tool. How can I help you?" - } + {"type": "text", "text": "I found the time tool. How can I help you?"}, ], }, - { - "role": "user", - "content": "What's the time in New York?" - }, + {"role": "user", "content": "What's the time in New York?"}, ] result = anthropic_messages_pt( @@ -1622,7 +1757,9 @@ def test_anthropic_messages_pt_server_tool_use_passthrough(): # Verify tool_search_tool_result block is preserved assert "tool_search_tool_result" in content_types tool_result_block = next( - b for b in assistant_msg["content"] if b.get("type") == "tool_search_tool_result" + b + for b in assistant_msg["content"] + if b.get("type") == "tool_search_tool_result" ) assert tool_result_block["tool_use_id"] == "srvtoolu_01ABC123" assert tool_result_block["content"]["type"] == "tool_search_tool_search_result" @@ -1630,9 +1767,7 @@ def test_anthropic_messages_pt_server_tool_use_passthrough(): # Verify text block is also preserved assert "text" in content_types - text_block = next( - b for b in assistant_msg["content"] if b.get("type") == "text" - ) + text_block = next(b for b in assistant_msg["content"] if b.get("type") == "text") assert text_block["text"] == "I found the time tool. How can I help you?" @@ -1663,7 +1798,10 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs(): "Expression": { "type": "object", "properties": { - "type": {"type": "string", "enum": ["and", "or", "not", "comparison"]}, + "type": { + "type": "string", + "enum": ["and", "or", "not", "comparison"], + }, "left": {"$ref": "#/$defs/Operand"}, "right": {"$ref": "#/$defs/Operand"}, "operator": {"$ref": "#/$defs/Operator"}, @@ -1674,7 +1812,9 @@ def test_bedrock_tools_unpack_defs_no_oom_with_nested_refs(): "anyOf": [ {"$ref": "#/$defs/Literal"}, {"$ref": "#/$defs/FieldRef"}, - {"$ref": "#/$defs/Expression"}, # Circular: Operand -> Expression -> Operand + { + "$ref": "#/$defs/Expression" + }, # Circular: Operand -> Expression -> Operand ], }, "Literal": { @@ -1808,9 +1948,9 @@ def test_anthropic_messages_pt_file_block_preserves_cache_control(): file_block = content_blocks[0] assert file_block["type"] == "document" - assert "cache_control" in file_block, ( - "cache_control should be preserved on file/document content blocks" - ) + assert ( + "cache_control" in file_block + ), "cache_control should be preserved on file/document content blocks" assert file_block["cache_control"]["type"] == "ephemeral" text_block = content_blocks[1] @@ -2056,7 +2196,9 @@ def test_sanitize_messages_deduplicates_tool_results(): # Count tool messages with this ID — should be exactly 1 tool_results = [ - m for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123" + m + for m in result + if m.get("role") == "tool" and m.get("tool_call_id") == "call_abc123" ] assert len(tool_results) == 1 # Should keep the LAST occurrence (most complete) @@ -2193,7 +2335,8 @@ def test_sanitize_messages_dedup_scoped_per_turn_preserves_cross_turn(): # Both tool results must survive — one per turn tool_results = [ - m for m in result + m + for m in result if m.get("role") == "tool" and m.get("tool_call_id") == "call_X" ] assert len(tool_results) == 2, ( @@ -2252,38 +2395,35 @@ def test_sanitize_messages_combined_case_a_and_case_d(): missing_results = [ m for m in tool_results if m.get("tool_call_id") == "call_missing" ] - assert len(missing_results) == 1, ( - f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}" - ) + assert ( + len(missing_results) == 1 + ), f"Expected 1 dummy result for call_missing (Case A), got {len(missing_results)}" # Case D: call_duped should have exactly 1 result (the fresh one) duped_results = [ m for m in tool_results if m.get("tool_call_id") == "call_duped" ] - assert len(duped_results) == 1, ( - f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}" - ) - assert duped_results[0]["content"] == "fresh_result", ( - f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'" - ) + assert ( + len(duped_results) == 1 + ), f"Expected 1 result for call_duped after dedup (Case D), got {len(duped_results)}" + assert ( + duped_results[0]["content"] == "fresh_result" + ), f"Expected last-wins 'fresh_result', got '{duped_results[0]['content']}'" # Verify tool results immediately follow the assistant message - asst_idx = next( - i for i, m in enumerate(result) if m.get("role") == "assistant" - ) + asst_idx = next(i for i, m in enumerate(result) if m.get("role") == "assistant") tool_msgs_after_asst = [ - m - for m in result[asst_idx + 1 :] - if m.get("role") in ("tool", "function") + m for m in result[asst_idx + 1 :] if m.get("role") in ("tool", "function") ] - assert len(tool_msgs_after_asst) == 2, ( - f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}" - ) + assert ( + len(tool_msgs_after_asst) == 2 + ), f"Expected 2 tool results after assistant, got {len(tool_msgs_after_asst)}" # Both tool_call_ids should be present (order may vary) tool_ids = {m["tool_call_id"] for m in tool_msgs_after_asst} - assert tool_ids == {"call_missing", "call_duped"}, ( - f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}" - ) + assert tool_ids == { + "call_missing", + "call_duped", + }, f"Expected tool_call_ids {{call_missing, call_duped}}, got {tool_ids}" finally: litellm.modify_params = original @@ -2329,9 +2469,9 @@ def test_anthropic_messages_pt_file_block_preserves_cache_control(): # Document block (from file) should preserve cache_control doc_block = content_blocks[0] assert doc_block["type"] == "document" - assert "cache_control" in doc_block, ( - "cache_control was dropped from file/document block" - ) + assert ( + "cache_control" in doc_block + ), "cache_control was dropped from file/document block" assert doc_block["cache_control"]["type"] == "ephemeral" # Text block should also preserve cache_control