From a5b465b5884bbeccd825c68368d1945c937c7a69 Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 25 Jun 2026 00:13:36 +0800 Subject: [PATCH] feat(bedrock): add helper to neutralize orphaned tool blocks --- .../bedrock/chat/converse_transformation.py | 57 +++++++ .../chat/test_converse_transformation.py | 154 ++++++++++++++++++ 2 files changed, 211 insertions(+) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index a700c07d87a..4929b4a686d 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -43,6 +43,7 @@ from litellm.types.llms.openai import ( AllMessageValues, ChatCompletionAnnotation, ChatCompletionAssistantMessage, + ChatCompletionAssistantToolCall, ChatCompletionRedactedThinkingBlock, ChatCompletionResponseMessage, ChatCompletionSystemMessage, @@ -191,6 +192,62 @@ class AmazonConverseConfig(BaseConfig): return messages_copy + @staticmethod + def _has_orphaned_tool_blocks(messages: List[AllMessageValues]) -> bool: + return any( + (m.get("role") == "assistant" and m.get("tool_calls")) + or m.get("role") in ("tool", "function") + for m in messages + ) + + @staticmethod + def _neutralize_orphaned_tool_blocks( + messages: List[AllMessageValues], optional_params: dict + ) -> List[AllMessageValues]: + if optional_params.get( + "tools" + ) or not AmazonConverseConfig._has_orphaned_tool_blocks(messages): + return messages + + from litellm.litellm_core_utils.prompt_templates.common_utils import ( + convert_content_list_to_str, + ) + + def _tool_call_text(tool_call: ChatCompletionAssistantToolCall) -> str: + function = tool_call.get("function") or {} + name = function.get("name") or "unknown_tool" + arguments = function.get("arguments") or "" + return f"[tool call: {name}({arguments})]" + + def _result_text(message: AllMessageValues) -> str: + rendered = convert_content_list_to_str(message).strip() + return rendered or "" + + def _rewrite(message: AllMessageValues) -> AllMessageValues: + role = message.get("role") + if role == "assistant" and message.get("tool_calls"): + base_text = convert_content_list_to_str(message) + call_texts = [_tool_call_text(call) for call in message["tool_calls"]] + text = "\n".join(filter(None, [base_text, *call_texts])) + return ChatCompletionAssistantMessage(role="assistant", content=text) + if role in ("tool", "function"): + tool_call_id = message.get("tool_call_id") + name = message.get("name") + label = f"tool result for {tool_call_id or name or 'unknown'}" + return ChatCompletionUserMessage( + role="user", + content=f"[{label}: {_result_text(message)}]", + ) + return message + + verbose_logger.warning( + "litellm.bedrock: request has tool blocks in message history but no " + "`tools=` param; neutralizing orphaned tool blocks to text so Bedrock " + "accepts the request without a toolConfig. Non-text tool-result " + "payloads are dropped. Pass `tools=` to preserve structured tool calling." + ) + return [_rewrite(message) for message in messages] + @classmethod def get_config(cls): return { diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index d940f9f47a6..383a8417aaf 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5428,3 +5428,157 @@ async def test_grounding_source_and_query_rendered_as_text(): user_content = result[0]["content"] assert {"text": "Tokyo is the capital of Japan."} in user_content assert {"text": "What is the capital of Japan?"} in user_content + + +def _orphaned_tool_history_messages(): + return [ + {"role": "user", "content": "What's the weather in Paris?"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"city": "Paris"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_abc", + "content": "Sunny, 25C", + }, + {"role": "user", "content": "Summarize our conversation so far."}, + ] + + +def test_neutralize_orphaned_tool_blocks_rewrites_when_no_tools(): + """No tools= but history has tool blocks: assistant tool_calls and the tool + result must be rewritten to text, with the structured tool fields gone and + tool_call_id preserved, so Bedrock accepts the request without a toolConfig + (#24158, #27138).""" + messages = _orphaned_tool_history_messages() + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={} + ) + + serialized = json.dumps(result) + assert "tool_calls" not in serialized + assert not any(m.get("role") in ("tool", "function") for m in result) + assert "get_weather" in serialized + # The arguments string contains quotes; after json.dumps the literal + # '{"city": "Paris"}' is escaped, so assert on quote-free tokens that survive. + assert "city" in serialized and "Paris" in serialized + assert "Sunny, 25C" in serialized + assert "call_abc" in serialized # tool_call_id correlation preserved + + +@pytest.mark.parametrize("tools_value", [[], None]) +def test_neutralize_orphaned_tool_blocks_rewrites_when_tools_empty(tools_value): + """tools=[] and tools=None are 'no usable tools'; the gate must be on + truthiness, not key presence, or these slip through and still emit + structured tool blocks with no toolConfig.""" + messages = _orphaned_tool_history_messages() + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={"tools": tools_value} + ) + + serialized = json.dumps(result) + assert "tool_calls" not in serialized + assert "get_weather" in serialized + + +def test_neutralize_orphaned_tool_blocks_rewrites_tool_result_only_history(): + """A role:"tool"-only history (no assistant tool_calls) must also be + neutralized; has_tool_call_blocks misses this, but the factory still emits a + lone toolResult with no toolConfig.""" + messages = [ + {"role": "user", "content": "hi"}, + {"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"}, + ] + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={} + ) + + assert not any(m.get("role") in ("tool", "function") for m in result) + serialized = json.dumps(result) + assert "lookup result" in serialized + assert "call_xyz" in serialized + + +def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty(): + """Non-text tool-result payloads (image/file) collapse to an explicit + marker, never an empty string (Bedrock rejects empty text blocks) and never + a silent drop.""" + messages = [ + {"role": "user", "content": "hi"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + {"id": "c1", "type": "function", + "function": {"name": "render", "arguments": "{}"}} + ], + }, + { + "role": "tool", + "tool_call_id": "c1", + "content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}}], + }, + ] + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={} + ) + + rewritten = next(m for m in result if m.get("role") == "user" and m is not messages[0]) + text = rewritten["content"] + assert text.strip() # never empty + assert "non-text tool result omitted" in text + + +def test_neutralize_orphaned_tool_blocks_noop_when_tools_present(): + """When a non-empty tools= is provided, tool blocks are legitimate and must + be left untouched (returns the same object, no rewriting).""" + messages = _orphaned_tool_history_messages() + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, + optional_params={"tools": [{"type": "function", "function": {"name": "x"}}]}, + ) + + assert result is messages + + +def test_neutralize_orphaned_tool_blocks_noop_when_no_tool_history(): + """Plain conversation with no tool blocks is returned unchanged.""" + messages = [{"role": "user", "content": "hi"}] + + result = AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={} + ) + + assert result is messages + + +def test_neutralize_orphaned_tool_blocks_logs_warning(caplog): + """Neutralization must surface at WARNING level so a developer who forgot + tools= sees it instead of a silent degrade.""" + messages = _orphaned_tool_history_messages() + + with caplog.at_level("WARNING"): + AmazonConverseConfig._neutralize_orphaned_tool_blocks( + messages, optional_params={} + ) + + assert any( + "neutralizing orphaned tool blocks" in record.getMessage() + for record in caplog.records + )