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"