diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d29ca1649f..22bf4c565c 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -4051,6 +4051,36 @@ def _deduplicate_bedrock_tool_content( return _deduplicate_bedrock_content_blocks(tool_content, "toolResult") +def _sort_bedrock_assistant_content_blocks( + blocks: List[BedrockContentBlock], +) -> List[BedrockContentBlock]: + """ + Sort assistant content blocks so that ``text`` blocks appear before + ``toolUse`` blocks. + + Bedrock requires all ``text`` blocks to precede any ``toolUse`` blocks + within an assistant message. When the Responses API converts + function_call items before message items, the resulting ``toolUse`` + blocks can end up before ``text`` blocks, causing Bedrock to reject + the request with a 400 error because the ``toolUse`` → ``toolResult`` + pairing is broken by the intervening ``text`` block. + + Sort order (stable): + 0 - reasoningContent + 1 - text / image / document / video / other non-tool blocks + 2 - toolUse + """ + + def _sort_key(block: BedrockContentBlock) -> int: + if "reasoningContent" in block: + return 0 + if "toolUse" in block: + return 2 + return 1 + + return sorted(blocks, key=_sort_key) + + def _insert_assistant_continue_message( messages: List[BedrockMessageBlock], assistant_continue_message: Optional[ @@ -4642,6 +4672,9 @@ class BedrockConverseMessagesProcessor: assistant_content = _deduplicate_bedrock_content_blocks( assistant_content, "toolUse" ) + assistant_content = _sort_bedrock_assistant_content_blocks( + assistant_content + ) if assistant_content: contents.append( @@ -5007,6 +5040,9 @@ def _bedrock_converse_messages_pt( # noqa: PLR0915 assistant_content = _deduplicate_bedrock_content_blocks( assistant_content, "toolUse" ) + assistant_content = _sort_bedrock_assistant_content_blocks( + assistant_content + ) if assistant_content: contents.append( diff --git a/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py b/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py index 5bd0c9993a..da3de22b42 100644 --- a/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py +++ b/tests/litellm_core_utils/test_bedrock_converse_dedup_factory.py @@ -9,6 +9,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( _bedrock_converse_messages_pt, _deduplicate_bedrock_content_blocks, _deduplicate_bedrock_tool_content, + _sort_bedrock_assistant_content_blocks, BedrockConverseMessagesProcessor, ) @@ -445,3 +446,133 @@ def test_bedrock_converse_filters_empty_list_content(): assert len(text_blocks) == 2 assert text_blocks[0]["text"] == "Hello" assert text_blocks[1]["text"] == "World" + + +# --------------------------------------------------------------------------- +# Content block ordering tests (text before toolUse) +# --------------------------------------------------------------------------- + + +def _make_tooluse_before_text_messages(): + """Return messages where the assistant message has a tool_call followed by + a separate assistant message with text content. When merged, the toolUse + block would end up before the text block without sorting.""" + return [ + {"role": "user", "content": "What's the weather?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "tooluse_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location": "Paris"}', + }, + } + ], + }, + { + "role": "assistant", + "content": "Let me check the weather for you.", + }, + { + "role": "tool", + "tool_call_id": "tooluse_abc123", + "content": '{"temp": 22}', + }, + ] + + +def test_sort_bedrock_assistant_content_blocks_text_before_tooluse(): + """Direct unit test: text blocks should come before toolUse blocks.""" + blocks = [ + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"text": "thinking..."}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert len(result) == 2 + assert "text" in result[0] + assert "toolUse" in result[1] + + +def test_sort_bedrock_assistant_content_blocks_reasoning_first(): + """reasoningContent blocks should come before text and toolUse.""" + blocks = [ + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"text": "thinking..."}, + {"reasoningContent": {"reasoningText": {"text": "reasoning"}}}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert "reasoningContent" in result[0] + assert "text" in result[1] + assert "toolUse" in result[2] + + +def test_sort_bedrock_assistant_content_blocks_preserves_order_when_correct(): + """If blocks are already in the correct order, sorting should not change them.""" + blocks = [ + {"text": "hello"}, + {"toolUse": {"toolUseId": "id_1", "name": "fn_a", "input": {}}}, + {"toolUse": {"toolUseId": "id_2", "name": "fn_b", "input": {}}}, + ] + + result = _sort_bedrock_assistant_content_blocks(blocks) + + assert result == blocks + + +def test_bedrock_converse_sorts_text_before_tooluse_sync(): + """Verify the sync path sorts text blocks before toolUse blocks in + assistant messages.""" + messages = _make_tooluse_before_text_messages() + result = _bedrock_converse_messages_pt(messages, MODEL, PROVIDER) + + assistant_msgs = [msg for msg in result if msg["role"] == "assistant"] + assert len(assistant_msgs) == 1 + + content = assistant_msgs[0]["content"] + text_indices = [i for i, b in enumerate(content) if "text" in b] + tool_indices = [i for i, b in enumerate(content) if "toolUse" in b] + + # All text blocks must come before all toolUse blocks + assert max(text_indices) < min(tool_indices), ( + f"text blocks at {text_indices} should all precede toolUse blocks at {tool_indices}" + ) + + +@pytest.mark.asyncio +async def test_bedrock_converse_sorts_text_before_tooluse_async(): + """Verify the async path sorts text blocks before toolUse blocks in + assistant messages.""" + messages = _make_tooluse_before_text_messages() + result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages, MODEL, PROVIDER + ) + + assistant_msgs = [msg for msg in result if msg["role"] == "assistant"] + assert len(assistant_msgs) == 1 + + content = assistant_msgs[0]["content"] + text_indices = [i for i, b in enumerate(content) if "text" in b] + tool_indices = [i for i, b in enumerate(content) if "toolUse" in b] + + assert max(text_indices) < min(tool_indices), ( + f"text blocks at {text_indices} should all precede toolUse blocks at {tool_indices}" + ) + + +@pytest.mark.asyncio +async def test_bedrock_converse_content_ordering_sync_async_parity(): + """Sync and async paths should produce identical content block ordering.""" + messages = _make_tooluse_before_text_messages() + sync_result = _bedrock_converse_messages_pt(messages, MODEL, PROVIDER) + async_result = await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages, MODEL, PROVIDER + ) + assert sync_result == async_result