From 3d61084c60839cbdd08b2941a1231b03ae30be83 Mon Sep 17 00:00:00 2001 From: gumpm5 Date: Mon, 11 May 2026 15:02:30 +0800 Subject: [PATCH] fix(responses-bridge): drop unknown tool types + read function fields dual-format (#27276) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- .../transformation.py | 35 ++- .../test_responses_tools_transformation.py | 226 ++++++++++++++++++ 2 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index 48b12a5fba9..c62ba76c6ba 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -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) diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py new file mode 100644 index 00000000000..438aaa3b4fe --- /dev/null +++ b/tests/test_litellm/responses/litellm_completion_transformation/test_responses_tools_transformation.py @@ -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"