fix(deepseek): drop non-function tools before chat completions call (#30910)

* 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

* test(deepseek): cover async tool filtering and document tool_choice assumption

Add an async_transform_request regression test so the sync and async tool
filtering paths cannot silently diverge, and document in _drop_unsupported_tools
that only non-function tools are dropped, so a function-named tool_choice always
references a surviving tool
This commit is contained in:
Jerry-Scintilla 2026-06-24 18:49:19 +08:00 • committed by GitHub
parent 00725de9f2
commit 04c6492476
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 155 additions and 0 deletions

View file

@ -146,6 +146,56 @@ 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.
Only non-`function` tools are ever dropped, so a `tool_choice` that names
a specific function still points at a surviving tool and is left intact;
`tool_choice`/`parallel_tool_calls` are cleared only when no function
tool remains.
"""
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 +213,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 +236,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,103 @@
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"
async def test_async_transform_request_strips_unsupported_tools_from_body():
config = DeepSeekChatConfig()
body = await config.async_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"