mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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:
parent
36685849a0
commit
e73bbb5e9a
2 changed files with 212 additions and 1 deletions
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue