From d4e69e48a44e066845fce1df85fd6cbf31fef16d Mon Sep 17 00:00:00 2001 From: gumpm5 Date: Mon, 11 May 2026 14:57:26 +0800 Subject: [PATCH] fix(deepseek-v4): convert tool_choice='required'/'any'/dict to 'auto' for reasoner family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- litellm/llms/deepseek/chat/transformation.py | 20 ++++ .../chat/test_deepseek_chat_transformation.py | 94 +++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 5cd8d119542..d9282192be6 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -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 diff --git a/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index a2f45e7188b..2b3405ebc70 100644 --- a/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -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"