mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
fix(deepseek-v4): convert tool_choice='required'/'any'/dict to 'auto' for reasoner family
DeepSeek V4 (`deepseek-v4-pro` / `deepseek-v4-flash`, released 2026-04) is a
reasoner-only model family that rejects:
- `tool_choice="required"`
- `tool_choice="any"`
- `tool_choice={"type":"function","function":{"name":"..."}}`
with HTTP 400 from the DeepSeek API:
deepseek-reasoner does not support this tool_choice
Only `"auto"` and `"none"` are accepted in thinking mode, and V4 enables
thinking mode by default — so this rejection happens even when the caller
did NOT explicitly send `thinking={"type":"enabled"}`.
This breaks any SDK that hardcodes `tool_choice="required"` for forced
tool selection. A real-world example: OpenAI Agents SDK 0.17+ uses
`ModelSettings(tool_choice="required")` on its delegation /
sub-agent paths to force the parent agent to actually dispatch a tool.
With DeepSeek V4 as the routed model, every delegation call 400s.
## Fix
In `DeepSeekChatConfig.map_openai_params`, when the model is recognised
as a V4 reasoner (either by model-name substring match — needed for the
first turn before `thinking` propagates — OR by `thinking={"type":
"enabled"}` already being set), rewrite unsupported `tool_choice` values
to `"auto"`. `"auto"` and `"none"` pass through unchanged. Non-V4 models
(e.g. `deepseek-chat` / V3) are unaffected.
## Tests
Added `TestDeepSeekV4ToolChoiceConstraint` with 7 cases:
- v4-pro + tool_choice="required" → "auto"
- v4-flash + tool_choice="any" → "auto"
- v4-pro + dict tool_choice → "auto"
- v4-pro + "auto" → unchanged
- v4-pro + "none" → unchanged
- non-V4 deepseek-chat + "required" → unchanged (no regression)
- explicit thinking={"type":"enabled"} on any model + "required" → "auto"
All 18 tests in this file pass (11 existing + 7 new):
pytest tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py
18 passed in 2.38s
Real-world verified: re-ran a multi-turn delegation e2e against
`deepseek/deepseek-v4-pro` (Lead `Financial Advisor` → SubAgent
`market_analyst` with `tool_choice="required"` forced by the SDK) — 3
prior 400s now zero, full 7-section market-review output completes.
## Refs
- DeepSeek docs: https://api-docs.deepseek.com/guides/thinking_mode
- Related upstream context: V4 series is reasoner-only by design, mirroring
`deepseek-reasoner` (R1) tool_choice semantics.
This commit is contained in:
parent
e182a5e0ba
commit
d4e69e48a4
2 changed files with 114 additions and 0 deletions
|
|
@ -60,6 +60,26 @@ class DeepSeekChatConfig(OpenAIGPTConfig):
|
|||
elif reasoning_effort is not None and reasoning_effort != "none":
|
||||
optional_params["thinking"] = {"type": "enabled"}
|
||||
|
||||
# DeepSeek V4 (deepseek-v4-pro / deepseek-v4-flash, released 2026-04) is
|
||||
# a reasoner-only family that rejects `tool_choice="required"` |
|
||||
# `"any"` | `{"type":"function",...}` with HTTP 400
|
||||
# "deepseek-reasoner does not support this tool_choice", even when the
|
||||
# caller did NOT explicitly send `thinking={"type":"enabled"}` (the
|
||||
# server enables thinking mode by default). Only "auto" and "none" are
|
||||
# accepted in thinking mode. Convert unsupported values to "auto" so
|
||||
# SDKs that hardcode `tool_choice="required"` (e.g. OpenAI Agents SDK
|
||||
# delegation paths) keep working on V4. Match on model name so the fix
|
||||
# also fires on the first turn (before any reasoning_content reflects
|
||||
# `thinking` back into optional_params).
|
||||
# Ref: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
is_v4_reasoner = "deepseek-v4" in (model or "").lower() or optional_params.get(
|
||||
"thinking"
|
||||
) == {"type": "enabled"}
|
||||
if is_v4_reasoner:
|
||||
tc = optional_params.get("tool_choice")
|
||||
if tc in ("required", "any") or isinstance(tc, dict):
|
||||
optional_params["tool_choice"] = "auto"
|
||||
|
||||
return optional_params
|
||||
|
||||
@overload
|
||||
|
|
|
|||
|
|
@ -166,3 +166,97 @@ class TestDeepSeekThinkingParams:
|
|||
)
|
||||
|
||||
assert "thinking" not in result
|
||||
|
||||
|
||||
class TestDeepSeekV4ToolChoiceConstraint:
|
||||
"""
|
||||
DeepSeek V4 (deepseek-v4-pro / deepseek-v4-flash, released 2026-04) is
|
||||
reasoner-only and rejects ``tool_choice="required"``, ``"any"``, or a
|
||||
dict form with HTTP 400 "deepseek-reasoner does not support this
|
||||
tool_choice", even when the caller did NOT explicitly send
|
||||
``thinking={"type":"enabled"}`` (the server enables thinking mode by
|
||||
default for V4). Only ``"auto"`` and ``"none"`` are accepted in thinking
|
||||
mode. Verify the transformation converts unsupported values to ``"auto"``
|
||||
so SDKs that hardcode ``tool_choice="required"`` (e.g. OpenAI Agents SDK
|
||||
delegation paths) keep working on V4.
|
||||
"""
|
||||
|
||||
def setup_method(self):
|
||||
self.config = DeepSeekChatConfig()
|
||||
|
||||
def test_v4_pro_tool_choice_required_converted_to_auto(self):
|
||||
"""V4-pro + tool_choice='required' → 'auto' (model-name match, no thinking required)."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": "required"},
|
||||
optional_params={"tool_choice": "required"},
|
||||
model="deepseek/deepseek-v4-pro",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "auto"
|
||||
|
||||
def test_v4_flash_tool_choice_any_converted_to_auto(self):
|
||||
"""V4-flash + tool_choice='any' → 'auto'."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": "any"},
|
||||
optional_params={"tool_choice": "any"},
|
||||
model="deepseek-v4-flash",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "auto"
|
||||
|
||||
def test_v4_pro_tool_choice_dict_form_converted_to_auto(self):
|
||||
"""V4-pro + dict tool_choice (specific function) → 'auto'."""
|
||||
tc = {"type": "function", "function": {"name": "my_tool"}}
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": tc},
|
||||
optional_params={"tool_choice": tc},
|
||||
model="deepseek/deepseek-v4-pro",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "auto"
|
||||
|
||||
def test_v4_pro_tool_choice_auto_unchanged(self):
|
||||
"""V4-pro + tool_choice='auto' → unchanged (already valid)."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": "auto"},
|
||||
optional_params={"tool_choice": "auto"},
|
||||
model="deepseek/deepseek-v4-pro",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "auto"
|
||||
|
||||
def test_v4_pro_tool_choice_none_unchanged(self):
|
||||
"""V4-pro + tool_choice='none' → unchanged (already valid)."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": "none"},
|
||||
optional_params={"tool_choice": "none"},
|
||||
model="deepseek/deepseek-v4-pro",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "none"
|
||||
|
||||
def test_non_v4_chat_model_tool_choice_required_unchanged(self):
|
||||
"""Non-V4 deepseek-chat + tool_choice='required' → unchanged (V3 supports it)."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={"tool_choice": "required"},
|
||||
optional_params={"tool_choice": "required"},
|
||||
model="deepseek/deepseek-chat",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "required"
|
||||
|
||||
def test_thinking_enabled_explicit_also_converts(self):
|
||||
"""Explicit thinking={'type':'enabled'} on any deepseek model converts tool_choice too."""
|
||||
result = self.config.map_openai_params(
|
||||
non_default_params={
|
||||
"thinking": {"type": "enabled"},
|
||||
"tool_choice": "required",
|
||||
},
|
||||
optional_params={
|
||||
"thinking": {"type": "enabled"},
|
||||
"tool_choice": "required",
|
||||
},
|
||||
model="deepseek-reasoner",
|
||||
drop_params=False,
|
||||
)
|
||||
assert result["tool_choice"] == "auto"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue