fix(bedrock): filter out custom field from tools to prevent 400 errors (#22861)

Claude Code v2.1.69+ sends `custom: {defer_loading: true}` on tool
definitions. Anthropic's API accepts this field, but Bedrock rejects it
with "Extra inputs are not permitted", causing ~90% of requests to fail.

Strip the `custom` field from each tool in the request body before
sending to Bedrock, in both the Messages API and Chat API invoke paths.

Fixes #22847

Co-authored-by: Ishaan Jaff <ishaanjaffer0324@gmail.com>
This commit is contained in:
weiguang li 2026-03-06 05:54:23 +08:00 committed by Milan
parent a8cf646850
commit cbbb79a92f
4 changed files with 135 additions and 1 deletions

View file

@ -6,7 +6,10 @@ from litellm.llms.anthropic.chat.transformation import AnthropicConfig
from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation import (
AmazonInvokeConfig,
)
from litellm.llms.bedrock.common_utils import get_anthropic_beta_from_headers
from litellm.llms.bedrock.common_utils import (
get_anthropic_beta_from_headers,
remove_custom_field_from_tools,
)
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
from litellm.types.utils import ModelResponse
@ -108,6 +111,12 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig):
if "anthropic_version" not in _anthropic_request:
_anthropic_request["anthropic_version"] = self.anthropic_version
# Remove `custom` field from tools (Bedrock doesn't support it)
# Claude Code sends `custom: {defer_loading: true}` on tool definitions,
# which causes Bedrock to reject the request with "Extra inputs are not permitted"
# Ref: https://github.com/BerriAI/litellm/issues/22847
remove_custom_field_from_tools(_anthropic_request)
tools = optional_params.get("tools")
tool_search_used = self.is_tool_search_used(tools)
programmatic_tool_calling_used = self.is_programmatic_tool_calling_used(tools)

View file

@ -49,6 +49,27 @@ def get_cached_model_info():
return _get_model_info
def remove_custom_field_from_tools(request_body: dict) -> None:
"""
Remove ``custom`` field from each tool in the request body.
Claude Code (v2.1.69+) sends ``custom: {defer_loading: true}`` on tool
definitions, which Anthropic's API accepts but Bedrock rejects with
``"Extra inputs are not permitted"``.
Args:
request_body: The request dictionary to modify in-place.
Ref: https://github.com/BerriAI/litellm/issues/22847
"""
tools = request_body.get("tools")
if not tools or not isinstance(tools, list):
return
for tool in tools:
if isinstance(tool, dict):
tool.pop("custom", None)
class AmazonBedrockGlobalConfig:
def __init__(self):
pass

View file

@ -26,6 +26,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.base_invoke_transformation
from litellm.llms.bedrock.common_utils import (
get_anthropic_beta_from_headers,
is_claude_4_5_on_bedrock,
remove_custom_field_from_tools,
)
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
@ -402,6 +403,12 @@ class AmazonAnthropicClaudeMessagesConfig(
anthropic_messages_request=anthropic_messages_request,
)
# 5a. Remove `custom` field from tools (Bedrock doesn't support it)
# Claude Code sends `custom: {defer_loading: true}` on tool definitions,
# which causes Bedrock to reject the request with "Extra inputs are not permitted"
# Ref: https://github.com/BerriAI/litellm/issues/22847
remove_custom_field_from_tools(anthropic_messages_request)
# 6. AUTO-INJECT beta headers based on features used
anthropic_model_info = AnthropicModelInfo()
tools = anthropic_messages_optional_request_params.get("tools")

View file

@ -11,6 +11,7 @@ import pytest
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 remove_custom_field_from_tools
from litellm.llms.bedrock.messages.invoke_transformations.anthropic_claude3_transformation import (
AmazonAnthropicClaudeMessagesConfig,
AmazonAnthropicClaudeMessagesStreamDecoder,
@ -178,3 +179,99 @@ def test_remove_ttl_from_cache_control():
request5 = {}
cfg._remove_ttl_from_cache_control(request5)
assert request5 == {}
def test_remove_custom_field_from_tools():
"""
Ensure the `custom` field is stripped from every tool definition.
Claude Code v2.1.69+ sends `custom: {defer_loading: true}` on tool
objects. Bedrock does not accept this extra field and returns
"Extra inputs are not permitted".
Ref: https://github.com/BerriAI/litellm/issues/22847
"""
# Case 1: tool with `custom` field should have it removed
request = {
"tools": [
{
"name": "Read",
"description": "Read a file",
"input_schema": {"type": "object", "properties": {}},
"custom": {"defer_loading": True},
},
{
"name": "Write",
"description": "Write a file",
"input_schema": {"type": "object", "properties": {}},
},
]
}
remove_custom_field_from_tools(request)
for tool in request["tools"]:
assert "custom" not in tool, f"Tool {tool['name']} still has 'custom' field"
# Other fields should be preserved
assert request["tools"][0]["name"] == "Read"
assert request["tools"][1]["name"] == "Write"
# Case 2: request without tools key (should not raise error)
request2 = {"messages": [{"role": "user", "content": "hi"}]}
remove_custom_field_from_tools(request2)
assert "tools" not in request2
# Case 3: empty tools list (should not raise error)
request3 = {"tools": []}
remove_custom_field_from_tools(request3)
assert request3["tools"] == []
# Case 4: tools with None value (should not raise error)
request4 = {"tools": None}
remove_custom_field_from_tools(request4)
assert request4["tools"] is None
def test_remove_scope_from_cache_control():
"""Ensure scope field is removed from cache_control for Bedrock (not supported)."""
cfg = AmazonAnthropicClaudeMessagesConfig()
# Test case 1: System with cache_control containing scope
request = {
"system": [
{
"type": "text",
"text": "You are an AI assistant.",
"cache_control": {
"type": "ephemeral",
"scope": "global",
},
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Hello",
"cache_control": {
"type": "ephemeral",
"scope": "global",
},
}
],
}
],
}
cfg._remove_ttl_from_cache_control(request)
# Verify scope is removed from system
assert "scope" not in request["system"][0]["cache_control"]
assert request["system"][0]["cache_control"]["type"] == "ephemeral"
# Verify scope is removed from messages
assert "scope" not in request["messages"][0]["content"][0]["cache_control"]
assert request["messages"][0]["content"][0]["cache_control"]["type"] == "ephemeral"