From 56cc2fef8525b41347a13adaef41a0f36d334a5d Mon Sep 17 00:00:00 2001 From: Xi Zhang <106144707+X-iZhang@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:19:50 +0200 Subject: [PATCH] fix(deepseek): add empty-string fallback for reasoning_content in cross-provider scenarios (#192) --- EvoScientist/llm/patches.py | 23 ++++++++++++++++---- tests/test_llm.py | 43 ++++++++++++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 9 deletions(-) diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index fe95afb..613e0e5 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -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 diff --git a/tests/test_llm.py b/tests/test_llm.py index 9a4c24f..078bc72 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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)