fix(bedrock): tool_search type normalize on messages path; document+text

Bedrock Invoke Messages was forwarding Claude Code's dated
tool_search_tool_regex_20251119 type and 400ing; normalize to the
undated form (and strip on models without tool-search support). Converse
rejects document-only tool/user content without a sibling text block;
inject a minimal text block and accept Anthropic document blocks on
tool results
This commit is contained in:
mubashir1osmani 2026-07-16 18:20:13 -07:00
parent 4ee161a152
commit d5200c96c0
5 changed files with 113 additions and 3 deletions

View file

@ -3802,7 +3802,39 @@ def _parse_bedrock_tool_result_content_list(
_append_bedrock_tool_result_image_url_block(tool_result_content_blocks, content)
elif content["type"] == "file":
_append_bedrock_tool_result_file_block(tool_result_content_blocks, content)
return tool_result_content_blocks
elif content["type"] == "document":
# Anthropic-shape document blocks (e.g. Claude Code Read of a PDF) need
# the same Bedrock document conversion as user-message document blocks.
processed = BedrockConverseMessagesProcessor._process_document_message(content)
_append_bedrock_tool_result_media_block(
tool_result_content_blocks, processed, content, "document"
)
return _ensure_bedrock_tool_result_text_with_documents(tool_result_content_blocks)
def _ensure_bedrock_tool_result_text_with_documents(
blocks: List[BedrockToolResultContentBlock],
) -> List[BedrockToolResultContentBlock]:
"""Bedrock Converse requires a text content block when toolResult includes documents.
Without it: ``A text block must be included when using documents``.
"""
has_document = any(isinstance(b, dict) and "document" in b for b in blocks)
has_text = any(isinstance(b, dict) and "text" in b for b in blocks)
if has_document and not has_text:
return [BedrockToolResultContentBlock(text=" "), *blocks]
return blocks
def _ensure_bedrock_message_content_text_with_documents(
blocks: List[BedrockContentBlock],
) -> List[BedrockContentBlock]:
"""Same Converse rule as toolResult: document content needs a sibling text block."""
has_document = any(isinstance(b, dict) and "document" in b for b in blocks)
has_text = any(isinstance(b, dict) and "text" in b for b in blocks)
if has_document and not has_text:
return [BedrockContentBlock(text=" "), *blocks]
return blocks
def _build_bedrock_tool_result_content_blocks(
@ -4405,6 +4437,7 @@ class BedrockConverseMessagesProcessor:
msg_i += 1
if user_content:
user_content = _ensure_bedrock_message_content_text_with_documents(user_content)
if len(contents) > 0 and contents[-1]["role"] == "user":
if assistant_continue_message is not None or litellm.modify_params is True:
# if last message was a 'user' message, then add a dummy assistant message (bedrock requires alternating roles)
@ -4780,6 +4813,7 @@ def _bedrock_converse_messages_pt(
msg_i += 1
if user_content:
user_content = _ensure_bedrock_message_content_text_with_documents(user_content)
if len(contents) > 0 and contents[-1]["role"] == "user":
if assistant_continue_message is not None or litellm.modify_params is True:
# if last message was a 'user' message, then add a dummy assistant message (bedrock requires alternating roles)

View file

@ -196,7 +196,13 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
model=model,
output_config=filtered_params["output_config"],
)
filtered_params = self._normalize_bedrock_tool_search_tools(filtered_params)
from litellm.llms.bedrock.common_utils import normalize_bedrock_invoke_tool_search_tools
# In-place on a tools-shaped request stub so shared normalizer can run.
tools_stub = {"tools": filtered_params.get("tools")}
normalize_bedrock_invoke_tool_search_tools(tools_stub, model=model)
if "tools" in tools_stub:
filtered_params["tools"] = tools_stub["tools"]
anthropic_request = AnthropicConfig.transform_request(
self,

View file

@ -250,6 +250,72 @@ def ensure_bedrock_anthropic_messages_tool_names(request_body: dict) -> None:
tool["name"] = f"litellm_unnamed_tool_{i}"
_BEDROCK_TOOL_SEARCH_DATED_REGEX = "tool_search_tool_regex_20251119"
_BEDROCK_TOOL_SEARCH_DATED_BM25 = "tool_search_tool_bm25_20251119"
def bedrock_model_supports_tool_search(model: str) -> bool:
"""Server-side tool search on Bedrock is limited to Sonnet/Opus 4.5+ (not Haiku)."""
model_lower = model.lower()
patterns = (
"opus-4.5",
"opus_4.5",
"opus-4-5",
"opus_4_5",
"sonnet-4.5",
"sonnet_4.5",
"sonnet-4-5",
"sonnet_4_5",
"opus-4.6",
"opus_4.6",
"opus-4-6",
"opus_4_6",
"sonnet-4.6",
"sonnet_4.6",
"sonnet-4-6",
"sonnet_4_6",
)
return any(p in model_lower for p in patterns)
def normalize_bedrock_invoke_tool_search_tools(request_body: dict, model: str = "") -> None:
"""Normalize Claude Code tool-search tool types for Bedrock Invoke Messages.
Anthropic API uses dated types (``tool_search_tool_regex_20251119``); Bedrock
Invoke expects the undated ``tool_search_tool_regex`` form. BM25 is unsupported
on Invoke and is dropped. Models that do not support tool search (e.g. Haiku)
have those tools stripped so the rest of the request still succeeds.
"""
tools = request_body.get("tools")
if not tools or not isinstance(tools, list):
return
supports = bedrock_model_supports_tool_search(model) if model else True
normalized: list = []
for tool in tools:
if not isinstance(tool, dict):
normalized.append(tool)
continue
tool_type = tool.get("type")
if tool_type == _BEDROCK_TOOL_SEARCH_DATED_BM25:
continue
if tool_type == _BEDROCK_TOOL_SEARCH_DATED_REGEX or tool_type == "tool_search_tool_regex":
if not supports:
continue
entry = dict(tool)
entry["type"] = "tool_search_tool_regex"
entry["name"] = entry.get("name") or "tool_search_tool_regex"
normalized.append(entry)
continue
if isinstance(tool_type, str) and tool_type.startswith("tool_search_tool"):
if not supports:
continue
normalized.append(tool)
continue
normalized.append(tool)
request_body["tools"] = normalized
class AmazonBedrockGlobalConfig:
def __init__(self):
pass

View file

@ -41,6 +41,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_bedrock_opus_output_config_effort,
normalize_tool_input_schema_types_for_bedrock_invoke,
pop_bedrock_invoke_output_config_format,
@ -775,6 +776,7 @@ class AmazonAnthropicClaudeMessagesConfig(
remove_custom_field_from_tools(anthropic_messages_request)
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_messages_request)
ensure_bedrock_anthropic_messages_tool_names(anthropic_messages_request)
normalize_bedrock_invoke_tool_search_tools(anthropic_messages_request, model=model)
# 6. AUTO-INJECT beta headers based on features used
filtered_betas = self._get_bedrock_invoke_anthropic_beta_headers(

View file

@ -52,8 +52,10 @@ from claude_code.http_probe import (
)
# Haiku is omitted: Bedrock server-side tool search is only supported on
# Sonnet/Opus 4.5+ (see bedrock_model_supports_tool_search). Including Haiku
# 400s on the dated tool_search type and fails the whole cell.
BEDROCK_INVOKE_MODELS = [
"claude-haiku-4-5-bedrock-invoke",
"claude-sonnet-4-5-bedrock-invoke",
"claude-opus-4-7-bedrock-invoke",
]