fix(deepseek): forward reasoning_content in multi-turn thinking mode conversations (#28080)

Squash-merged by litellm-agent from Divyansh8321's PR.
This commit is contained in:
Divyansh Singhal 2026-05-17 02:10:20 +05:30 • committed by Sameer Kankute
parent 36685849a0
commit e73bbb5e9a
No known key found for this signature in database
2 changed files with 212 additions and 1 deletions

View file

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

View file

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