mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(bedrock): normalize invoke tool search tools for messages API
This commit is contained in:
parent
f92e1b0d3f
commit
38036830a5
4 changed files with 315 additions and 30 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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", [])
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue