From 9cf8cf6f398d11a3b42a5dee2de5e2ae1669a710 Mon Sep 17 00:00:00 2001 From: jawad-khan Date: Tue, 17 Mar 2026 14:40:53 +0500 Subject: [PATCH] fix(bedrock): strip custom tool fields for Converse API to fix Haiku 4.5 rejection Claude Code sends custom: {eager_input_streaming: true} on tool definitions. Bedrock Converse accepts this for Claude 4 models (Sonnet 4.6, Opus 4.6) but rejects it for Haiku 4.5 with 'Extra inputs are not permitted'. - Add strip_custom_from_tools_list() in common_utils for list-based stripping - Refactor remove_custom_field_from_tools to use the new helper - Call strip_custom_from_tools_list in _process_tools_and_beta before passing tools to _bedrock_tools_pt - Add unit tests for strip_custom_from_tools_list and _process_tools_and_beta Fixes #23825 Related: #16679 Made-with: Cursor --- .../bedrock/chat/converse_transformation.py | 7 ++ litellm/llms/bedrock/common_utils.py | 25 ++++++ .../chat/test_converse_transformation.py | 39 +++++++++ .../llms/bedrock/test_bedrock_common_utils.py | 79 ++++++++++++++++++- 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 229457a73b4..ac3c92d1fed 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -73,6 +73,7 @@ from ..common_utils import ( get_anthropic_beta_from_headers, get_bedrock_tool_name, is_claude_4_5_on_bedrock, + strip_custom_from_tools_list, ) # Computer use tool prefixes supported by Bedrock @@ -1299,6 +1300,12 @@ class AmazonConverseConfig(BaseConfig): continue filtered_tools.append(tool) + # Strip custom field from tools before sending to Bedrock Converse. + # Claude Code sends custom: {eager_input_streaming: true} etc. which Anthropic + # accepts but Bedrock rejects for some models (e.g. Haiku 4.5) with + # "Extra inputs are not permitted". Ref: https://github.com/BerriAI/litellm/issues/23825 + strip_custom_from_tools_list(filtered_tools) + # Only separate tools if computer use tools are actually present if filtered_tools and self.is_computer_use_tool_used(filtered_tools, model): # Separate computer use tools from regular function tools diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 9666aa68c99..630808aa58b 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -63,11 +63,36 @@ def remove_custom_field_from_tools(request_body: dict) -> None: Ref: https://github.com/BerriAI/litellm/issues/22847 """ tools = request_body.get("tools") + if not tools or not isinstance(tools, list): + return + strip_custom_from_tools_list(tools) + + +def strip_custom_from_tools_list(tools: list) -> None: + """ + Strip ``custom`` field from each tool in the list (modifies in-place). + + Claude Code sends Anthropic-specific fields like ``custom: {eager_input_streaming: true}`` + or ``custom: {input_examples: [...]}`` on tool definitions. Anthropic's API accepts these + but Bedrock Converse rejects them with "Extra inputs are not permitted" for some models + (e.g. Haiku 4.5). + + Handles both top-level tool ``custom`` and nested ``function.custom`` (OpenAI format). + + Args: + tools: List of tool dicts to modify in-place. + + Ref: https://github.com/BerriAI/litellm/issues/23825 + Ref: https://github.com/BerriAI/litellm/issues/16679 + """ if not tools or not isinstance(tools, list): return for tool in tools: if isinstance(tool, dict): tool.pop("custom", None) + func = tool.get("function") + if isinstance(func, dict): + func.pop("custom", None) class AmazonBedrockGlobalConfig: diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index a305009659c..cff0d20cee8 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -319,6 +319,45 @@ def test_get_supported_openai_params_bedrock_converse(): print(f"✅ Passed for model: {model}") +def test_process_tools_and_beta_strips_custom_field(): + """ + Test that _process_tools_and_beta strips custom field from tools before sending to Bedrock. + + Claude Code sends custom: {eager_input_streaming: true} on tool definitions. + Bedrock Converse rejects this for Haiku 4.5 with "Extra inputs are not permitted". + Ref: https://github.com/BerriAI/litellm/issues/23825 + """ + config = AmazonConverseConfig() + tools_with_custom = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the weather", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + "custom": {"eager_input_streaming": True}, + }, + ] + additional_params = {} + bedrock_tools, _ = config._process_tools_and_beta( + original_tools=tools_with_custom, + model="us.anthropic.claude-haiku-4-5-20251001-v1:0", + headers=None, + additional_request_params=additional_params, + ) + # Should produce valid Bedrock tools (toolSpec format) without custom + assert len(bedrock_tools) == 1 + assert "toolSpec" in bedrock_tools[0] + assert bedrock_tools[0]["toolSpec"]["name"] == "get_weather" + # Original tools list should have been modified in-place (custom stripped) + assert "custom" not in tools_with_custom[0] + + def test_transform_request_helper_includes_anthropic_beta_and_tools(): """Test _transform_request_helper includes anthropic_beta for computer tools.""" config = AmazonConverseConfig() diff --git a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py index b804f549ecb..d4b62616bb8 100644 --- a/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py +++ b/tests/test_litellm/llms/bedrock/test_bedrock_common_utils.py @@ -10,7 +10,84 @@ sys.path.insert( ) # Adds the parent directory to the system path -from litellm.llms.bedrock.common_utils import BedrockModelInfo +from litellm.llms.bedrock.common_utils import ( + BedrockModelInfo, + remove_custom_field_from_tools, + strip_custom_from_tools_list, +) + + +def test_strip_custom_from_tools_list(): + """ + Ensure strip_custom_from_tools_list removes custom field from tools. + + Claude Code sends custom: {eager_input_streaming: true} or custom: {input_examples: [...]} + on tool definitions. Bedrock Converse rejects these for some models (e.g. Haiku 4.5) + with "Extra inputs are not permitted". + + Ref: https://github.com/BerriAI/litellm/issues/23825 + Ref: https://github.com/BerriAI/litellm/issues/16679 + """ + # Case 1: OpenAI format - custom at tool level + tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather", + "parameters": {"type": "object", "properties": {}}, + }, + "custom": {"eager_input_streaming": True}, + }, + ] + strip_custom_from_tools_list(tools) + assert "custom" not in tools[0] + assert tools[0]["function"]["name"] == "get_weather" + + # Case 2: OpenAI format - custom nested in function + tools2 = [ + { + "type": "function", + "function": { + "name": "search", + "parameters": {}, + "custom": {"input_examples": [{"query": "test"}]}, + }, + }, + ] + strip_custom_from_tools_list(tools2) + assert "custom" not in tools2[0]["function"] + + # Case 3: Anthropic format - custom at top level + tools3 = [ + { + "name": "some_tool", + "input_schema": {"type": "object"}, + "custom": {"eager_input_streaming": True}, + }, + ] + strip_custom_from_tools_list(tools3) + assert "custom" not in tools3[0] + assert tools3[0]["name"] == "some_tool" + + # Case 4: empty list (no-op) + tools4 = [] + strip_custom_from_tools_list(tools4) + assert tools4 == [] + + # Case 5: None (no-op) + strip_custom_from_tools_list(None) # type: ignore + + +def test_remove_custom_field_from_tools_uses_strip_custom_from_tools_list(): + """Ensure remove_custom_field_from_tools delegates to strip_custom_from_tools_list.""" + request = { + "tools": [ + {"name": "tool1", "input_schema": {}, "custom": {"eager_input_streaming": True}}, + ] + } + remove_custom_field_from_tools(request) + assert "custom" not in request["tools"][0] def test_deepseek_cris():