fix(deepseek): add empty-string fallback for reasoning_content in cross-provider scenarios (#192)

This commit is contained in:
Xi Zhang
2026-04-27 20:19:50 +02:00
committed by GitHub
parent 52f8d3a3a5
commit 56cc2fef85
2 changed files with 57 additions and 9 deletions
+19 -4
View File
@@ -481,8 +481,18 @@ def _patch_deepseek_reasoning_passback(model: Any) -> None:
message to carry its reasoning_content as a top-level field (sibling to
content / tool_calls). Without this, multi-turn requests fail with 400.
For deepseek-reasoner models, also injects an empty reasoning_content
when none is present (DeepSeek API requires the field even if empty).
For assistant messages where no reasoning_content was captured (e.g.
history left over from another provider, from DeepSeek Flash, or from an
older EvoSci version that ran before the capture patch landed), we
inject an empty string. This satisfies DeepSeek's format requirement
in thinking mode. Non-thinking DeepSeek endpoints are believed to
accept the extra field without complaint based on observed behavior,
but this has not been independently audited; if a future DeepSeek
release rejects empty reasoning_content on non-thinking models, this
fallback would need a per-call thinking-mode check instead of a blanket
inject. The check is intentionally not gated on model name: this
function is only mounted when provider == "deepseek" (see
EvoScientist/llm/models.py), so all callers are DeepSeek endpoints.
Args:
model: A langchain-openai ChatOpenAI instance configured for DeepSeek.
@@ -523,7 +533,6 @@ def _patch_deepseek_reasoning_passback(model: Any) -> None:
if not isinstance(msgs, list):
return payload
is_reasoner = "deepseek-reasoner" in str(getattr(model, "model_name", ""))
ai_idx = 0
for msg in msgs:
if not isinstance(msg, dict) or msg.get("role") != "assistant":
@@ -531,7 +540,13 @@ def _patch_deepseek_reasoning_passback(model: Any) -> None:
rc = ai_rcs[ai_idx] if ai_idx < len(ai_rcs) else None
if rc:
msg["reasoning_content"] = rc
elif is_reasoner and "reasoning_content" not in msg:
elif "reasoning_content" not in msg:
# Empty-string fallback for ALL DeepSeek models (not just
# reasoner). Required when history contains AI messages that
# came from a different provider (Anthropic / OpenAI /
# DeepSeek Flash) or from an older EvoSci that didn't capture
# reasoning_content. Empirically tolerated by non-thinking
# DeepSeek endpoints; see docstring for the audit caveat.
msg["reasoning_content"] = ""
ai_idx += 1
+38 -5
View File
@@ -879,7 +879,7 @@ class TestPatchDeepseekReasoningPassback:
assert payload["messages"][1]["reasoning_content"] == ""
def test_no_injection_for_non_reasoner_without_rc(self):
def test_empty_fallback_for_non_reasoner_without_rc(self):
from langchain_core.messages import AIMessage, HumanMessage
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
@@ -894,7 +894,9 @@ class TestPatchDeepseekReasoningPassback:
]
payload = model._get_request_payload(messages)
assert "reasoning_content" not in payload["messages"][1]
# Empty-string fallback applies to ALL DeepSeek models (not just
# reasoner) so cross-provider history doesn't trigger 400.
assert payload["messages"][1]["reasoning_content"] == ""
def test_handles_multiple_ai_messages(self):
from langchain_core.messages import AIMessage, HumanMessage
@@ -986,7 +988,7 @@ class TestPatchDeepseekReasoningPassback:
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
model = self._make_model(
model_name="deepseek-v4-pro", # NOT reasoner — empty fallback off
model_name="deepseek-v4-pro",
payload_messages=[
{"role": "user", "content": "q1"},
{"role": "assistant", "content": "a1"}, # no rc
@@ -1009,8 +1011,8 @@ class TestPatchDeepseekReasoningPassback:
]
payload = model._get_request_payload(messages)
# First AI msg: no rc → not injected (V4 Pro doesn't get empty fallback)
assert "reasoning_content" not in payload["messages"][1]
# First AI msg: no rc → empty-string fallback (covers cross-model switch)
assert payload["messages"][1]["reasoning_content"] == ""
# Second AI msg: has rc → injected
assert payload["messages"][3]["reasoning_content"] == "rc2"
@@ -1045,6 +1047,37 @@ class TestPatchDeepseekReasoningPassback:
assert "input" in payload
assert "messages" not in payload
def test_cross_provider_switch_history(self):
"""User chats with Anthropic/OpenAI then switches to DeepSeek V4 Pro.
Historical AI messages have no reasoning_content (the previous
provider never produced it). The patch must inject an empty-string
fallback so DeepSeek doesn't 400 on
"reasoning_content must be passed back to the API".
"""
from langchain_core.messages import AIMessage, HumanMessage
from EvoScientist.llm.patches import _patch_deepseek_reasoning_passback
model = self._make_model(
model_name="deepseek-v4-pro",
payload_messages=[
{"role": "user", "content": "earlier question to anthropic"},
{"role": "assistant", "content": "anthropic answer"},
{"role": "user", "content": "now ask deepseek pro"},
],
)
_patch_deepseek_reasoning_passback(model)
messages = [
HumanMessage("earlier question to anthropic"),
AIMessage(content="anthropic answer"), # no reasoning_content
HumanMessage("now ask deepseek pro"),
]
payload = model._get_request_payload(messages)
assert payload["messages"][1]["reasoning_content"] == ""
# =============================================================================
# Test _patch_openai_capture_reasoning_content (module-level monkey-patch)