From 903b09c7ef0d4f39fd29d9cb266c47af19979920 Mon Sep 17 00:00:00 2001 From: Divyansh8321 Date: Mon, 18 May 2026 00:39:53 +0530 Subject: [PATCH] fix(deepseek): pass reasoning_effort value through to DeepSeek V4 API Fixes #27439 DeepSeek V4 Pro/Flash support graded reasoning effort via reasoning_effort ("high" or "max"). The old code collapsed all non-none values to thinking: {"type": "enabled"}, discarding the effort level entirely. Changes in map_openai_params(): - reasoning_effort low/medium/high -> normalized to "high", sends both thinking: {"type": "enabled"} and reasoning_effort: "high" for V4 models - reasoning_effort max/xhigh -> normalized to "max", sends both thinking: {"type": "enabled"} and reasoning_effort: "max" for V4 models - reasoning_effort is NOT forwarded to deepseek-reasoner/R1 - those models have always-on thinking and reject the field (guarded via supports_reasoning()) - reasoning_effort="none" sends thinking: {"type": "disabled"} for V4 opt-in models only; no-op for deepseek-reasoner (always-on, can't disable) --- litellm/llms/deepseek/chat/transformation.py | 77 ++++--- .../chat/test_deepseek_chat_transformation.py | 194 +++++++++++++++++- 2 files changed, 239 insertions(+), 32 deletions(-) diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 7ed3e484535..7fa8dfbaf2e 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -35,7 +35,8 @@ class DeepSeekChatConfig(OpenAIGPTConfig): Map OpenAI params to DeepSeek params. Handles `thinking` and `reasoning_effort` parameters for DeepSeek reasoner models. - DeepSeek only supports `{"type": "enabled"}` - no budget_tokens like Anthropic. + DeepSeek supports `{"type": "enabled"}` and `{"type": "disabled"}` for thinking, + and `reasoning_effort` values of `"high"` or `"max"` for V4 models. Reference: https://api-docs.deepseek.com/guides/thinking_mode """ @@ -49,18 +50,51 @@ class DeepSeekChatConfig(OpenAIGPTConfig): thinking_value = optional_params.pop("thinking", None) reasoning_effort = optional_params.pop("reasoning_effort", None) - # Handle thinking parameter - only accept {"type": "enabled"} - if thinking_value is not None: - if ( - isinstance(thinking_value, dict) - and thinking_value.get("type") == "enabled" - ): - # DeepSeek only accepts {"type": "enabled"}, ignore budget_tokens - optional_params["thinking"] = {"type": "enabled"} + is_always_on_reasoner = supports_reasoning( + model=model, custom_llm_provider="deepseek" + ) - # Handle reasoning_effort - map to thinking enabled - elif reasoning_effort is not None and reasoning_effort != "none": - optional_params["thinking"] = {"type": "enabled"} + # Handle thinking parameter - accepts {"type": "enabled"} or {"type": "disabled"}. + # Guard: deepseek-reasoner has always-on thinking and rejects {"type": "disabled"}. + if thinking_value is not None: + if isinstance(thinking_value, dict) and thinking_value.get("type") in ( + "enabled", + "disabled", + ): + thinking_type = thinking_value.get("type") + if thinking_type == "disabled" and is_always_on_reasoner: + pass # no-op: deepseek-reasoner rejects {"type": "disabled"} + else: + optional_params["thinking"] = {"type": thinking_type} + + # Handle reasoning_effort when thinking was not explicitly provided. + if reasoning_effort is not None and "thinking" not in optional_params: + if reasoning_effort == "none": + # Only send thinking: disabled on V4 opt-in models. + # deepseek-reasoner/R1 have always-on thinking and reject {"type": "disabled"}. + if not is_always_on_reasoner: + optional_params["thinking"] = {"type": "disabled"} + else: + # Normalize to DeepSeek's two supported values + normalized = "max" if reasoning_effort in ("max", "xhigh") else "high" + optional_params["thinking"] = {"type": "enabled"} + # Only forward reasoning_effort on V4 opt-in models. + # deepseek-reasoner/R1 have supports_reasoning=True but don't accept reasoning_effort field. + if not is_always_on_reasoner: + optional_params["reasoning_effort"] = normalized + + # When both thinking=enabled and reasoning_effort are provided for V4 models, + # also forward the effort level (not dropped by the thinking branch above). + if ( + reasoning_effort is not None + and reasoning_effort != "none" + and optional_params.get("thinking", {}).get("type") == "enabled" + and "reasoning_effort" not in optional_params + and not is_always_on_reasoner + ): + optional_params["reasoning_effort"] = ( + "max" if reasoning_effort in ("max", "xhigh") else "high" + ) return optional_params @@ -137,14 +171,13 @@ class DeepSeekChatConfig(OpenAIGPTConfig): def _thinking_mode_active(self, model: str, optional_params: dict) -> bool: """ - Returns True only when thinking mode is actually active for this request: - - model supports reasoning (capability check) - - user explicitly passed thinking={"type": "enabled"} (opt-in check) + Returns True when thinking mode is active for this request: + - deepseek-reasoner/R1: always-on thinking, no explicit param needed + - V4 opt-in models: only when user explicitly passed thinking={"type": "enabled"} """ - return ( - supports_reasoning(model=model, custom_llm_provider="deepseek") - and (optional_params.get("thinking") or {}).get("type") == "enabled" - ) + if supports_reasoning(model=model, custom_llm_provider="deepseek"): + return True # deepseek-reasoner always has thinking on + return (optional_params.get("thinking") or {}).get("type") == "enabled" def transform_request( self, @@ -158,10 +191,8 @@ class DeepSeekChatConfig(OpenAIGPTConfig): Ensures `reasoning_content` is forwarded on assistant messages for multi-turn thinking-mode conversations (issue #28045). - Only runs when thinking mode is actually active - guarded by both - supports_reasoning() (model capability) and optional_params["thinking"] - (user explicitly enabled it), preventing spurious injection on models - like deepseek-v3.2 that support thinking as opt-in but not always-on. + Runs when thinking mode is active: always for deepseek-reasoner (always-on), + and for V4 opt-in models only when the user explicitly enabled thinking. """ if self._thinking_mode_active(model=model, optional_params=optional_params): messages = self._fill_reasoning_content(messages) 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..ffa032a4023 100644 --- a/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -52,60 +52,126 @@ class TestDeepSeekThinkingParams: assert "budget_tokens" not in result.get("thinking", {}) def test_map_reasoning_effort_medium(self): - """Test that reasoning_effort='medium' maps to thinking enabled.""" + """Test that reasoning_effort='medium' normalizes to high for V4 models.""" non_default_params = {"reasoning_effort": "medium"} optional_params = {} result = self.config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=self.model, + model="deepseek-v4-pro", drop_params=False, ) assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "high" def test_map_reasoning_effort_low(self): - """Test that reasoning_effort='low' maps to thinking enabled.""" + """Test that reasoning_effort='low' normalizes to high for V4 models.""" non_default_params = {"reasoning_effort": "low"} optional_params = {} result = self.config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=self.model, + model="deepseek-v4-pro", drop_params=False, ) assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "high" def test_map_reasoning_effort_high(self): - """Test that reasoning_effort='high' maps to thinking enabled.""" + """Test that reasoning_effort='high' passes through as high for V4 models.""" non_default_params = {"reasoning_effort": "high"} optional_params = {} result = self.config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=self.model, + model="deepseek-v4-pro", drop_params=False, ) assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "high" - def test_map_reasoning_effort_none_does_not_enable_thinking(self): - """Test that reasoning_effort='none' does not enable thinking.""" + def test_map_reasoning_effort_max(self): + """Test that reasoning_effort='max' passes through as max for V4 models.""" + non_default_params = {"reasoning_effort": "max"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "max" + + def test_map_reasoning_effort_xhigh_normalizes_to_max(self): + """Test that reasoning_effort='xhigh' normalizes to max for V4 models.""" + non_default_params = {"reasoning_effort": "xhigh"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "max" + + def test_map_reasoning_effort_not_forwarded_for_reasoner(self): + """Test that reasoning_effort is not forwarded to deepseek-reasoner (R1 doesn't accept it).""" + non_default_params = {"reasoning_effort": "max"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, # deepseek-reasoner + drop_params=False, + ) + + # thinking should still be enabled but reasoning_effort must NOT be forwarded + assert result["thinking"] == {"type": "enabled"} + assert "reasoning_effort" not in result + + def test_map_reasoning_effort_none_is_noop_for_reasoner(self): + """Test that reasoning_effort='none' is a no-op for deepseek-reasoner (always-on thinking).""" non_default_params = {"reasoning_effort": "none"} optional_params = {} result = self.config.map_openai_params( non_default_params=non_default_params, optional_params=optional_params, - model=self.model, + model=self.model, # deepseek-reasoner drop_params=False, ) + # deepseek-reasoner has always-on thinking, API rejects {"type": "disabled"} assert "thinking" not in result + assert "reasoning_effort" not in result + + def test_map_reasoning_effort_none_disables_thinking_for_v4(self): + """Test that reasoning_effort='none' sends thinking disabled for V4 opt-in models.""" + non_default_params = {"reasoning_effort": "none"} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "disabled"} + assert "reasoning_effort" not in result def test_map_reasoning_effort_null_does_not_enable_thinking(self): """Test that reasoning_effort=None does not enable thinking.""" @@ -166,3 +232,113 @@ class TestDeepSeekThinkingParams: ) assert "thinking" not in result + + def test_map_thinking_disabled_is_noop_for_reasoner(self): + """Test that thinking={"type": "disabled"} is a no-op for deepseek-reasoner (always-on thinking).""" + non_default_params = {"thinking": {"type": "disabled"}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model=self.model, # deepseek-reasoner + drop_params=False, + ) + + # deepseek-reasoner rejects {"type": "disabled"} - should be silently dropped + assert "thinking" not in result + + def test_map_thinking_disabled_passes_through_for_v4(self): + """Test that thinking={"type": "disabled"} is passed through for V4 opt-in models.""" + non_default_params = {"thinking": {"type": "disabled"}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "disabled"} + + def test_map_thinking_disabled_with_budget_tokens_strips_budget(self): + """Test that budget_tokens is stripped even when thinking is disabled.""" + non_default_params = {"thinking": {"type": "disabled", "budget_tokens": 0}} + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "disabled"} + assert "budget_tokens" not in result.get("thinking", {}) + + def test_map_thinking_and_reasoning_effort_both_forwarded_for_v4(self): + """Test that when both thinking and reasoning_effort are provided for V4, effort is not dropped.""" + non_default_params = { + "thinking": {"type": "enabled"}, + "reasoning_effort": "max", + } + optional_params = {} + + result = self.config.map_openai_params( + non_default_params=non_default_params, + optional_params=optional_params, + model="deepseek-v4-pro", + drop_params=False, + ) + + assert result["thinking"] == {"type": "enabled"} + assert result["reasoning_effort"] == "max" + + +class TestFillReasoningContent: + """Test _fill_reasoning_content helper for multi-turn thinking-mode conversations.""" + + def setup_method(self): + self.config = DeepSeekChatConfig() + + def test_injects_placeholder_when_reasoning_content_missing(self): + """Assistant message missing reasoning_content gets a space placeholder injected.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi"}, + ] + result = self.config._fill_reasoning_content(messages) + assert result[1]["reasoning_content"] == " " + + def test_promotes_reasoning_content_from_provider_specific_fields(self): + """reasoning_content stored in provider_specific_fields is promoted to top level.""" + messages = [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": "hi", + "provider_specific_fields": {"reasoning_content": "my reasoning"}, + }, + ] + result = self.config._fill_reasoning_content(messages) + assert result[1]["reasoning_content"] == "my reasoning" + assert "reasoning_content" not in result[1].get("provider_specific_fields", {}) + + def test_does_not_overwrite_existing_reasoning_content(self): + """Assistant message that already has reasoning_content is left unchanged.""" + messages = [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "hi", "reasoning_content": "already here"}, + ] + result = self.config._fill_reasoning_content(messages) + assert result[1]["reasoning_content"] == "already here" + + def test_non_assistant_messages_are_unchanged(self): + """User and system messages are passed through untouched.""" + messages = [ + {"role": "system", "content": "you are helpful"}, + {"role": "user", "content": "hello"}, + ] + result = self.config._fill_reasoning_content(messages) + assert result == messages