mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(bedrock): hoist custom.defer_loading before dropping custom on invoke tools (#36855)
This commit is contained in:
parent
d9530bf3d1
commit
86f2f16fd4
4 changed files with 95 additions and 24 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue