Fix DeepSeek V4 reasoning_content in multi-turn chat

This commit is contained in:
cdxiaodong 2026-04-28 16:37:57 +08:00
parent 3d2b8fed32
commit a3dbffa8e7
6 changed files with 142 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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