From a3dbffa8e73fb546b354bcbbc7a619f4ca936166 Mon Sep 17 00:00:00 2001 From: cdxiaodong Date: Tue, 28 Apr 2026 16:37:57 +0800 Subject: [PATCH] Fix DeepSeek V4 reasoning_content in multi-turn chat --- .../llms/openai/chat/gpt_transformation.py | 7 ++ litellm/llms/openai/common_utils.py | 37 +++++++++++ litellm/llms/openai/openai.py | 8 +++ litellm/types/utils.py | 2 +- .../test_deepseek_completion.py | 65 +++++++++++++++++++ tests/test_litellm/types/test_types_utils.py | 24 +++++++ 6 files changed, 142 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openai/chat/gpt_transformation.py b/litellm/llms/openai/chat/gpt_transformation.py index 6b7ec4dfb1c..b9fd217f99a 100644 --- a/litellm/llms/openai/chat/gpt_transformation.py +++ b/litellm/llms/openai/chat/gpt_transformation.py @@ -56,6 +56,7 @@ from litellm.types.utils import ( from litellm.utils import convert_to_model_response_object from ..common_utils import OpenAIError +from ..common_utils import patch_deepseek_v4_reasoning_messages if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj @@ -169,6 +170,8 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): ] # works across all models model_specific_params = [] + if "deepseek" in model.lower(): + model_specific_params.extend(["thinking", "reasoning_effort"]) if ( model != "gpt-3.5-turbo-16k" and model != "gpt-4" ): # gpt-4 does not support 'response_format' @@ -435,6 +438,7 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): dict: The transformed request. Sent as the body of the API call. """ messages = self._transform_messages(messages=messages, model=model) + messages = patch_deepseek_v4_reasoning_messages(model=model, messages=messages) messages, tools = self.remove_cache_control_flag_from_messages_and_tools( model=model, messages=messages, tools=optional_params.get("tools", []) ) @@ -460,6 +464,9 @@ class OpenAIGPTConfig(BaseLLMModelInfo, BaseConfig): transformed_messages = await self._transform_messages( messages=messages, model=model, is_async=True ) + transformed_messages = patch_deepseek_v4_reasoning_messages( + model=model, messages=transformed_messages + ) ( transformed_messages, tools, diff --git a/litellm/llms/openai/common_utils.py b/litellm/llms/openai/common_utils.py index c13a976c1b9..a34433240b0 100644 --- a/litellm/llms/openai/common_utils.py +++ b/litellm/llms/openai/common_utils.py @@ -258,6 +258,43 @@ class BaseOpenAILLM: class OpenAICredentials(NamedTuple): api_base: str api_key: Optional[str] + + +def requires_deepseek_v4_reasoning_content(model: Optional[str]) -> bool: + """Return True when the model requires DeepSeek V4 thinking history.""" + if not model: + return False + + normalized_model = model.lower() + if normalized_model.startswith("responses/"): + normalized_model = normalized_model.split("responses/", 1)[1] + + return "deepseek-v4" in normalized_model + + +def patch_deepseek_v4_reasoning_messages( + model: Optional[str], messages: List[Any] +) -> List[Any]: + """ + Ensure assistant tool-call messages include reasoning_content for DeepSeek V4. + + DeepSeek V4 rejects multi-turn requests when prior assistant tool-call messages + omit the reasoning_content field, even if the value is empty. + """ + if not requires_deepseek_v4_reasoning_content(model): + return messages + + for message in messages: + if not isinstance(message, dict): + continue + if message.get("role") != "assistant": + continue + if not (message.get("tool_calls") or message.get("tool_call_id")): + continue + if message.get("reasoning_content") is None: + message["reasoning_content"] = "" + + return messages organization: Optional[str] diff --git a/litellm/llms/openai/openai.py b/litellm/llms/openai/openai.py index 194f29648c4..d79a5f0288f 100644 --- a/litellm/llms/openai/openai.py +++ b/litellm/llms/openai/openai.py @@ -58,6 +58,7 @@ from .chat.o_series_transformation import OpenAIOSeriesConfig from .common_utils import ( BaseOpenAILLM, OpenAIError, + patch_deepseek_v4_reasoning_messages, drop_params_from_unprocessable_entity_error, ) @@ -267,6 +268,7 @@ class OpenAIConfig(BaseConfig): headers: dict, ) -> dict: messages = self._transform_messages(messages=messages, model=model) + messages = patch_deepseek_v4_reasoning_messages(model=model, messages=messages) return {"model": model, "messages": messages, **optional_params} def transform_response( @@ -433,6 +435,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): - call chat.completions.create by default """ start_time = time.time() + data["messages"] = patch_deepseek_v4_reasoning_messages( + model=data.get("model"), messages=data.get("messages", []) + ) try: raw_response = ( await openai_aclient.chat.completions.with_raw_response.create( @@ -474,6 +479,9 @@ class OpenAIChatCompletion(BaseLLM, BaseOpenAILLM): - call chat.completions.create by default """ raw_response = None + data["messages"] = patch_deepseek_v4_reasoning_messages( + model=data.get("model"), messages=data.get("messages", []) + ) try: raw_response = openai_client.chat.completions.with_raw_response.create( **data, timeout=timeout diff --git a/litellm/types/utils.py b/litellm/types/utils.py index a212d56c1ae..41122a79f54 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1221,7 +1221,7 @@ class Message(SafeAttributeModel, OpenAIObject): if hasattr(self, "annotations"): del self.annotations - if reasoning_content is None: + if reasoning_content is None and not getattr(self, "tool_calls", None): # ensure default response matches OpenAI spec if hasattr(self, "reasoning_content"): del self.reasoning_content diff --git a/tests/llm_translation/test_deepseek_completion.py b/tests/llm_translation/test_deepseek_completion.py index da402a51b68..c37a15ab5d4 100644 --- a/tests/llm_translation/test_deepseek_completion.py +++ b/tests/llm_translation/test_deepseek_completion.py @@ -176,3 +176,68 @@ def test_completion_cost_deepseek(): pass except Exception as e: pytest.fail(f"Error occurred: {e}") + + +def test_deepseek_v4_supported_openai_params_include_thinking_controls(): + from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig + + supported_params = OpenAIGPTConfig().get_supported_openai_params( + "deepseek/deepseek-v4-pro" + ) + + assert "thinking" in supported_params + assert "reasoning_effort" in supported_params + + +def test_deepseek_v4_transform_request_injects_reasoning_content_for_tool_calls(): + from litellm.llms.openai.openai import OpenAIConfig + + request = OpenAIConfig().transform_request( + model="deepseek/deepseek-v4-pro", + messages=[ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + ], + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert request["messages"][1]["reasoning_content"] == "" + + +def test_deepseek_reasoner_transform_request_does_not_inject_reasoning_content(): + from litellm.llms.openai.openai import OpenAIConfig + + request = OpenAIConfig().transform_request( + model="deepseek/deepseek-reasoner", + messages=[ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + }, + ], + optional_params={}, + litellm_params={}, + headers={}, + ) + + assert "reasoning_content" not in request["messages"][1] diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index c146847f391..c40b5c505ba 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -310,3 +310,27 @@ def test_delta_maps_reasoning_to_reasoning_content(): # When neither is present, reasoning_content is not set (OpenAI spec) delta4 = Delta(content="hello") assert not hasattr(delta4, "reasoning_content") + + +def test_message_keeps_reasoning_content_slot_for_tool_calls(): + """ + DeepSeek V4 requires reasoning_content to remain available on assistant + tool-call messages so later request transforms can re-inject it. + """ + from litellm.types.utils import Message + + message = Message( + role="assistant", + content=None, + tool_calls=[ + { + "id": "call_1", + "type": "function", + "function": {"name": "lookup", "arguments": "{}"}, + } + ], + reasoning_content=None, + ) + + assert hasattr(message, "reasoning_content") + assert message.reasoning_content is None