fix(bedrock): normalize invoke tool search tools for messages API

This commit is contained in:
Daoyuan Li 2026-05-18 19:54:20 -07:00
parent f92e1b0d3f
commit 38036830a5
4 changed files with 315 additions and 30 deletions

View file

@ -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(

View file

@ -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

View file

@ -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)
)

View file

@ -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", [])