mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
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:
parent
2964761e0c
commit
125cc1f0be
2 changed files with 274 additions and 109 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue