fix(bedrock): neutralize orphaned tool blocks instead of raising or injecting dummy tool (#24158, #27138)

This commit is contained in:
Kent 2026-06-25 00:24:58 +08:00
parent a5b465b588
commit 600924af32
3 changed files with 183 additions and 27 deletions

View file

@ -66,9 +66,7 @@ from litellm.types.utils import (
Usage,
)
from litellm.utils import (
add_dummy_tool,
any_assistant_message_has_thinking_blocks,
has_tool_call_blocks,
last_assistant_with_tool_calls_has_no_thinking_blocks,
supports_reasoning,
token_counter,
@ -1647,26 +1645,6 @@ class AmazonConverseConfig(BaseConfig):
headers: Optional[dict] = None,
drop_params: bool = False,
) -> CommonRequestObject:
## VALIDATE REQUEST
"""
Bedrock doesn't support tool calling without `tools=` param specified.
"""
if (
"tools" not in optional_params
and messages is not None
and has_tool_call_blocks(messages)
):
if litellm.modify_params:
optional_params["tools"] = add_dummy_tool(
custom_llm_provider="bedrock_converse"
)
else:
raise litellm.UnsupportedParamsError(
message="Bedrock doesn't support tool calling without `tools=` param specified. Pass `tools=` param OR set `litellm.modify_params = True` // `litellm_settings::modify_params: True` to add dummy tool to the request.",
model="",
llm_provider="bedrock",
)
# Drop thinking param if thinking is enabled but thinking_blocks are missing
# This prevents the error: "Expected thinking or redacted_thinking, but found tool_use"
#
@ -1763,6 +1741,8 @@ class AmazonConverseConfig(BaseConfig):
messages, model=model
)
messages = self._neutralize_orphaned_tool_blocks(messages, optional_params)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(
messages, optional_params
@ -1822,6 +1802,8 @@ class AmazonConverseConfig(BaseConfig):
messages, model=model
)
messages = self._neutralize_orphaned_tool_blocks(messages, optional_params)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(
messages, optional_params

View file

@ -301,11 +301,11 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
@pytest.mark.parametrize(
"model, messages, expect_unsupported_params_error",
[
# Bedrock Converse still requires modify_params to inject the dummy tool.
# Bedrock Converse neutralizes orphaned tool blocks to text; no error.
(
"anthropic.claude-3-sonnet-20240229-v1:0",
_PARALLEL_TOOL_HISTORY_MESSAGES,
True,
False,
),
# Anthropic Messages API: dummy tool is injected without modify_params.
(
@ -341,12 +341,12 @@ def test_parallel_function_call_anthropic_error_msg(
"""
Tool history without an explicit ``tools`` param:
- Bedrock **Converse** still raises ``UnsupportedParamsError`` unless
``litellm.modify_params`` is enabled (dummy tool is only added there).
- Bedrock **Converse** neutralizes the orphaned tool blocks into plain text
and sends no ``toolConfig`` (see #24158, #27138). It no longer raises.
- **Anthropic** (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
always get a dummy tool so CLIs work with ``modify_params`` left off.
Reference Issue: https://github.com/BerriAI/litellm/issues/5747, https://github.com/BerriAI/litellm/issues/5388
Reference Issue: https://github.com/BerriAI/litellm/issues/24158, https://github.com/BerriAI/litellm/issues/27138
"""
# Ensure modify_params is False so Bedrock Converse path still raises.
# (other tests in this file set it to True and don't reset it)

View file

@ -5582,3 +5582,177 @@ def test_neutralize_orphaned_tool_blocks_logs_warning(caplog):
"neutralizing orphaned tool blocks" in record.getMessage()
for record in caplog.records
)
def _assert_no_structured_tool_blocks(result):
"""A valid Bedrock body for a neutralized request has no tool config AND no
structured tool blocks in messages. Checking only toolConfig is insufficient:
deleting the raise without rewriting still leaves toolUse/toolResult, the
exact shape Bedrock rejects."""
assert "toolConfig" not in result
serialized = json.dumps(result)
assert "toolUse" not in serialized
assert "toolResult" not in serialized
def test_transform_request_no_tools_with_tool_history_succeeds_24158(monkeypatch):
"""#24158: a compaction-style call (tool blocks in history, no tools=) must
not raise and must send no toolConfig or structured tool blocks, on
default settings."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
serialized = json.dumps(result)
assert "get_weather" in serialized
assert "Sunny, 25C" in serialized
def test_transform_request_tool_unsupported_model_no_toolconfig_27138(monkeypatch):
"""#27138: a tool-incapable model with tool blocks in history and no tools=
must not get a toolConfig/toolUse/toolResult injected (which Bedrock would
400 on), even with modify_params on."""
monkeypatch.setattr(litellm, "modify_params", True)
config = AmazonConverseConfig()
result = config.transform_request(
model="meta.llama3-2-3b-instruct-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
@pytest.mark.parametrize("tools_value", [[], None])
def test_transform_request_empty_tools_with_tool_history(monkeypatch, tools_value):
"""tools=[] / tools=None must be neutralized like no tools at all; a
key-presence gate would skip them and emit toolUse/toolResult with no
toolConfig."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={"tools": tools_value},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
def test_transform_request_tool_result_only_history(monkeypatch):
"""A role:"tool"-only history (no assistant tool_calls) currently emits a
lone toolResult with no toolConfig; it must be neutralized."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=[
{"role": "user", "content": "hi"},
{"role": "tool", "tool_call_id": "call_xyz", "content": "lookup result"},
],
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
assert "lookup result" in json.dumps(result)
def test_transform_request_neutralized_tool_output_is_guarded(monkeypatch):
"""With guardrailConfig present, a neutralized tool result that becomes the
trailing user turn must be emitted as guardContent, not plain text, so
untrusted tool output does not bypass the guardrail (neutralize must run
before guarded-text conversion)."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=[
{"role": "user", "content": "look it up"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "c1",
"type": "function",
"function": {"name": "lookup", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "c1", "content": "secret tool output"},
],
optional_params={
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
serialized = json.dumps(result)
assert "guardContent" in serialized
assert "secret tool output" in serialized
@pytest.mark.asyncio
async def test_async_transform_request_no_tools_with_tool_history(monkeypatch):
"""Async is a separate request assembler; it must neutralize identically."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = await config._async_transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
assert "get_weather" in json.dumps(result)
def test_transform_request_with_tools_still_builds_toolconfig(monkeypatch):
"""Guard: when a non-empty tools= IS provided, tool blocks are legitimate and
a toolConfig must still be produced (neutralization must not regress this)."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
result = config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}},
},
}
]
},
litellm_params={},
headers={},
)
assert "toolConfig" in result