feat(bedrock): add helper to neutralize orphaned tool blocks

This commit is contained in:
Kent 2026-06-25 00:13:36 +08:00
parent e5da5a3b6d
commit a5b465b588
2 changed files with 211 additions and 0 deletions

View file

@ -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 "<non-text tool result omitted>"
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 {

View file

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