diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index f7ff4d6b16f..05edec3d32b 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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) 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 6b5cb304bec..1c281cadc23 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -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, diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 5114677ffc0..5e1d18d5cc1 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -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 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 a00d3ba1363..afd09ed71f8 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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( diff --git a/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py b/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py index 654c2aa18d1..030859bc6bc 100644 --- a/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py +++ b/tests/e2e/claude_code/tool_search/test_bedrock_invoke.py @@ -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", ]