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)
This commit is contained in:
ishaan-berri 2026-04-08 16:09:29 -07:00 committed by GitHub
parent 2964761e0c
commit 125cc1f0be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 274 additions and 109 deletions

View file

@ -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

View file

@ -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