mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(bedrock): add helper to neutralize orphaned tool blocks
This commit is contained in:
parent
e5da5a3b6d
commit
a5b465b588
2 changed files with 211 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue