From 43a58335f6e7f22daee64ce9839432dea85b06fb Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Fri, 1 May 2026 11:48:57 +0000 Subject: [PATCH] fix(provider): narrow DeepSeek reasoning history cleanup Made-with: Cursor --- nanobot/providers/openai_compat_provider.py | 18 +++++-- tests/providers/test_litellm_kwargs.py | 58 +++++++++++++++++++-- 2 files changed, 67 insertions(+), 9 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 251568af..decdccb3 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -452,6 +452,7 @@ class OpenAICompatProvider(LLMProvider): def _drop_deepseek_incomplete_reasoning_history( self, messages: list[dict[str, Any]], + model_name: str, reasoning_effort: str | None, ) -> list[dict[str, Any]]: if ( @@ -460,13 +461,19 @@ class OpenAICompatProvider(LLMProvider): ): return messages - # For DeepSeek models, always check for incomplete reasoning history - # when thinking mode might be active. reasoning_effort may not be set - # explicitly but the model could still be using thinking mode by default. - # Only skip this check when reasoning_effort is explicitly set to "none". - if reasoning_effort is not None and reasoning_effort.lower() == "none": + semantic_effort = reasoning_effort.lower() if isinstance(reasoning_effort, str) else None + if semantic_effort in {"none", "minimal", "minimum"}: return messages + # DeepSeek-V4 can require reasoning_content even when the config did + # not explicitly request reasoning_effort. Keep that implicit-thinking + # cleanup scoped to known thinking-capable DeepSeek models so normal + # deepseek-chat history is not trimmed. + if semantic_effort is None: + model_lower = model_name.lower() + if not any(token in model_lower for token in ("deepseek-v4", "deepseek-reasoner")): + return messages + bad_idx = None for idx, msg in enumerate(messages): if ( @@ -537,6 +544,7 @@ class OpenAICompatProvider(LLMProvider): messages = self._drop_deepseek_incomplete_reasoning_history( messages, + model_name, reasoning_effort, ) kwargs: dict[str, Any] = { diff --git a/tests/providers/test_litellm_kwargs.py b/tests/providers/test_litellm_kwargs.py index d3b03c60..a3b62417 100644 --- a/tests/providers/test_litellm_kwargs.py +++ b/tests/providers/test_litellm_kwargs.py @@ -913,8 +913,8 @@ def test_deepseek_backfills_reasoning_content_on_legacy_tool_call_messages() -> assert msg["reasoning_content"] == "" -def test_backfill_does_not_touch_messages_when_thinking_off() -> None: - """When reasoning_effort is None or minimal, legacy messages must NOT be altered.""" +def test_backfill_does_not_touch_messages_when_thinking_explicitly_off() -> None: + """When thinking is explicitly disabled, legacy messages must NOT be altered.""" spec = find_by_name("deepseek") with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) @@ -926,7 +926,7 @@ def test_backfill_does_not_touch_messages_when_thinking_off() -> None: {"role": "tool", "tool_call_id": "tc1", "content": "result"}, {"role": "user", "content": "thanks"}, ] - for effort in (None, "minimal"): + for effort in ("minimal", "none"): kw = p._build_kwargs( messages=list(messages), tools=None, model="deepseek-v4-pro", max_tokens=1024, temperature=0.7, @@ -937,6 +937,56 @@ def test_backfill_does_not_touch_messages_when_thinking_off() -> None: assert "reasoning_content" not in msg +def test_deepseek_v4_drops_incomplete_reasoning_history_when_effort_implicit() -> None: + """DeepSeek-V4 may default to thinking, so incomplete legacy history is trimmed.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-v4-pro", spec=spec) + messages = [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "", "tool_calls": [ + {"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "tc1", "content": "result"}, + {"role": "user", "content": "thanks"}, + ] + + kw = p._build_kwargs( + messages=list(messages), tools=None, model="deepseek-v4-pro", + max_tokens=1024, temperature=0.7, + reasoning_effort=None, tool_choice=None, + ) + + assert [msg["role"] for msg in kw["messages"]] == ["system", "user"] + assert kw["messages"][-1]["content"] == "thanks" + + +def test_deepseek_chat_keeps_tool_history_when_effort_implicit() -> None: + """Implicit cleanup must not trim non-thinking DeepSeek chat models.""" + spec = find_by_name("deepseek") + with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI"): + p = OpenAICompatProvider(api_key="k", default_model="deepseek-chat", spec=spec) + messages = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "", "tool_calls": [ + {"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "tc1", "content": "result"}, + {"role": "user", "content": "thanks"}, + ] + + kw = p._build_kwargs( + messages=list(messages), tools=None, model="deepseek-chat", + max_tokens=1024, temperature=0.7, + reasoning_effort=None, tool_choice=None, + ) + + roles = [msg["role"] for msg in kw["messages"]] + assert roles == ["user", "assistant", "tool", "user"] + assert kw["messages"][1]["tool_calls"] + + def test_deepseek_coerces_list_content_to_string() -> None: """DeepSeek chat endpoint expects message.content to be a string.""" spec = find_by_name("deepseek") @@ -1061,7 +1111,7 @@ def test_kimi_k2_thinking_series_no_thinking_injection() -> None: # --------------------------------------------------------------------------- -# reasoning_effort="none" — treated as thinking disabled +# reasoning_effort="none" — treated as thinking disabled # --------------------------------------------------------------------------- def test_deepseek_thinking_disabled_for_none_string() -> None: