diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 5cd8d119542..06b86b2fa44 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -2,13 +2,15 @@ Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions` """ -from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, overload +from typing import Any, Coroutine, List, Literal, Optional, Tuple, Union, cast, overload +import litellm from litellm.litellm_core_utils.prompt_templates.common_utils import ( handle_messages_with_content_list_to_str_conversion, ) from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues +from litellm.utils import supports_reasoning from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -62,6 +64,45 @@ class DeepSeekChatConfig(OpenAIGPTConfig): return optional_params + def _fill_reasoning_content( + self, messages: List[AllMessageValues] + ) -> List[AllMessageValues]: + """ + DeepSeek thinking mode requires `reasoning_content` to be passed back on + every assistant message in multi-turn conversations. If it is missing, + the API returns: + "The reasoning_content in the thinking mode must be passed back to the API." + + For each assistant message that is missing `reasoning_content`: + 1. Promote it from `provider_specific_fields["reasoning_content"]` if present + (LiteLLM stores provider-specific response fields there). + 2. Otherwise inject a single space — the minimum value the API accepts. + """ + result: List[AllMessageValues] = [] + for msg in messages: + if msg.get("role") == "assistant" and not msg.get("reasoning_content"): + patched = dict(cast(dict, msg)) + provider_fields = patched.get("provider_specific_fields") or {} + stored = provider_fields.get("reasoning_content") + if stored: + patched["reasoning_content"] = stored + cleaned = dict(provider_fields) + cleaned.pop("reasoning_content", None) + patched["provider_specific_fields"] = cleaned + else: + litellm.verbose_logger.debug( + "DeepSeek thinking mode: assistant message is missing " + "`reasoning_content`. Injecting a placeholder to satisfy " + "API validation. For best results, preserve " + "`reasoning_content` from the original assistant response " + "when building multi-turn conversation history." + ) + patched["reasoning_content"] = " " + result.append(cast(AllMessageValues, patched)) + else: + result.append(msg) + return result + @overload def _transform_messages( self, messages: List[AllMessageValues], model: str, is_async: Literal[True] @@ -91,6 +132,66 @@ class DeepSeekChatConfig(OpenAIGPTConfig): messages=messages, model=model, is_async=False ) + 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) + """ + return ( + supports_reasoning(model=model, custom_llm_provider="deepseek") + and (optional_params.get("thinking") or {}).get("type") == "enabled" + ) + + def transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + 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. + """ + if self._thinking_mode_active(model=model, optional_params=optional_params): + messages = self._fill_reasoning_content(messages) + return super().transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + + async def async_transform_request( + self, + model: str, + messages: List[AllMessageValues], + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> dict: + """ + Async equivalent of transform_request — applies the same reasoning_content + fix for multi-turn thinking-mode conversations. + """ + if self._thinking_mode_active(model=model, optional_params=optional_params): + messages = self._fill_reasoning_content(messages) + return await super().async_transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=headers, + ) + def _get_openai_compatible_provider_info( self, api_base: Optional[str], api_key: Optional[str] ) -> Tuple[Optional[str], Optional[str]]: diff --git a/tests/llm_translation/test_deepseek_completion.py b/tests/llm_translation/test_deepseek_completion.py index da402a51b68..2ede5d3f3f8 100644 --- a/tests/llm_translation/test_deepseek_completion.py +++ b/tests/llm_translation/test_deepseek_completion.py @@ -176,3 +176,113 @@ def test_completion_cost_deepseek(): pass except Exception as e: pytest.fail(f"Error occurred: {e}") + + +def test_deepseek_fill_reasoning_content_multiturn(): + """ + Unit test for _fill_reasoning_content. + Reproduces issue #28045: DeepSeek thinking mode fails in multi-turn conversations + because reasoning_content is not passed back to the API. + """ + from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig + + config = DeepSeekChatConfig() + + # Case 1: assistant message already has reasoning_content — should be left as-is + messages_with_rc = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi", "reasoning_content": "I thought about it"}, + {"role": "user", "content": "Follow up"}, + ] + result = config._fill_reasoning_content(messages_with_rc) + assert result[1]["reasoning_content"] == "I thought about it" + + # Case 2: assistant message has reasoning_content in provider_specific_fields — should be promoted + messages_with_psf = [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": "Hi", + "provider_specific_fields": {"reasoning_content": "stored thinking"}, + }, + {"role": "user", "content": "Follow up"}, + ] + result = config._fill_reasoning_content(messages_with_psf) + assert result[1]["reasoning_content"] == "stored thinking" + # Should be removed from provider_specific_fields to avoid duplication + assert "reasoning_content" not in result[1].get("provider_specific_fields", {}) + + # Case 3: assistant message has no reasoning_content anywhere — should inject placeholder + messages_no_rc = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": "Follow up"}, + ] + result = config._fill_reasoning_content(messages_no_rc) + assert result[1]["reasoning_content"] == " " + + # Case 4: non-assistant messages should never be touched + messages_user_only = [ + {"role": "user", "content": "Hello"}, + {"role": "system", "content": "You are helpful"}, + ] + result = config._fill_reasoning_content(messages_user_only) + assert "reasoning_content" not in result[0] + assert "reasoning_content" not in result[1] + + +def test_deepseek_fill_reasoning_content_guard_in_transform_request(): + """ + _fill_reasoning_content must only run when BOTH conditions are true: + 1. supports_reasoning() is True for the model + 2. thinking mode is explicitly enabled in optional_params ({"type": "enabled"}) + + This prevents spurious injection on models like deepseek-v3.2 that support + thinking as opt-in but not always-on. Addresses oss-pr-review-agent feedback + on PR #28057. + """ + from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig + + config = DeepSeekChatConfig() + + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi"}, + {"role": "user", "content": "Follow up"}, + ] + + # Case 1: reasoning model + thinking enabled -> injection should happen + result = config.transform_request( + model="deepseek-reasoner", + messages=messages, + optional_params={"thinking": {"type": "enabled"}}, + litellm_params={}, + headers={}, + ) + assert result["messages"][1].get("reasoning_content") == " ", ( + "reasoning_content should be injected when thinking is enabled" + ) + + # Case 2: reasoning model + thinking NOT in optional_params -> no injection + result = config.transform_request( + model="deepseek-reasoner", + messages=messages, + optional_params={}, + litellm_params={}, + headers={}, + ) + assert "reasoning_content" not in result["messages"][1], ( + "reasoning_content should not be injected when thinking is not enabled" + ) + + # Case 3: non-reasoning model + thinking enabled -> no injection + result = config.transform_request( + model="deepseek-chat", + messages=messages, + optional_params={"thinking": {"type": "enabled"}}, + litellm_params={}, + headers={}, + ) + assert "reasoning_content" not in result["messages"][1], ( + "reasoning_content should not be injected for non-reasoning models" + )