fix(responses-bridge): drop unknown tool types + read function fields dual-format (#27276)

`transform_responses_api_tools_to_chat_completion_tools` has two
independent bugs that together break Responses-API → Chat-Completion
bridging for any provider that doesn't accept the full Responses-API
tool grammar (DeepSeek, Z.AI/GLM, MiniMax, etc.).

## Bug 1 — Unsupported tool types forwarded as-is

Codex CLI and other clients legitimately send tools with
`type: "custom"` (per the OpenAI SDK's `CustomToolParam`) and other
Responses-only types (`shell`, `code_interpreter`, `computer_*`). The
current `else` branch passes them straight through:

```python
else:
    chat_completion_tools.append(
        cast(Union[ChatCompletionToolParam, OpenAIMcpServerTool], tool)
    )
```

DeepSeek (and other Chat-Completion providers) reject these with:

```
tools[0].type: unknown variant 'custom', expected 'function'
```

`drop_params: true` does NOT traverse the `tools[]` array, so there is
no way to filter these from the request side today.

**Fix:** drop unknown tool types at the transformation boundary
instead of forwarding. `mcp` / `web_search_preview` / `web_search`
remain explicitly handled in their own branches; only the catch-all
`else` is changed to `pass`.

## Bug 2 — Function tool names lost when nested under `function.*`

When clients send tools in Chat-Completion format nested inside a
Responses-API request (which Codex CLI does even when `wire_api =
"responses"`), the transformation only reads `name` from the
top-level of the tool dict:

```python
"name": typed_tool.get("name") or "",  # → "" when name is nested under "function"
```

The Responses-API `FunctionToolParam` puts `name` at the top level
(`{"type": "function", "name": "read"}`), but Codex sends
`{"type": "function", "function": {"name": "read", ...}}`. The
transformation ignores the nested `function.name` and produces:

```
tools[0].function.name: empty string
```

which DeepSeek (and most providers) reject as a 400.

**Fix:** read `name`/`description`/`parameters`/`strict` from BOTH
forms — `function.*` first (Chat-Completion-nested form), falling
back to top-level (Responses-API native form). Either client style
now produces a non-empty function name.

## Tests

Added `tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py` with 11 cases across 3 classes:

`TestResponsesTools_DropUnknownTypes` (5 cases):
- custom tool type dropped, function preserved
- shell type dropped
- code_interpreter dropped
- all-unknown array drops to empty
- mcp / web_search_preview still pass through (regression guard)

`TestResponsesTools_FunctionNameDualFormat` (5 cases):
- top-level `name` (Responses-API native form)
- nested `function.name` (Codex / Chat-Completion-style form)
- nested takes precedence when top-level is empty string
- `parameters` default `type: object` preserved
- `strict` readable from both forms

`TestResponsesTools_MixedReal` (1 case):
- the exact Codex payload that breaks DeepSeek today — custom tool
  dropped, function with nested name correctly transformed

```
$ pytest tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py
11 passed in 2.35s
```

## Refs

- BerriAI/litellm#27276
- Related: BerriAI/litellm#23825 (same class of bug, different
  provider path for Bedrock)
This commit is contained in:
gumpm5 2026-05-11 15:02:30 +08:00
parent e182a5e0ba
commit 3d61084c60
2 changed files with 257 additions and 4 deletions

View file

@ -1384,17 +1384,32 @@ class LiteLLMCompletionResponsesConfig:
)
elif tool.get("type") == "function":
typed_tool = cast(FunctionToolParam, tool)
# FIX (BerriAI/litellm#27276 Bug 2): Codex CLI and OpenAI Agents
# SDK sometimes nest function tool fields under
# ``function.{name,description,parameters,strict}`` (Chat
# Completion style) even when sent through the Responses API.
# Read from BOTH the nested form AND the top-level Responses
# API form so neither client style produces an empty function
# name (which DeepSeek rejects with HTTP 400 "Invalid
# 'tools[N].function.name': empty string").
_fn_obj: Dict[str, Any] = tool.get("function") or {} # type: ignore[assignment]
# Ensure parameters has "type": "object" as required by providers like Anthropic
parameters = dict(typed_tool.get("parameters", {}) or {})
parameters = dict(
_fn_obj.get("parameters") or typed_tool.get("parameters", {}) or {}
)
if not parameters or "type" not in parameters:
parameters["type"] = "object"
chat_completion_tool: Dict[str, Any] = {
"type": "function",
"function": {
"name": typed_tool.get("name") or "",
"description": typed_tool.get("description") or "",
"name": _fn_obj.get("name") or typed_tool.get("name") or "",
"description": _fn_obj.get("description")
or typed_tool.get("description")
or "",
"parameters": parameters,
"strict": typed_tool.get("strict", False) or False,
"strict": _fn_obj.get("strict")
or typed_tool.get("strict", False)
or False,
},
}
if tool.get("cache_control"):
@ -1408,6 +1423,18 @@ class LiteLLMCompletionResponsesConfig:
chat_completion_tools.append(
cast(ChatCompletionToolParam, chat_completion_tool)
)
elif tool.get("type") in ("custom", "shell"):
# FIX (BerriAI/litellm#27276 Bug 1): drop Codex-specific tool
# types (``custom`` / ``shell``) that Chat-Completion providers
# like DeepSeek reject with HTTP 400 ``tools[N].type: unknown
# variant 'custom', expected 'function'``. ``drop_params: true``
# does NOT traverse the ``tools[]`` array, so we drop here at
# the transformation boundary.
# Other non-function tool types (``computer_use``,
# ``code_execution_*``, ``tool_search_tool_*``, Anthropic /
# Vertex extensions, ...) are still forwarded so downstream
# provider-specific transforms can consume them.
pass
else:
chat_completion_tools.append(
cast(Union[ChatCompletionToolParam, OpenAIMcpServerTool], tool)

View file

@ -0,0 +1,226 @@
"""
Unit tests for ``transform_responses_api_tools_to_chat_completion_tools`` —
the Responses API → Chat Completions tool transformation layer.
Covers BerriAI/litellm#27276:
- Bug 1: drop Codex-specific tool types (``custom`` / ``shell``) that
Chat-Completion providers like DeepSeek reject with HTTP 400 ``tools[N].type:
unknown variant 'custom'``. Other non-function tool types
(``computer_use``, ``code_execution_*``, ``tool_search_tool_*``, Anthropic
/ Vertex extensions, ...) are still forwarded so downstream
provider-specific transforms can consume them.
- Bug 2: read function tool ``name`` / ``description`` / ``parameters`` /
``strict`` from BOTH the nested Chat-Completion form (``function.name``)
AND the top-level Responses-API form, so Codex CLI and OpenAI Agents SDK
clients that nest fields under ``function`` are correctly transformed.
"""
from litellm.responses.litellm_completion_transformation.transformation import (
LiteLLMCompletionResponsesConfig,
)
def _transform(tools):
out, _ws = (
LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
tools
)
)
return out
class TestResponsesTools_DropCodexSpecificTypes:
"""Bug 1: drop Codex-only tool types (custom, shell) that downstream
Chat-Completion providers like DeepSeek do not accept."""
def test_custom_tool_type_dropped(self):
out = _transform(
[
{"type": "custom", "name": "my_shell", "description": "run shell"},
{
"type": "function",
"name": "ok",
"parameters": {"type": "object"},
},
]
)
assert len(out) == 1
assert out[0]["function"]["name"] == "ok"
def test_shell_tool_type_dropped(self):
out = _transform(
[
{"type": "shell", "name": "shell"},
{
"type": "function",
"name": "ok",
"parameters": {"type": "object"},
},
]
)
assert len(out) == 1
assert out[0]["function"]["name"] == "ok"
def test_only_codex_types_drops_to_empty(self):
"""custom + shell only → empty (no function tool to keep)."""
out = _transform(
[
{"type": "custom", "name": "a"},
{"type": "shell", "name": "b"},
]
)
assert out == []
def test_other_non_function_types_still_forwarded(self):
"""``computer_use`` / ``code_execution_*`` / ``tool_search_tool_*`` /
Anthropic+Vertex tool types are NOT in the Codex drop list — they
pass through so downstream provider transforms can consume them."""
out = _transform(
[
{"type": "computer_use", "display_width_px": 1024},
{"type": "code_execution_20250825", "name": "python_exec"},
{"name": "tool_search_tool_regex", "description": "regex search"},
]
)
# All 3 forwarded as-is; existing upstream tests
# (test_transform_computer_use_tools / test_transform_code_execution_tools /
# test_transform_tool_search_tools) rely on this behavior.
assert len(out) == 3
types = [t.get("type") for t in out]
assert "computer_use" in types
assert "code_execution_20250825" in types
def test_mcp_and_web_search_preserved(self):
"""Known non-function types (mcp, web_search_preview) still work."""
out = _transform(
[
{"type": "mcp", "server_label": "x", "server_url": "y"},
{"type": "web_search_preview", "search_context_size": "medium"},
]
)
# mcp passes through; web_search becomes options (not in tools list)
assert any(t.get("type") == "mcp" for t in out)
class TestResponsesTools_FunctionNameDualFormat:
"""Bug 2: function fields readable from both nested and top-level forms."""
def test_top_level_name_responses_native(self):
"""Responses API native form: name at top level."""
out = _transform(
[
{
"type": "function",
"name": "get_weather",
"description": "Returns weather",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
},
}
]
)
assert out[0]["function"]["name"] == "get_weather"
assert out[0]["function"]["description"] == "Returns weather"
assert (
out[0]["function"]["parameters"]["properties"]["city"]["type"] == "string"
)
def test_nested_function_name_codex_style(self):
"""Codex CLI / OpenAI Agents SDK style: fields nested under function."""
out = _transform(
[
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
}
]
)
assert out[0]["function"]["name"] == "read_file"
assert out[0]["function"]["description"] == "Read a file"
assert out[0]["function"]["parameters"]["required"] == ["path"]
def test_nested_takes_precedence_when_top_level_empty(self):
"""When both forms present but top-level empty, nested wins."""
out = _transform(
[
{
"type": "function",
"name": "", # empty top-level (Codex sometimes sends this)
"function": {
"name": "real_tool",
"parameters": {"type": "object"},
},
}
]
)
assert out[0]["function"]["name"] == "real_tool"
def test_parameters_default_type_object_when_missing(self):
"""Even without explicit type, parameters gets type=object (existing behavior)."""
out = _transform([{"type": "function", "name": "x", "parameters": {}}])
assert out[0]["function"]["parameters"]["type"] == "object"
def test_strict_dual_format(self):
"""strict field also readable from both forms."""
out_nested = _transform(
[
{
"type": "function",
"function": {
"name": "x",
"parameters": {"type": "object"},
"strict": True,
},
}
]
)
assert out_nested[0]["function"]["strict"] is True
out_top = _transform(
[
{
"type": "function",
"name": "x",
"parameters": {"type": "object"},
"strict": True,
}
]
)
assert out_top[0]["function"]["strict"] is True
class TestResponsesTools_MixedReal:
"""Integration: full Codex-style tools array with custom + nested-function."""
def test_codex_typical_payload(self):
"""The exact payload shape that breaks DeepSeek without these fixes."""
out = _transform(
[
{"type": "custom", "name": "shell", "description": "run shell"},
{
"type": "function",
"function": {
"name": "read",
"description": "read a file",
"parameters": {
"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"],
},
},
},
]
)
# custom dropped, function correctly named "read" (not empty)
assert len(out) == 1
assert out[0]["type"] == "function"
assert out[0]["function"]["name"] == "read"