From 82b8a3af7e1a73baf0930d1ae27138edc8aecbfb Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sun, 26 Apr 2026 12:47:09 +0000 Subject: [PATCH] fix(provider): handle incomplete DeepSeek reasoning history --- nanobot/providers/openai_compat_provider.py | 45 +++++++++++++ tests/providers/test_litellm_kwargs.py | 75 +++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 558093822..fdbad585c 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -384,6 +384,47 @@ class OpenAICompatProvider(LLMProvider): clean["tool_call_id"] = map_id(clean["tool_call_id"]) return self._enforce_role_alternation(sanitized) + def _drop_deepseek_incomplete_reasoning_history( + self, + messages: list[dict[str, Any]], + reasoning_effort: str | None, + ) -> list[dict[str, Any]]: + if ( + not self._spec + or self._spec.name != "deepseek" + or not reasoning_effort + or reasoning_effort.lower() == "none" + ): + return messages + + bad_idx = None + for idx, msg in enumerate(messages): + if ( + msg.get("role") == "assistant" + and msg.get("tool_calls") + and not msg.get("reasoning_content") + ): + bad_idx = idx + if bad_idx is None: + return messages + + keep_from = None + for idx in range(bad_idx + 1, len(messages)): + if messages[idx].get("role") == "user": + keep_from = idx + break + + if keep_from is None: + trimmed = messages[:bad_idx] + else: + prefix = [msg for msg in messages[:keep_from] if msg.get("role") == "system"] + trimmed = prefix + messages[keep_from:] + logger.warning( + "Dropped {} DeepSeek thinking history message(s) with incomplete reasoning_content", + len(messages) - len(trimmed), + ) + return trimmed + # ------------------------------------------------------------------ # Build kwargs # ------------------------------------------------------------------ @@ -424,6 +465,10 @@ class OpenAICompatProvider(LLMProvider): if spec and spec.strip_model_prefix: model_name = model_name.split("/")[-1] + messages = self._drop_deepseek_incomplete_reasoning_history( + messages, + reasoning_effort, + ) kwargs: dict[str, Any] = { "model": model_name, "messages": self._sanitize_messages(self._sanitize_empty_content(messages)), diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index dfa0f58ac..1c3cfb851 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -585,6 +585,81 @@ def test_openai_compat_preserves_message_level_reasoning_fields() -> None: assert sanitized[1]["tool_calls"][0]["extra_content"] == {"google": {"thought_signature": "sig"}} +def _deepseek_kwargs(messages: list[dict]) -> dict: + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + provider = OpenAICompatProvider( + api_key="sk-test", + default_model="deepseek-v4-flash", + spec=find_by_name("deepseek"), + ) + + return provider._build_kwargs( + messages=messages, + tools=None, + model="deepseek-v4-flash", + max_tokens=1024, + temperature=0.7, + reasoning_effort="high", + tool_choice=None, + ) + + +def _tool_call(call_id: str) -> dict: + return { + "id": call_id, + "type": "function", + "function": {"name": "my", "arguments": "{}"}, + } + + +def test_deepseek_thinking_drops_tool_history_missing_reasoning_content() -> None: + kwargs = _deepseek_kwargs([ + {"role": "system", "content": "system"}, + {"role": "user", "content": "can we use wechat?"}, + {"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]}, + {"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"}, + {"role": "user", "content": "continue"}, + ]) + + assert kwargs["messages"] == [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "continue"}, + ] + + +def test_deepseek_thinking_keeps_tool_history_with_reasoning_content() -> None: + kwargs = _deepseek_kwargs([ + {"role": "user", "content": "can we use wechat?"}, + { + "role": "assistant", + "content": "", + "reasoning_content": "I should inspect supported channels.", + "tool_calls": [_tool_call("call_good")], + }, + {"role": "tool", "tool_call_id": "call_good", "name": "my", "content": "channels"}, + {"role": "user", "content": "continue"}, + ]) + + assistant = kwargs["messages"][1] + assert assistant["role"] == "assistant" + assert assistant["reasoning_content"] == "I should inspect supported channels." + assert kwargs["messages"][2]["role"] == "tool" + + +def test_deepseek_thinking_drops_current_bad_tool_turn_without_followup_user() -> None: + kwargs = _deepseek_kwargs([ + {"role": "system", "content": "system"}, + {"role": "user", "content": "can we use wechat?"}, + {"role": "assistant", "content": "", "tool_calls": [_tool_call("call_bad")]}, + {"role": "tool", "tool_call_id": "call_bad", "name": "my", "content": "channels"}, + ]) + + assert kwargs["messages"] == [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "can we use wechat?"}, + ] + + def test_openai_compat_keeps_tool_calls_after_consecutive_assistant_messages() -> None: with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): provider = OpenAICompatProvider()