fix(bedrock): forward freeform tools as parameterless toolSpecs, dropping the custom field

OpenAI Agents SDK freeform tools ({"type": "custom", "name": ...,
"custom": {...}}) carry a top-level name and no function payload. On
v1.90.1 the custom key reached Bedrock Converse and the request failed
with 400 "tools.0.custom.strict: Extra inputs are not permitted"
(#38799); on current main the tool is silently dropped by the
unnamed-builtin guard, so the model never sees it.

A named type=custom tool is now forwarded as a parameterless toolSpec
with the provider-specific extras stripped; nameless built-ins
(web_search, image_generation) and other named built-ins (namespace,
tool_search) are still dropped.

Fixes #38799
This commit is contained in:
jmxjiamingxu 2026-08-30 12:37:41 +08:00
parent 5e4b3838aa
commit 6cd13687d7
2 changed files with 63 additions and 8 deletions

View file

@ -5064,14 +5064,29 @@ def _bedrock_tools_pt(tools: list, model: str | None = None) -> list[BedrockTool
tool_block_list.append(tool)
continue
# Responses built-in tools (web_search, image_generation, namespace, tool_search,
# custom) carry neither an OpenAI "function" nor an Anthropic "input_schema" and have
# no Bedrock toolSpec equivalent; drop them instead of emitting an empty junk toolSpec.
if isinstance(tool, dict) and "function" not in tool and "input_schema" not in tool:
# OpenAI Agents SDK freeform tools ({"type": "custom", "name": ...,
# "description": ..., "custom": {...}}) carry a top-level name and no
# function payload; forward them as parameterless toolSpecs, dropping
# provider-specific extras like `custom`, which Bedrock rejects (#38799).
# Other named-but-builtin tools (e.g. namespace) and nameless Responses
# built-ins (web_search, image_generation, ...) have no Bedrock toolSpec
# equivalent and are still dropped below.
if (
isinstance(tool, dict)
and "function" not in tool
and "input_schema" not in tool
and tool.get("type") == "custom"
):
freeform_name = tool.get("name")
if not (isinstance(freeform_name, str) and freeform_name.strip()):
continue
parameters = {"type": "object", "properties": {}}
raw_name = freeform_name
_tool_description = tool.get("description", None)
elif isinstance(tool, dict) and "function" not in tool and "input_schema" not in tool:
continue
# OpenAI function tools, or Anthropic Messages / Claude Code ({name, input_schema, type, ...})
if isinstance(tool, dict) and "input_schema" in tool and "function" not in tool:
elif isinstance(tool, dict) and "input_schema" in tool and "function" not in tool:
parameters = copy.deepcopy(tool.get("input_schema") or {"type": "object", "properties": {}})
raw_name = tool.get("name", "") or ""
_tool_description = tool.get("description", None)

View file

@ -1030,6 +1030,42 @@ def test_bedrock_tools_pt_strict_parameter():
assert "additionalProperties" not in result[0]["toolSpec"]["inputSchema"]["json"]
def test_bedrock_tools_pt_forwards_freeform_tool_without_custom_field():
"""OpenAI Agents SDK freeform tools carry a top-level name and a provider-specific
`custom` dict instead of a function payload. Bedrock rejects the `custom` key
outright (400 "tools.0.custom.strict: Extra inputs are not permitted"), so the
tool must be forwarded as a parameterless toolSpec with the extras stripped
rather than dropped or passed through.
"""
tools = [
{
"type": "custom",
"name": "apply_patch",
"description": "Apply a unified diff patch to the workspace.",
"custom": {"strict": None},
}
]
result = _bedrock_tools_pt(tools, model="us.anthropic.claude-sonnet-5")
assert len(result) == 1, "the freeform tool must not be dropped"
tool_spec = result[0]["toolSpec"]
assert tool_spec["name"] == "apply_patch"
assert tool_spec["description"] == "Apply a unified diff patch to the workspace."
assert tool_spec["inputSchema"]["json"] == {"type": "object", "properties": {}, "required": []}
assert "custom" not in tool_spec
assert "strict" not in tool_spec
def test_bedrock_tools_pt_still_drops_unnamed_builtin_tools():
"""Responses built-in tools (web_search, image_generation, ...) have no top-level
name and no Bedrock toolSpec equivalent; they must stay dropped.
"""
tools = [
{"type": "web_search_preview"},
{"type": "image_generation"},
]
assert _bedrock_tools_pt(tools, model="us.anthropic.claude-sonnet-5") == []
def test_bedrock_image_processor_content_type_fallback_url_extension():
"""
Test that _post_call_image_processing falls back to URL extension
@ -1632,9 +1668,13 @@ def test_bedrock_tools_pt_does_not_handle_system_tool():
def test_bedrock_tools_pt_drops_unmappable_responses_builtin_tools():
"""
Regression for LIT-3858: Responses built-in tools (image_generation, namespace,
tool_search, custom) have no Bedrock toolSpec equivalent. They must be dropped, not
tool_search) have no Bedrock toolSpec equivalent. They must be dropped, not
emitted as junk ``litellm_unnamed_tool_N`` toolSpecs the model can hallucinate calls to.
Mappable ``function`` and Anthropic ``input_schema`` tools must survive untouched.
Named ``custom`` tools (freeform tools, e.g. the OpenAI Agents SDK's apply_patch)
ARE mappable — a parameterless toolSpec — and are forwarded since #38799; only
nameless built-ins stay dropped.
"""
from litellm.litellm_core_utils.prompt_templates.factory import _bedrock_tools_pt
@ -1657,7 +1697,7 @@ def test_bedrock_tools_pt_drops_unmappable_responses_builtin_tools():
)
names = [block["toolSpec"]["name"] for block in result if "toolSpec" in block]
assert names == ["noop"]
assert names == ["noop", "free_form"], "named built-ins like namespace must stay dropped"
assert not any(name.startswith("litellm_unnamed_tool_") for name in names)