diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 795fb36961e..9dfadbbd9b6 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -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) diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py index 72d26f31c60..5780a1bea6b 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_factory.py @@ -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)