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
This commit is contained in:
Jerry 2026-06-21 13:44:48 +08:00
parent 84c1414aef
commit 5bc0830e31
3 changed files with 130 additions and 0 deletions

View file

@ -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 '<type>', 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(

View file

@ -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"