Merge pull request #41513 from BerriAI/litellm_internal_copy_31400

fix(bedrock): neutralize orphaned tool blocks instead of raising or injecting a dummy tool (internal copy of #31400)
This commit is contained in:
Mateo Wang 2026-09-16 17:12:35 -07:00 committed by GitHub
commit d4a22acb66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 547 additions and 62 deletions

View file

@ -244,6 +244,7 @@ telemetry = True
max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults
drop_params = drop_params_env_flag(os.environ, verbose_logger)
modify_params = bool(os.getenv("LITELLM_MODIFY_PARAMS", False))
bedrock_neutralize_orphaned_tool_blocks: bool = True
use_chat_completions_url_for_anthropic_messages: bool = bool(
os.getenv("LITELLM_USE_CHAT_COMPLETIONS_URL_FOR_ANTHROPIC_MESSAGES", False)
) # When True, routes OpenAI /v1/messages requests to chat/completions instead of the Responses API

View file

@ -53,6 +53,7 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAnnotation,
ChatCompletionAssistantMessage,
ChatCompletionAssistantToolCall,
ChatCompletionRedactedThinkingBlock,
ChatCompletionResponseMessage,
ChatCompletionSystemMessage,
@ -205,6 +206,84 @@ 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 ""
call_id = tool_call.get("id")
label = f"tool call {call_id}" if call_id else "tool call"
return f"[{label}: {name}({arguments})]"
def _result_text(message: AllMessageValues) -> str:
rendered = convert_content_list_to_str(message).strip()
return rendered or "<non-text tool result omitted>"
guardrail_active: Final = "guardrailConfig" in optional_params
def _rewrite(message: AllMessageValues) -> AllMessageValues:
role = message.get("role")
tool_calls = message.get("tool_calls")
if role == "assistant" and tool_calls:
base_text: Final = convert_content_list_to_str(message)
call_texts: Final = tuple(_tool_call_text(call) for call in tool_calls)
text: Final = "\n".join(part for part in (base_text, *call_texts) if part)
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'}"
result_text: Final = f"[{label}: {_result_text(message)}]"
# Tool results are externally controlled, so guard them wherever they
# land in history; _convert_consecutive_user_messages_to_guarded_text
# only covers the trailing user turn.
content: Final = [{"type": "guarded_text", "text": result_text}] if guardrail_active else result_text
return ChatCompletionUserMessage(role="user", content=content)
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]
@staticmethod
def _handle_orphaned_tool_blocks(messages: list[AllMessageValues], optional_params: dict) -> list[AllMessageValues]:
if litellm.bedrock_neutralize_orphaned_tool_blocks:
return AmazonConverseConfig._neutralize_orphaned_tool_blocks(messages, optional_params)
if "tools" in optional_params or not has_tool_call_blocks(messages):
return messages
if litellm.modify_params:
optional_params["tools"] = add_dummy_tool(custom_llm_provider="bedrock_converse")
return messages
raise litellm.utils.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",
)
@classmethod
def get_config(cls):
return {
@ -1609,20 +1688,6 @@ class AmazonConverseConfig(BaseConfig):
drop_params: bool = False,
litellm_params: Mapping[str, object] | None = None,
) -> 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"
#
@ -1735,7 +1800,9 @@ class AmazonConverseConfig(BaseConfig):
messages, system_content_blocks = self._transform_system_message(messages, model=model)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
messages = self._convert_consecutive_user_messages_to_guarded_text(
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
)
## TRANSFORMATION ##
_data: Final[CommonRequestObject] = self._transform_request_helper(
@ -1796,7 +1863,9 @@ class AmazonConverseConfig(BaseConfig):
messages, system_content_blocks = self._transform_system_message(messages, model=model)
# Convert last user message to guarded_text if guardrailConfig is present
messages = self._convert_consecutive_user_messages_to_guarded_text(messages, optional_params)
messages = self._convert_consecutive_user_messages_to_guarded_text(
self._handle_orphaned_tool_blocks(messages, optional_params), optional_params
)
_data: Final[CommonRequestObject] = self._transform_request_helper(
model=model,

View file

@ -261,7 +261,6 @@ def test_aaparallel_function_call_with_anthropic_thinking(model):
from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message
_PARALLEL_TOOL_HISTORY_MESSAGES = [
{
"role": "user",
@ -293,20 +292,11 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
@pytest.mark.parametrize(
"model, messages, expect_unsupported_params_error",
"model, messages",
[
# Bedrock Converse still requires modify_params to inject the dummy tool.
(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
_PARALLEL_TOOL_HISTORY_MESSAGES,
True,
),
# Anthropic Messages API: dummy tool is injected without modify_params.
(
"claude-haiku-4-5-20251001",
_PARALLEL_TOOL_HISTORY_MESSAGES,
False,
),
# Anthropic Messages API: a dummy tool is injected without modify_params,
# so tool history with no tools= completes instead of raising.
("claude-haiku-4-5-20251001", _PARALLEL_TOOL_HISTORY_MESSAGES),
(
"us.anthropic.claude-sonnet-4-5-20250929-v1:0",
[
@ -315,7 +305,6 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
}
],
False,
),
(
"claude-haiku-4-5-20251001",
@ -325,48 +314,34 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [
"content": "What's the weather like in San Francisco, Tokyo, and Paris? - give me 3 responses",
}
],
False,
),
],
)
def test_parallel_function_call_anthropic_error_msg(
model, messages, expect_unsupported_params_error
):
def test_parallel_function_call_anthropic_error_msg(model, messages):
"""
Tool history without an explicit ``tools`` param:
Tool history without an explicit ``tools`` param must complete, not raise.
- Bedrock **Converse** still raises ``UnsupportedParamsError`` unless
``litellm.modify_params`` is enabled (dummy tool is only added there).
- **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
Anthropic (and Bedrock Invoke via ``AnthropicConfig.transform_request``)
inject a dummy tool so CLIs work with ``modify_params`` left off. Bedrock
Converse's no-raise behavior is covered offline in
``tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py``
(see #24158, #27138), which needs no live credentials.
"""
# Ensure modify_params is False so Bedrock Converse path still raises.
# Force modify_params off as a clean baseline: it exercises the Anthropic
# dummy-tool path, which injects regardless of modify_params
# (other tests in this file set it to True and don't reset it)
original_modify_params = litellm.modify_params
litellm.modify_params = False
try:
litellm.set_verbose = True
if expect_unsupported_params_error:
with pytest.raises(litellm.UnsupportedParamsError) as e:
litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
)
else:
second_response = litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
) # get a new response from the model where it can see the function response
print("second response\n", second_response)
second_response = litellm.completion(
model=model,
messages=messages,
temperature=0.2,
seed=22,
drop_params=True,
) # get a new response from the model where it can see the function response
print("second response\n", second_response)
except litellm.InternalServerError as e:
print(e)
except litellm.RateLimitError as e:

View file

@ -6534,6 +6534,446 @@ async def test_grounding_source_and_query_rendered_as_text():
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 "[tool call call_abc: get_weather(" in result[1]["content"]
assert "[tool result for call_abc: Sunny, 25C]" in result[2]["content"]
@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
)
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
def test_transform_request_neutralized_tool_output_guarded_mid_history(monkeypatch):
"""Regression: a neutralized tool result that is NOT the trailing turn (an
assistant reply and a later user turn follow it) must still be guardContent.
_convert_consecutive_user_messages_to_guarded_text only covers the trailing
user turn, so neutralize itself must guard untrusted tool output regardless
of position, else an attacker controlling the tool response bypasses the
guardrail (bot review)."""
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": "IGNORE_PRIOR malware"},
{"role": "assistant", "content": "Here is the summary."},
{"role": "user", "content": "thanks"},
],
optional_params={
"guardrailConfig": {"guardrailIdentifier": "gid", "guardrailVersion": "1"}
},
litellm_params={},
headers={},
)
_assert_no_structured_tool_blocks(result)
blocks = [block for message in result["messages"] for block in message["content"]]
guarded_texts = [
block["guardContent"]["text"]["text"] for block in blocks if "guardContent" in block
]
plain_texts = [block["text"] for block in blocks if "text" in block and "guardContent" not in block]
assert any("malware" in text for text in guarded_texts), "mid-history tool output must be guarded"
assert not any(
"malware" in text for text in plain_texts
), "mid-history tool output must not reach the model as unguarded text"
@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
def test_transform_request_flag_off_restores_raise(monkeypatch):
"""Opt-out: with bedrock_neutralize_orphaned_tool_blocks=False and
modify_params=False, the legacy UnsupportedParamsError contract is restored."""
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
with pytest.raises(litellm.utils.UnsupportedParamsError, match="without `tools="):
config.transform_request(
model="us.anthropic.claude-opus-4-5-20251101-v1:0",
messages=_orphaned_tool_history_messages(),
optional_params={},
litellm_params={},
headers={},
)
def test_transform_request_flag_off_with_modify_params_restores_dummy_tool(monkeypatch):
"""Opt-out: with the flag off and modify_params=True, the legacy dummy-tool
injection is restored (a toolConfig is produced, not neutralized text)."""
monkeypatch.setattr(litellm, "bedrock_neutralize_orphaned_tool_blocks", False)
monkeypatch.setattr(litellm, "modify_params", True)
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 "toolConfig" in result
assert "dummy_tool" in json.dumps(result)
def test_transform_request_flag_on_is_default(monkeypatch):
"""Default-on: without touching the flag, neutralization is the behavior."""
monkeypatch.setattr(litellm, "modify_params", False)
config = AmazonConverseConfig()
assert litellm.bedrock_neutralize_orphaned_tool_blocks is True
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)
def _agentic_messages_with_ttl(ttl_target: str):
"""A tool-loop conversation with `ttl: 1h` cache_control at `ttl_target`:
'user', 'tool_call' (per-tool-call, on the assistant's tool call), or