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
This commit is contained in:
jawad-khan 2026-03-17 14:40:53 +05:00
parent 245a3d2b26
commit 9cf8cf6f39
4 changed files with 149 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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