From a5b465b5884bbeccd825c68368d1945c937c7a69 Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 25 Jun 2026 00:13:36 +0800 Subject: [PATCH 1/9] 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 + ) From 600924af329bf67ff29d0d4c19b1b63eb6e4839a Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 25 Jun 2026 00:24:58 +0800 Subject: [PATCH 2/9] fix(bedrock): neutralize orphaned tool blocks instead of raising or injecting dummy tool (#24158, #27138) --- .../bedrock/chat/converse_transformation.py | 26 +-- tests/local_testing/test_function_calling.py | 10 +- .../chat/test_converse_transformation.py | 174 ++++++++++++++++++ 3 files changed, 183 insertions(+), 27 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 4929b4a686d..79d5ac51fa8 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -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 diff --git a/tests/local_testing/test_function_calling.py b/tests/local_testing/test_function_calling.py index 3c7e004b62e..b51da95c4f0 100644 --- a/tests/local_testing/test_function_calling.py +++ b/tests/local_testing/test_function_calling.py @@ -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) 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 383a8417aaf..8750d587dda 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -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 From edb7ec230ee6c42a5ccbbdbef53abf96e8d69d3f Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 25 Jun 2026 00:40:34 +0800 Subject: [PATCH 3/9] chore(bedrock): fix stale test comment; modernize types and narrow tool_calls for lint Fix the now-inaccurate comment in test_parallel_function_call_anthropic_error_msg (Bedrock Converse no longer raises; modify_params is forced off as a clean baseline that exercises the Anthropic dummy-tool path). Switch the new helper's annotations to builtin list[...] to satisfy the ruff-strict UP006 ceiling, and bind message.get("tool_calls") to a local so basedpyright narrows the union instead of tripping reportGeneralTypeIssues. Both keep the lint budgets ratcheting down rather than bumping ceilings. --- litellm/llms/bedrock/chat/converse_transformation.py | 11 ++++++----- tests/local_testing/test_function_calling.py | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 79d5ac51fa8..5781b97ce00 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -191,7 +191,7 @@ class AmazonConverseConfig(BaseConfig): return messages_copy @staticmethod - def _has_orphaned_tool_blocks(messages: List[AllMessageValues]) -> bool: + 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") @@ -200,8 +200,8 @@ class AmazonConverseConfig(BaseConfig): @staticmethod def _neutralize_orphaned_tool_blocks( - messages: List[AllMessageValues], optional_params: dict - ) -> List[AllMessageValues]: + messages: list[AllMessageValues], optional_params: dict + ) -> list[AllMessageValues]: if optional_params.get( "tools" ) or not AmazonConverseConfig._has_orphaned_tool_blocks(messages): @@ -223,9 +223,10 @@ class AmazonConverseConfig(BaseConfig): def _rewrite(message: AllMessageValues) -> AllMessageValues: role = message.get("role") - if role == "assistant" and message.get("tool_calls"): + tool_calls = message.get("tool_calls") + if role == "assistant" and tool_calls: base_text = convert_content_list_to_str(message) - call_texts = [_tool_call_text(call) for call in message["tool_calls"]] + call_texts = [_tool_call_text(call) for call in tool_calls] text = "\n".join(filter(None, [base_text, *call_texts])) return ChatCompletionAssistantMessage(role="assistant", content=text) if role in ("tool", "function"): diff --git a/tests/local_testing/test_function_calling.py b/tests/local_testing/test_function_calling.py index b51da95c4f0..e0984cd4311 100644 --- a/tests/local_testing/test_function_calling.py +++ b/tests/local_testing/test_function_calling.py @@ -348,7 +348,8 @@ def test_parallel_function_call_anthropic_error_msg( 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. + # 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 From 989f5e06f5858aaec59f039e4f206b46a5d3ca5a Mon Sep 17 00:00:00 2001 From: Kent Date: Fri, 26 Jun 2026 12:34:06 +0800 Subject: [PATCH 4/9] feat(bedrock): add opt-out flag for orphaned-tool-block neutralization Address Greptile P1 review feedback on PR #31400. P1 (backward compat): gate neutralization behind a new litellm.bedrock_neutralize_orphaned_tool_blocks flag (default True, so both bugs stay fixed). Setting it False restores the legacy contract: raise UnsupportedParamsError, or inject a dummy tool under modify_params. A new _handle_orphaned_tool_blocks dispatcher selects the path; the pure _neutralize_orphaned_tool_blocks helper is unchanged. Adds tests for flag-off-raises, flag-off-with-modify_params-injects, and default-on. P1 (CI credentials): the live-network test_parallel_function_call_anthropic_error_msg no longer needs Bedrock creds for the Converse case. That case is dropped (its no-raise behavior is already covered offline in test_converse_transformation.py); the now-unused expect_unsupported_params_error param and dead pytest.raises branch are removed. --- litellm/__init__.py | 4 ++ .../bedrock/chat/converse_transformation.py | 30 +++++++- tests/local_testing/test_function_calling.py | 65 +++++------------ .../chat/test_converse_transformation.py | 71 +++++++++++++++++-- 4 files changed, 118 insertions(+), 52 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 5ae5942f32f..4b10130326e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -242,6 +242,10 @@ telemetry = True max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults drop_params = bool(os.getenv("LITELLM_DROP_PARAMS", False)) modify_params = bool(os.getenv("LITELLM_MODIFY_PARAMS", False)) +bedrock_neutralize_orphaned_tool_blocks = ( + os.getenv("LITELLM_BEDROCK_NEUTRALIZE_ORPHANED_TOOL_BLOCKS", "true").lower() + == "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 diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 5781b97ce00..4bdba492ee5 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -66,7 +66,9 @@ 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, @@ -247,6 +249,30 @@ class AmazonConverseConfig(BaseConfig): ) 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 { @@ -1742,7 +1768,7 @@ class AmazonConverseConfig(BaseConfig): messages, model=model ) - messages = self._neutralize_orphaned_tool_blocks(messages, optional_params) + messages = self._handle_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( @@ -1803,7 +1829,7 @@ class AmazonConverseConfig(BaseConfig): messages, model=model ) - messages = self._neutralize_orphaned_tool_blocks(messages, optional_params) + messages = self._handle_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( diff --git a/tests/local_testing/test_function_calling.py b/tests/local_testing/test_function_calling.py index e0984cd4311..3d32e2e24f9 100644 --- a/tests/local_testing/test_function_calling.py +++ b/tests/local_testing/test_function_calling.py @@ -267,7 +267,6 @@ def test_aaparallel_function_call_with_anthropic_thinking(model): from litellm.types.utils import ChatCompletionMessageToolCall, Function, Message - _PARALLEL_TOOL_HISTORY_MESSAGES = [ { "role": "user", @@ -299,20 +298,11 @@ _PARALLEL_TOOL_HISTORY_MESSAGES = [ @pytest.mark.parametrize( - "model, messages, expect_unsupported_params_error", + "model, messages", [ - # Bedrock Converse neutralizes orphaned tool blocks to text; no error. - ( - "anthropic.claude-3-sonnet-20240229-v1:0", - _PARALLEL_TOOL_HISTORY_MESSAGES, - False, - ), - # 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), ( "anthropic.claude-3-sonnet-20240229-v1:0", [ @@ -321,7 +311,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", @@ -331,22 +320,18 @@ _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** 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/24158, https://github.com/BerriAI/litellm/issues/27138 + 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. """ # Force modify_params off as a clean baseline: it exercises the Anthropic # dummy-tool path, which injects regardless of modify_params @@ -355,26 +340,14 @@ def test_parallel_function_call_anthropic_error_msg( litellm.modify_params = False try: litellm.set_verbose = True - - if expect_unsupported_params_error: - with pytest.raises(litellm.UnsupportedParamsError) as e: - 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) - 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: 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 8750d587dda..e608c2c73b6 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5523,14 +5523,22 @@ def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty(): "role": "assistant", "content": None, "tool_calls": [ - {"id": "c1", "type": "function", - "function": {"name": "render", "arguments": "{}"}} + { + "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"}}], + "content": [ + { + "type": "image_url", + "image_url": {"url": "data:image/png;base64,AAAA"}, + } + ], }, ] @@ -5538,7 +5546,9 @@ def test_neutralize_orphaned_tool_blocks_non_text_result_marked_not_empty(): messages, optional_params={} ) - rewritten = next(m for m in result if m.get("role") == "user" and m is not messages[0]) + 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 @@ -5756,3 +5766,56 @@ def test_transform_request_with_tools_still_builds_toolconfig(monkeypatch): ) 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) From 681e75bf307284c51a40ee25e538a1e5b8a3a30c Mon Sep 17 00:00:00 2001 From: Kent Date: Fri, 26 Jun 2026 13:21:33 +0800 Subject: [PATCH 5/9] fix(bedrock): drop env-var backing for neutralize flag to satisfy doc CI The documentation_test_env_keys check requires every os.getenv() key to be documented in litellm-docs (a separate repo), which would create a cross-repo merge-order dependency. The flag does not need an env var: make it a plain module attribute (litellm.bedrock_neutralize_orphaned_tool_blocks = True), still overridable in Python or via proxy litellm_settings (which setattrs litellm module attrs). Default unchanged (True). --- litellm/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 4b10130326e..d8166280c68 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -242,10 +242,7 @@ telemetry = True max_tokens: int = DEFAULT_MAX_TOKENS # OpenAI Defaults drop_params = bool(os.getenv("LITELLM_DROP_PARAMS", False)) modify_params = bool(os.getenv("LITELLM_MODIFY_PARAMS", False)) -bedrock_neutralize_orphaned_tool_blocks = ( - os.getenv("LITELLM_BEDROCK_NEUTRALIZE_ORPHANED_TOOL_BLOCKS", "true").lower() - == "true" -) +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 From 5493be743b1487e28c08868f5e8e920a3a8d2f7a Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 12 Sep 2026 09:33:07 +0800 Subject: [PATCH 6/9] refactor(bedrock): fold orphaned-tool handling into existing rebind to satisfy LIT011 budget --- litellm/llms/bedrock/chat/converse_transformation.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index d0d97ecb7f0..4c618b0ba83 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1793,10 +1793,10 @@ class AmazonConverseConfig(BaseConfig): ) -> RequestObject: messages, system_content_blocks = self._transform_system_message(messages, model=model) - messages = self._handle_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) + 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( @@ -1856,10 +1856,10 @@ class AmazonConverseConfig(BaseConfig): ) -> RequestObject: messages, system_content_blocks = self._transform_system_message(messages, model=model) - messages = self._handle_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) + 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, From 04086be7955615a2498ab36e1f6539447be9ab73 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 12 Sep 2026 09:51:01 +0800 Subject: [PATCH 7/9] fix(bedrock): guard neutralized tool results anywhere in history when guardrailConfig is set --- .../bedrock/chat/converse_transformation.py | 18 ++++--- .../chat/test_converse_transformation.py | 48 +++++++++++++++++++ 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 4c618b0ba83..e44dece3a8b 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -234,22 +234,28 @@ class AmazonConverseConfig(BaseConfig): rendered = convert_content_list_to_str(message).strip() return rendered or "" + 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 = convert_content_list_to_str(message) - call_texts = [_tool_call_text(call) for call in tool_calls] - text = "\n".join(filter(None, [base_text, *call_texts])) + 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'}" - return ChatCompletionUserMessage( - role="user", - content=f"[{label}: {_result_text(message)}]", + 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( 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 ca550530369..21e8e94d871 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -6714,6 +6714,54 @@ def test_transform_request_neutralized_tool_output_is_guarded(monkeypatch): 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.""" From e250e661e36f217f338e0fe2e4e51415a45847d5 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 12 Sep 2026 10:02:36 +0800 Subject: [PATCH 8/9] style(bedrock): apply ruff format to neutralize helper --- litellm/llms/bedrock/chat/converse_transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index e44dece3a8b..33a77e8d346 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -252,9 +252,7 @@ class AmazonConverseConfig(BaseConfig): # 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 - ) + content: Final = [{"type": "guarded_text", "text": result_text}] if guardrail_active else result_text return ChatCompletionUserMessage(role="user", content=content) return message From 610249c66341ce69d68b2e70b4d35b3df9934cef Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:08:46 -0700 Subject: [PATCH 9/9] fix(bedrock): carry the tool_call_id into the rewritten tool call text --- litellm/llms/bedrock/chat/converse_transformation.py | 4 +++- .../llms/bedrock/chat/test_converse_transformation.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 33a77e8d346..7c2dadbc970 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -228,7 +228,9 @@ class AmazonConverseConfig(BaseConfig): function = tool_call.get("function") or {} name = function.get("name") or "unknown_tool" arguments = function.get("arguments") or "" - return f"[tool call: {name}({arguments})]" + 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() 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 21e8e94d871..9ecca87134b 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -6467,7 +6467,8 @@ def test_neutralize_orphaned_tool_blocks_rewrites_when_no_tools(): # '{"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 + 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])