fix(bedrock): hoist custom.defer_loading before dropping custom on invoke tools (#36855)

This commit is contained in:
Yassin Kortam 2026-08-13 19:28:18 -07:00 committed by GitHub
parent d9530bf3d1
commit 86f2f16fd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 95 additions and 24 deletions

View file

@ -19,9 +19,9 @@ from litellm.llms.bedrock.common_utils import (
convert_bedrock_invoke_output_format_to_inline_schema,
get_anthropic_beta_from_headers,
normalize_bedrock_opus_output_config_effort,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
pop_bedrock_invoke_output_config_format,
remove_custom_field_from_tools,
)
from litellm.types.llms.anthropic import ANTHROPIC_TOOL_SEARCH_BETA_HEADER
from litellm.types.llms.openai import AllMessageValues
@ -243,8 +243,8 @@ 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)
remove_custom_field_from_tools(anthropic_request)
# Hoist `custom.defer_loading` then drop `custom` (Bedrock doesn't support it)
normalize_custom_field_on_tools(anthropic_request)
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_request)
return anthropic_request

View file

@ -176,13 +176,14 @@ def convert_bedrock_invoke_output_format_to_inline_schema(
request_body["messages"] = new_messages
def remove_custom_field_from_tools(request_body: dict) -> None:
def normalize_custom_field_on_tools(request_body: dict) -> None:
"""
Remove ``custom`` field from each tool in the request body.
Drop the ``custom`` field from each tool, first hoisting a boolean
``custom.defer_loading`` onto the top-level ``defer_loading`` flag that
Bedrock and Anthropic actually document, unless the tool already carries one.
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"``.
Claude Code (v2.1.69+) is reported to send ``custom: {defer_loading: true}`` on
tool definitions, which Bedrock rejects with ``"Extra inputs are not permitted"``.
Args:
request_body: The request dictionary to modify in-place.
@ -193,8 +194,14 @@ def remove_custom_field_from_tools(request_body: dict) -> None:
if not tools or not isinstance(tools, list):
return
for tool in tools:
if isinstance(tool, dict):
tool.pop("custom", None)
if not isinstance(tool, dict):
continue
custom: dict[str, object] | None = tool.pop("custom", None)
if not isinstance(custom, dict) or "defer_loading" in tool:
continue
deferred: object = custom.get("defer_loading")
if isinstance(deferred, bool):
tool["defer_loading"] = deferred
def normalize_json_schema_custom_types_to_object(schema: dict) -> None:

View file

@ -33,9 +33,9 @@ from litellm.llms.bedrock.common_utils import (
get_anthropic_beta_from_headers,
is_claude_4_5_on_bedrock,
normalize_bedrock_opus_output_config_effort,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
pop_bedrock_invoke_output_config_format,
remove_custom_field_from_tools,
)
from litellm.types.llms.anthropic import (
ANTHROPIC_BETA_HEADER_VALUES,
@ -749,11 +749,9 @@ class AmazonAnthropicClaudeMessagesConfig(
model,
)
# 5b. 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"
# 5b. Hoist `custom.defer_loading` then drop `custom` (Bedrock doesn't support it)
# Ref: https://github.com/BerriAI/litellm/issues/22847
remove_custom_field_from_tools(anthropic_messages_request)
normalize_custom_field_on_tools(anthropic_messages_request)
normalize_tool_input_schema_types_for_bedrock_invoke(anthropic_messages_request)
ensure_bedrock_anthropic_messages_tool_names(anthropic_messages_request)

View file

@ -16,8 +16,8 @@ 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 (
ensure_bedrock_anthropic_messages_tool_names,
normalize_custom_field_on_tools,
normalize_tool_input_schema_types_for_bedrock_invoke,
remove_custom_field_from_tools,
)
from litellm.constants import (
BEDROCK_MIN_THINKING_BUDGET_TOKENS,
@ -353,12 +353,13 @@ def test_remove_ttl_from_cache_control():
assert request5 == {}
def test_remove_custom_field_from_tools():
def test_normalize_custom_field_on_tools():
"""
Ensure the `custom` field is stripped from every tool definition.
Ensure the `custom` field is stripped from every tool definition, and that a
boolean `custom.defer_loading` is hoisted onto the top-level `defer_loading`
flag Bedrock documents instead of being dropped with the wrapper.
Claude Code v2.1.69+ sends `custom: {defer_loading: true}` on tool
objects. Bedrock does not accept this extra field and returns
Bedrock does not accept a `custom` object on a tool and returns
"Extra inputs are not permitted".
Ref: https://github.com/BerriAI/litellm/issues/22847
@ -381,29 +382,94 @@ def test_remove_custom_field_from_tools():
]
}
remove_custom_field_from_tools(request)
normalize_custom_field_on_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"
# `custom.defer_loading` is hoisted; the tool that never carried it is untouched
assert request["tools"][0]["defer_loading"] is True
assert "defer_loading" not in request["tools"][1]
# Case 2: request without tools key (should not raise error)
request2 = {"messages": [{"role": "user", "content": "hi"}]}
remove_custom_field_from_tools(request2)
normalize_custom_field_on_tools(request2)
assert "tools" not in request2
# Case 3: empty tools list (should not raise error)
request3 = {"tools": []}
remove_custom_field_from_tools(request3)
normalize_custom_field_on_tools(request3)
assert request3["tools"] == []
# Case 4: tools with None value (should not raise error)
request4 = {"tools": None}
remove_custom_field_from_tools(request4)
normalize_custom_field_on_tools(request4)
assert request4["tools"] is None
# Case 5: an explicit top-level flag wins over a conflicting wrapped one
request5 = {
"tools": [
{"name": "Read", "defer_loading": False, "custom": {"defer_loading": True}}
]
}
normalize_custom_field_on_tools(request5)
assert request5["tools"][0] == {"name": "Read", "defer_loading": False}
# Case 6: a non-boolean `custom.defer_loading` is dropped, never forwarded
for junk in ("true", 1, None, {"nested": True}):
request6 = {"tools": [{"name": "Read", "custom": {"defer_loading": junk}}]}
normalize_custom_field_on_tools(request6)
assert request6["tools"][0] == {"name": "Read"}, f"leaked defer_loading={junk!r}"
# Case 7: a `custom` that is not a dict is dropped without raising
request7 = {
"tools": [
{"name": "Read", "custom": "defer_loading"},
{"name": "Write", "custom": None},
]
}
normalize_custom_field_on_tools(request7)
assert request7["tools"] == [{"name": "Read"}, {"name": "Write"}]
@pytest.mark.parametrize(
"deferred_marker", [{"custom": {"defer_loading": True}}, {"defer_loading": True}]
)
def test_bedrock_invoke_messages_transform_emits_top_level_defer_loading(
deferred_marker,
):
"""A deferred tool must reach Bedrock as top-level ``defer_loading``, whether the
client wrapped the flag in ``custom`` or sent it top-level, and the outbound body
must still carry the Bedrock tool-search beta."""
from litellm.types.router import GenericLiteLLMParams
cfg = AmazonAnthropicClaudeMessagesConfig()
result = cfg.transform_anthropic_messages_request(
model="us.anthropic.claude-haiku-4-5-20251001-v1:0",
messages=[{"role": "user", "content": "hi"}],
anthropic_messages_optional_request_params={
"max_tokens": 128,
"stream": False,
"betas": ["advanced-tool-use-2025-11-20"],
"tools": [
{
"name": "Read",
"description": "Read a file",
"input_schema": {"type": "object", "properties": {}},
**deferred_marker,
},
{"type": "tool_search_tool_regex_20251119", "name": "tool_search"},
],
},
litellm_params=GenericLiteLLMParams(),
headers={},
)
assert result["tools"][0]["defer_loading"] is True
assert "custom" not in result["tools"][0]
assert result["anthropic_beta"] == ["tool-search-tool-2025-10-19"]
def test_normalize_tool_input_schema_types_for_bedrock_invoke():
"""