From 38036830a509098b73362bb9cbd06baad6391388 Mon Sep 17 00:00:00 2001 From: Daoyuan Li <94409450+DaoyuanLi2816@users.noreply.github.com> Date: Mon, 18 May 2026 19:54:20 -0700 Subject: [PATCH] fix(bedrock): normalize invoke tool search tools for messages API --- .../anthropic_claude3_transformation.py | 27 +- litellm/llms/bedrock/common_utils.py | 48 ++++ .../anthropic_claude3_transformation.py | 29 +++ .../test_anthropic_claude3_transformation.py | 241 +++++++++++++++++- 4 files changed, 315 insertions(+), 30 deletions(-) diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index c883ab68dff..d48ec33828b 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -16,6 +16,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation ) from litellm.llms.bedrock.common_utils import ( get_anthropic_beta_from_headers, + normalize_bedrock_invoke_tool_search_tools, normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) @@ -297,28 +298,12 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): def _normalize_bedrock_tool_search_tools(self, optional_params: dict) -> dict: """ Convert tool search entries to the format supported by the Bedrock Invoke API. + + Delegates to ``normalize_bedrock_invoke_tool_search_tools`` so the + ``/v1/messages`` Bedrock handler can reuse the same normalization. See + that helper for the full rewrite/drop rules. """ - tools = optional_params.get("tools") - if not tools or not isinstance(tools, list): - return optional_params - - normalized_tools = [] - for tool in tools: - tool_type = tool.get("type") - if tool_type == "tool_search_tool_bm25_20251119": - # Bedrock Invoke does not support the BM25 variant, so skip it. - continue - if tool_type == "tool_search_tool_regex_20251119": - normalized_tool = tool.copy() - normalized_tool["type"] = "tool_search_tool_regex" - normalized_tool["name"] = normalized_tool.get( - "name", "tool_search_tool_regex" - ) - normalized_tools.append(normalized_tool) - continue - normalized_tools.append(tool) - - optional_params["tools"] = normalized_tools + normalize_bedrock_invoke_tool_search_tools(optional_params) return optional_params def transform_response( diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 0256d5d4b95..d97028343de 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -153,6 +153,54 @@ def ensure_bedrock_anthropic_messages_tool_names(request_body: dict) -> None: tool["name"] = f"litellm_unnamed_tool_{i}" +def normalize_bedrock_invoke_tool_search_tools(request_body: dict) -> None: + """ + Bedrock Invoke does not accept Anthropic's dated tool-search server-side tool + types. Pydantic's tool-type discriminator rejects the request client-side with + ``Input tag 'tool_search_tool_regex_20251119' ... does not match any of the + expected tags`` before the call ever reaches Bedrock. + + In-place normalization: + + - ``tool_search_tool_regex_20251119`` is rewritten to the SDK-version-bare + ``tool_search_tool_regex`` (the canonical ``name`` Anthropic and Bedrock + both accept on the wire). ``name`` defaults to ``tool_search_tool_regex`` + when missing. + - ``tool_search_tool_bm25_20251119`` is dropped entirely. Bedrock Invoke does + not support the BM25 variant of Anthropic tool-search. + - All other tool entries pass through unchanged. + + Args: + request_body: The request dictionary to modify in-place. + + Ref: https://github.com/BerriAI/litellm/issues/28083 + """ + tools = request_body.get("tools") + if not tools or not isinstance(tools, list): + return + + normalized_tools: List[Any] = [] + for tool in tools: + if not isinstance(tool, dict): + normalized_tools.append(tool) + continue + tool_type = tool.get("type") + if tool_type == "tool_search_tool_bm25_20251119": + # Bedrock Invoke does not support the BM25 variant, so skip it. + continue + if tool_type == "tool_search_tool_regex_20251119": + normalized_tool = tool.copy() + normalized_tool["type"] = "tool_search_tool_regex" + normalized_tool["name"] = normalized_tool.get( + "name", "tool_search_tool_regex" + ) + normalized_tools.append(normalized_tool) + continue + normalized_tools.append(tool) + + request_body["tools"] = normalized_tools + + class AmazonBedrockGlobalConfig: def __init__(self): pass diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 151e0e404a0..499fefa0ed2 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -35,6 +35,7 @@ from litellm.llms.bedrock.common_utils import ( ensure_bedrock_anthropic_messages_tool_names, get_anthropic_beta_from_headers, is_claude_4_5_on_bedrock, + normalize_bedrock_invoke_tool_search_tools, normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) @@ -563,6 +564,14 @@ class AmazonAnthropicClaudeMessagesConfig( # Ref: https://github.com/BerriAI/litellm/issues/22847 remove_custom_field_from_tools(anthropic_messages_request) normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_messages_request) + # Rewrite/drop Anthropic tool-search server-side tool types so Bedrock + # Invoke's Pydantic tool-type discriminator does not reject + # ``tool_search_tool_regex_20251119`` client-side. Runs before + # ``ensure_bedrock_anthropic_messages_tool_names`` so the canonical + # ``tool_search_tool_regex`` name default is preserved rather than + # masked by the unnamed-tool fallback. + # Ref: https://github.com/BerriAI/litellm/issues/28083 + normalize_bedrock_invoke_tool_search_tools(anthropic_messages_request) ensure_bedrock_anthropic_messages_tool_names(anthropic_messages_request) # 6. AUTO-INJECT beta headers based on features used @@ -570,6 +579,26 @@ class AmazonAnthropicClaudeMessagesConfig( tools = anthropic_messages_optional_request_params.get("tools") messages_typed = cast(List[AllMessageValues], messages) tool_search_used = anthropic_model_info.is_tool_search_used(tools) + # Suppress tool-search beta injection when no tool-search tool + # survives normalization. ``normalize_bedrock_invoke_tool_search_tools`` + # rewrites ``tool_search_tool_regex_20251119`` to bare + # ``tool_search_tool_regex`` (kept) and drops + # ``tool_search_tool_bm25_20251119``. A non-empty tools list alone is + # insufficient — a BM25 entry sent alongside a regular function tool + # leaves a list with zero tool-search entries, which must still skip + # the ``tool-search-tool-2025-10-19`` beta. + # Ref: https://github.com/BerriAI/litellm/issues/28083 + if tool_search_used and not any( + isinstance(t, dict) + and t.get("type") + in { + "tool_search_tool_regex", + "tool_search_tool_regex_20251119", + "tool_search_tool_bm25_20251119", + } + for t in anthropic_messages_request.get("tools") or [] + ): + tool_search_used = False programmatic_tool_calling_used = ( anthropic_model_info.is_programmatic_tool_calling_used(tools) ) diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index 9ecdad1fcff..8483aa9a161 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -15,6 +15,7 @@ sys.path.insert(0, os.path.abspath("../../../../../..")) from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.llms.bedrock.common_utils import ( ensure_bedrock_anthropic_messages_tool_names, + normalize_bedrock_invoke_tool_search_tools, normalize_tool_input_schema_types_for_bedrock_invoke, remove_custom_field_from_tools, ) @@ -360,6 +361,234 @@ def test_ensure_bedrock_anthropic_messages_tool_names(): assert request["tools"][3]["name"] == "KeepMe" +def test_normalize_bedrock_invoke_tool_search_tools(): + """ + Bedrock Invoke does not accept Anthropic's dated tool-search server-side tool + types. Normalization must: + + - Rewrite ``tool_search_tool_regex_20251119`` to bare ``tool_search_tool_regex`` + and default ``name`` when missing. + - Drop ``tool_search_tool_bm25_20251119`` (Bedrock does not support BM25). + - Preserve a custom ``name`` on the regex tool. + - Leave every other tool entry untouched. + + Ref: https://github.com/BerriAI/litellm/issues/28083 + """ + + # Mixed payload: BM25 (drop), regex with default name, regex with custom + # name, plus a regular function tool that must pass through. + request = { + "tools": [ + {"type": "tool_search_tool_bm25_20251119"}, + {"type": "tool_search_tool_regex_20251119"}, + { + "type": "tool_search_tool_regex_20251119", + "name": "my_regex_search", + }, + { + "name": "Read", + "input_schema": {"type": "object", "properties": {}}, + }, + ] + } + + normalize_bedrock_invoke_tool_search_tools(request) + + assert len(request["tools"]) == 3, "BM25 tool should be dropped" + assert request["tools"][0]["type"] == "tool_search_tool_regex" + assert request["tools"][0]["name"] == "tool_search_tool_regex" + assert request["tools"][1]["type"] == "tool_search_tool_regex" + assert request["tools"][1]["name"] == "my_regex_search" + # Unrelated function tool passes through unchanged. + assert request["tools"][2] == { + "name": "Read", + "input_schema": {"type": "object", "properties": {}}, + } + + # Empty / missing / non-list ``tools`` values must not raise. + empty = {"messages": [{"role": "user", "content": "hi"}]} + normalize_bedrock_invoke_tool_search_tools(empty) + assert "tools" not in empty + + empty_list = {"tools": []} + normalize_bedrock_invoke_tool_search_tools(empty_list) + assert empty_list["tools"] == [] + + none_tools = {"tools": None} + normalize_bedrock_invoke_tool_search_tools(none_tools) + assert none_tools["tools"] is None + + # Non-dict tool entries must pass through unchanged (no crash). + weird = {"tools": ["not-a-dict", 42]} + normalize_bedrock_invoke_tool_search_tools(weird) + assert weird["tools"] == ["not-a-dict", 42] + + +def test_bedrock_invoke_messages_transform_normalizes_tool_search_regex(): + """ + Regression test for #28083: ``tool_search_tool_regex_20251119`` must be + rewritten to bare ``tool_search_tool_regex`` on the ``/v1/messages`` path + so Bedrock Invoke's Pydantic tool-type discriminator does not reject the + request client-side. Also locks in that the canonical + ``tool-search-tool-2025-10-19`` beta header is still injected for + tool-search-supported Bedrock Claude models, and that beta detection + reads the post-normalization tools (so ``is_tool_search_used`` sees + the rewritten regex entry rather than the dropped ``_20251119`` form). + + Before this fix the messages handler called every other tool-normalization + helper but skipped tool-search normalization, so any Anthropic tool-search + request via ``/v1/messages`` failed with ``Input tag + 'tool_search_tool_regex_20251119' ... does not match any of the expected + tags``. The chat-completions handler already normalized correctly. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 128, + "stream": False, + "tools": [ + {"type": "tool_search_tool_regex_20251119"}, + {"type": "tool_search_tool_bm25_20251119"}, + ], + } + + result = cfg.transform_anthropic_messages_request( + model="anthropic.claude-sonnet-4-6-v1:0", + messages=[{"role": "user", "content": "Search for foo"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + tool_types = [t["type"] for t in result["tools"]] + assert "tool_search_tool_regex_20251119" not in tool_types, ( + "Bedrock Invoke would reject the dated `_20251119` regex type " + "client-side; it must be rewritten to bare `tool_search_tool_regex`." + ) + assert ( + "tool_search_tool_bm25_20251119" not in tool_types + ), "Bedrock Invoke does not support the BM25 variant; it must be dropped." + assert tool_types == ["tool_search_tool_regex"] + assert result["tools"][0]["name"] == "tool_search_tool_regex" + assert "tool-search-tool-2025-10-19" in result.get("anthropic_beta", []), ( + "Beta-header detection must see the rewritten regex tool in the " + "post-normalization request body and inject the Bedrock-side beta." + ) + + +def test_bedrock_invoke_messages_transform_bm25_only_no_tool_search_beta(): + """ + BM25-only edge case: when a request supplies + ``tool_search_tool_bm25_20251119`` without any regex tool, + ``normalize_bedrock_invoke_tool_search_tools`` drops the BM25 entry + (Bedrock Invoke does not support BM25), so the normalized tools list + is empty. The ``tool-search-tool-2025-10-19`` beta header must NOT + be injected. + + Before this fix, beta detection read the pre-normalization + ``optional_params["tools"]`` (still containing BM25) and called + ``is_tool_search_used`` on it, so the beta header was added to a + request that ultimately carried no tool-search tools. Reading the + post-normalization tools instead keeps beta-header injection + consistent with what actually reaches Bedrock. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 128, + "stream": False, + "tools": [ + {"type": "tool_search_tool_bm25_20251119"}, + ], + } + + result = cfg.transform_anthropic_messages_request( + # Tool-search-supported Bedrock Claude alias — would otherwise add + # the beta header if pre-normalization tools were read. + model="anthropic.claude-sonnet-4-6-v1:0", + messages=[{"role": "user", "content": "Search for foo"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert ( + result.get("tools", []) == [] + ), "BM25-only input must be reduced to an empty tools list." + assert "tool-search-tool-2025-10-19" not in result.get("anthropic_beta", []), ( + "Tool-search beta header must not be injected when the normalized " + "tools list contains no tool-search entry." + ) + + +def test_bedrock_invoke_messages_transform_bm25_alongside_function_tool_no_beta(): + """ + Mixed BM25 + regular function tool edge case: when a request supplies a + regular Anthropic-style function tool *and* ``tool_search_tool_bm25_20251119``, + ``normalize_bedrock_invoke_tool_search_tools`` drops the BM25 entry but the + function tool survives. The post-normalization request body therefore has + a non-empty tools list but **zero tool-search entries** — the + ``tool-search-tool-2025-10-19`` beta header must NOT be injected. + + The earlier empty-tools-only guard was insufficient: a surviving function + tool kept the list non-empty, so the guard stayed put and the beta header + was still added against a request with no tool-search tools. + """ + from litellm.types.router import GenericLiteLLMParams + + cfg = AmazonAnthropicClaudeMessagesConfig() + optional_params = { + "max_tokens": 128, + "stream": False, + "tools": [ + { + "name": "lookup", + "description": "look something up", + "input_schema": { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + }, + }, + {"type": "tool_search_tool_bm25_20251119"}, + ], + } + + result = cfg.transform_anthropic_messages_request( + # Tool-search-supported Bedrock Claude alias — would otherwise add + # the beta header for any tool_search_used == True path. + model="anthropic.claude-sonnet-4-6-v1:0", + messages=[{"role": "user", "content": "search please"}], + anthropic_messages_optional_request_params=copy.deepcopy(optional_params), + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + result_tools = result.get("tools") or [] + tool_types = [t.get("type") for t in result_tools if isinstance(t, dict)] + tool_names = [t.get("name") for t in result_tools if isinstance(t, dict)] + + assert ( + "tool_search_tool_bm25_20251119" not in tool_types + ), "BM25 variant must be dropped by `normalize_bedrock_invoke_tool_search_tools`." + assert "lookup" in tool_names, "Regular function tool must survive normalization." + tool_search_remaining = [ + t.get("type") + for t in result_tools + if isinstance(t, dict) and t.get("type", "").startswith("tool_search_") + ] + assert ( + tool_search_remaining == [] + ), "No tool-search tools should remain after dropping BM25." + assert "tool-search-tool-2025-10-19" not in result.get("anthropic_beta", []), ( + "Tool-search beta header must not be injected when the " + "post-normalization request body has no tool-search tools — " + "even if other (non-tool-search) tools survive." + ) + + def test_bedrock_invoke_messages_transform_adds_name_when_tool_missing_name(): """Bedrock requires tools.0.custom.name when the payload is schema-only.""" from litellm.types.router import GenericLiteLLMParams @@ -917,9 +1146,7 @@ def test_bedrock_messages_preserves_compact_context_management_and_adds_beta(): messages = [{"role": "user", "content": [{"type": "text", "text": "Hi"}]}] optional_params = { "max_tokens": 4096, - "context_management": { - "edits": [{"type": "compact_20260112"}] - }, + "context_management": {"edits": [{"type": "compact_20260112"}]}, } result = cfg.transform_anthropic_messages_request( @@ -930,9 +1157,7 @@ def test_bedrock_messages_preserves_compact_context_management_and_adds_beta(): headers={}, ) - assert result.get("context_management") == { - "edits": [{"type": "compact_20260112"}] - } + assert result.get("context_management") == {"edits": [{"type": "compact_20260112"}]} assert "compact-2026-01-12" in result.get("anthropic_beta", []) assert result["max_tokens"] == 4096 @@ -964,9 +1189,7 @@ def test_bedrock_messages_filters_unsupported_context_management_edits(): headers={}, ) - assert result.get("context_management") == { - "edits": [{"type": "compact_20260112"}] - } + assert result.get("context_management") == {"edits": [{"type": "compact_20260112"}]} assert "compact-2026-01-12" in result.get("anthropic_beta", [])