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:
gumpm5 2026-05-11 14:57:26 +08:00
parent e182a5e0ba
commit d4e69e48a4
2 changed files with 114 additions and 0 deletions

View file

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

View file

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