fix(provider): add DeepSeek thinking toggle; backfill reasoning_content on legacy messages

Two issues with DeepSeek V4 thinking mode support:

1. Missing thinking parameter injection.
   DeepSeek V4 requires `extra_body: {"thinking": {"type": "enabled/disabled"}}`
   — identical to VolcEngine/BytePlus. The code had this for volcengine,
   byteplus, dashscope, minimax, and kimi but not DeepSeek. This means
   `reasoning_effort=minimal` (thinking off) silently has no effect.

   Root cause: the thinking-style→wire-format mapping was an if/elif chain
   on provider *names*. DeepSeek was forgotten.

   Fix: make the mapping declarative via `ProviderSpec.thinking_style`:
   - "thinking_type" → {"thinking": {"type": "..."}} (DeepSeek, Volc, BytePlus)
   - "enable_thinking" → {"enable_thinking": bool} (DashScope)
   - "reasoning_split" → {"reasoning_split": bool} (MiniMax)
   `_build_kwargs` now does a single dict lookup. Adding a new provider
   with an existing wire format requires zero changes to the function.

2. Legacy session messages crash thinking-mode requests.
   When a session was started without thinking mode (or with a different
   model), assistant messages lack reasoning_content. DeepSeek V4 in
   thinking mode rejects these with 400:
   "The reasoning_content in the thinking mode must be passed back to the API."
   This affects ALL assistant messages, not just those with tool_calls
   (despite the docs only mentioning the tool_calls case).

   Fix: `_build_kwargs` backfills `reasoning_content: ""` on every
   assistant message missing it, but only when thinking mode is active.
   This is semantically neutral — the model treats empty reasoning_content
   as "no thinking happened on that turn". The backfill only touches the
   in-memory request copy; session files on disk are untouched.

Tests: +5 (3 thinking toggle, 2 backfill). Full suite: 2377 passed.
Made-with: Cursor
This commit is contained in:
Xubin Ren
2026-04-24 15:06:39 +08:00
committed by Xubin Ren
parent 4531167c12
commit 7f1913f619
3 changed files with 117 additions and 13 deletions
+69
View File
@@ -785,6 +785,75 @@ def test_byteplus_no_extra_body_when_reasoning_effort_none() -> None:
assert "extra_body" not in kw
def test_deepseek_thinking_enabled() -> None:
"""DeepSeek V4 requires extra_body.thinking when reasoning_effort is set."""
kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="high")
assert kw["extra_body"] == {"thinking": {"type": "enabled"}}
def test_deepseek_thinking_disabled_for_minimal() -> None:
"""reasoning_effort='minimal' must send thinking.type=disabled to DeepSeek."""
kw = _build_kwargs_for("deepseek", "deepseek-v4-pro", reasoning_effort="minimal")
assert kw["extra_body"] == {"thinking": {"type": "disabled"}}
def test_deepseek_no_extra_body_when_reasoning_effort_none() -> None:
"""Without reasoning_effort the thinking param must not be injected."""
kw = _build_kwargs_for("deepseek", "deepseek-chat", reasoning_effort=None)
assert "extra_body" not in kw
def test_deepseek_backfills_reasoning_content_on_legacy_tool_call_messages() -> None:
"""Session messages from before thinking mode was enabled may have assistant
messages with tool_calls but no reasoning_content. DeepSeek V4 rejects these
with 400. _build_kwargs must backfill reasoning_content='' on them."""
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": "user", "content": "search for news"},
{"role": "assistant", "content": "", "tool_calls": [
{"id": "tc1", "type": "function", "function": {"name": "web_search", "arguments": "{}"}}
]},
{"role": "tool", "tool_call_id": "tc1", "content": "result"},
{"role": "assistant", "content": "Here are the results."},
{"role": "user", "content": "hi"},
]
kw = p._build_kwargs(
messages=messages, tools=None, model="deepseek-v4-pro",
max_tokens=1024, temperature=0.7,
reasoning_effort="high", tool_choice=None,
)
for msg in kw["messages"]:
if msg.get("role") == "assistant":
assert "reasoning_content" in msg, "legacy assistant message missing reasoning_content"
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."""
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": "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"},
]
for effort in (None, "minimal"):
kw = p._build_kwargs(
messages=list(messages), tools=None, model="deepseek-v4-pro",
max_tokens=1024, temperature=0.7,
reasoning_effort=effort, tool_choice=None,
)
for msg in kw["messages"]:
if msg.get("role") == "assistant" and msg.get("tool_calls"):
assert "reasoning_content" not in msg
def test_openai_no_thinking_extra_body() -> None:
"""Non-thinking providers should never get extra_body for thinking."""
kw = _build_kwargs_for("openai", "gpt-4o", reasoning_effort="medium")