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 647ccc33a44..b8b07af59c6 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -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 diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 48bc60a07e5..4ad20772ed0 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -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: 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 8d039d95bb1..372cf110f7c 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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) 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 02bd3535c0a..fd66667af64 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 @@ -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(): """