From 5bc0830e31444a2fe340416c52fff2a78f49e2b7 Mon Sep 17 00:00:00 2001 From: Jerry Date: Sun, 21 Jun 2026 13:44:48 +0800 Subject: [PATCH] fix(deepseek): drop non-function tools before chat completions call DeepSeek's /chat/completions only accepts tools of type "function". Requests bridged from /v1/responses can carry responses-API-native tool types, for example a Codex CLI tool typed "namespace", which DeepSeek rejects with "unknown variant 'namespace', expected 'function'" so the whole request fails (issue #30722). Filter unsupported tool types in the DeepSeek request transform so the function tools still go through; when nothing callable remains, also drop the now-dangling tool_choice and parallel_tool_calls Fixes #30722 --- litellm/llms/deepseek/chat/transformation.py | 47 +++++++++++ .../llms/deepseek/chat/__init__.py | 0 .../chat/test_deepseek_chat_transformation.py | 83 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 tests/test_litellm/llms/deepseek/chat/__init__.py create mode 100644 tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 7ed3e484535..63147642692 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -146,6 +146,51 @@ class DeepSeekChatConfig(OpenAIGPTConfig): and (optional_params.get("thinking") or {}).get("type") == "enabled" ) + @staticmethod + def _drop_unsupported_tools(optional_params: dict) -> dict: + """ + DeepSeek's /chat/completions only accepts tools of type "function". + + Requests bridged from /v1/responses can carry responses-API-native tool + types (e.g. a Codex CLI tool typed "namespace"); DeepSeek rejects the + whole request with `unknown variant '', expected 'function'` (issue + #30722). Drop the unsupported entries so the function tools still go + through, and drop the now-dangling tool_choice/parallel_tool_calls when + nothing callable survives. + """ + tools = optional_params.get("tools") + if not isinstance(tools, list) or not tools: + return optional_params + + def _is_function_tool(tool: object) -> bool: + return isinstance(tool, dict) and tool.get("type") == "function" + + function_tools = [tool for tool in tools if _is_function_tool(tool)] + if len(function_tools) == len(tools): + return optional_params + + dropped_types = sorted( + { + str(tool.get("type")) if isinstance(tool, dict) else type(tool).__name__ + for tool in tools + if not _is_function_tool(tool) + } + ) + litellm.verbose_logger.warning( + "DeepSeek chat completions only supports function tools; dropping " + "unsupported tool type(s) %s before sending the request", + dropped_types, + ) + + cleaned = {k: v for k, v in optional_params.items() if k != "tools"} + if function_tools: + return {**cleaned, "tools": function_tools} + return { + k: v + for k, v in cleaned.items() + if k not in ("tool_choice", "parallel_tool_calls") + } + def transform_request( self, model: str, @@ -163,6 +208,7 @@ class DeepSeekChatConfig(OpenAIGPTConfig): (user explicitly enabled it), preventing spurious injection on models like deepseek-v3.2 that support thinking as opt-in but not always-on. """ + optional_params = self._drop_unsupported_tools(optional_params) if self._thinking_mode_active(model=model, optional_params=optional_params): messages = self._fill_reasoning_content(messages) return super().transform_request( @@ -185,6 +231,7 @@ class DeepSeekChatConfig(OpenAIGPTConfig): Async equivalent of transform_request — applies the same reasoning_content fix for multi-turn thinking-mode conversations. """ + optional_params = self._drop_unsupported_tools(optional_params) if self._thinking_mode_active(model=model, optional_params=optional_params): messages = self._fill_reasoning_content(messages) return await super().async_transform_request( diff --git a/tests/test_litellm/llms/deepseek/chat/__init__.py b/tests/test_litellm/llms/deepseek/chat/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py new file mode 100644 index 00000000000..8d2d1470d5a --- /dev/null +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -0,0 +1,83 @@ +from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig + + +def _function_tool(name: str) -> dict: + return { + "type": "function", + "function": {"name": name, "parameters": {"type": "object"}}, + } + + +def test_drop_unsupported_tools_keeps_function_tools_only(): + optional_params = { + "tools": [ + _function_tool("shell"), + {"type": "namespace", "name": "container.exec"}, + _function_tool("apply_patch"), + ], + "tool_choice": "auto", + } + + result = DeepSeekChatConfig._drop_unsupported_tools(optional_params) + + assert [tool["function"]["name"] for tool in result["tools"]] == [ + "shell", + "apply_patch", + ] + assert all(tool["type"] == "function" for tool in result["tools"]) + assert result["tool_choice"] == "auto" + + +def test_drop_unsupported_tools_drops_dangling_tool_choice_when_none_survive(): + optional_params = { + "tools": [{"type": "namespace", "name": "container.exec"}], + "tool_choice": "required", + "parallel_tool_calls": True, + "temperature": 0.2, + } + + result = DeepSeekChatConfig._drop_unsupported_tools(optional_params) + + assert "tools" not in result + assert "tool_choice" not in result + assert "parallel_tool_calls" not in result + assert result["temperature"] == 0.2 + + +def test_drop_unsupported_tools_is_noop_for_function_only(): + optional_params = { + "tools": [_function_tool("shell")], + "tool_choice": "auto", + } + + result = DeepSeekChatConfig._drop_unsupported_tools(optional_params) + + assert result is optional_params + + +def test_drop_unsupported_tools_is_noop_without_tools(): + optional_params = {"temperature": 0.7} + + result = DeepSeekChatConfig._drop_unsupported_tools(optional_params) + + assert result is optional_params + + +def test_transform_request_strips_unsupported_tools_from_body(): + config = DeepSeekChatConfig() + body = config.transform_request( + model="deepseek-chat", + messages=[{"role": "user", "content": "hi"}], + optional_params={ + "tools": [ + _function_tool("shell"), + {"type": "namespace", "name": "container.exec"}, + ], + "tool_choice": "auto", + }, + litellm_params={}, + headers={}, + ) + + assert [tool["type"] for tool in body["tools"]] == ["function"] + assert body["tools"][0]["function"]["name"] == "shell"