mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
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:
parent
e182a5e0ba
commit
3d61084c60
2 changed files with 257 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
Loading…
Add table
Reference in a new issue