From 600924af329bf67ff29d0d4c19b1b63eb6e4839a Mon Sep 17 00:00:00 2001 From: Kent Date: Thu, 25 Jun 2026 00:24:58 +0800 Subject: [PATCH] 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