From cbbb79a92f27b4a43792eb115897a31b129e423b Mon Sep 17 00:00:00 2001 From: weiguang li Date: Fri, 6 Mar 2026 05:54:23 +0800 Subject: [PATCH] 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 --- .../anthropic_claude3_transformation.py | 11 ++- litellm/llms/bedrock/common_utils.py | 21 ++++ .../anthropic_claude3_transformation.py | 7 ++ .../test_anthropic_claude3_transformation.py | 97 +++++++++++++++++++ 4 files changed, 135 insertions(+), 1 deletion(-) 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 dfab81123fd..e5698843e8f 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -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) diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index b779c892c67..8e944988a95 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -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 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 03885ff2080..7f368137e30 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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") diff --git a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index a4da4ebb683..e0b06ced172 100644 --- a/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -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"