fix(deepseek): add empty-string fallback for reasoning_content in cross-provider scenarios (#192)
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user