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
+33 -13
View File
@@ -58,6 +58,15 @@ _KIMI_THINKING_MODELS: frozenset[str] = frozenset({
"k2.6-code-preview",
})
# Maps ProviderSpec.thinking_style → extra_body builder.
# Each builder takes a bool (thinking_enabled) and returns the dict to
# merge into extra_body, keeping the style→wire-format mapping in one place.
_THINKING_STYLE_MAP: dict[str, Any] = {
"thinking_type": lambda on: {"thinking": {"type": "enabled" if on else "disabled"}},
"enable_thinking": lambda on: {"enable_thinking": on},
"reasoning_split": lambda on: {"reasoning_split": on},
}
def _is_kimi_thinking_model(model_name: str) -> bool:
"""Return True if model_name refers to a Kimi thinking-capable model.
@@ -407,20 +416,11 @@ class OpenAICompatProvider(LLMProvider):
# Provider-specific thinking parameters.
# Only sent when reasoning_effort is explicitly configured so that
# the provider default is preserved otherwise.
if spec and reasoning_effort is not None:
# The mapping is driven by ProviderSpec.thinking_style so that adding
# a new provider never requires touching this function.
if spec and spec.thinking_style and reasoning_effort is not None:
thinking_enabled = semantic_effort != "minimal"
extra: dict[str, Any] | None = None
if spec.name == "dashscope":
extra = {"enable_thinking": thinking_enabled}
elif spec.name == "minimax":
extra = {"reasoning_split": thinking_enabled}
elif spec.name in (
"volcengine", "volcengine_coding_plan",
"byteplus", "byteplus_coding_plan",
):
extra = {
"thinking": {"type": "enabled" if thinking_enabled else "disabled"}
}
extra = _THINKING_STYLE_MAP.get(spec.thinking_style, lambda _: None)(thinking_enabled)
if extra:
kwargs.setdefault("extra_body", {}).update(extra)
@@ -438,6 +438,26 @@ class OpenAICompatProvider(LLMProvider):
kwargs["tools"] = tools
kwargs["tool_choice"] = tool_choice or "auto"
# Backfill reasoning_content on legacy assistant messages.
# DeepSeek V4 (and potentially others) rejects thinking-mode
# requests that contain assistant messages without reasoning_content
# — even on turns that had no tool calls. This happens when a
# session was started with a non-thinking model or without
# reasoning_effort, then the user switches thinking mode on
# mid-session. Injecting an empty string satisfies the API
# without altering semantics (the model treats it as "no
# thinking happened on that turn").
thinking_active = (
(spec and spec.thinking_style and reasoning_effort is not None
and semantic_effort != "minimal")
or (reasoning_effort is not None and _is_kimi_thinking_model(model_name)
and semantic_effort != "minimal")
)
if thinking_active:
for msg in kwargs["messages"]:
if msg.get("role") == "assistant" and "reasoning_content" not in msg:
msg["reasoning_content"] = ""
return kwargs
def _should_use_responses_api(
+15
View File
@@ -63,6 +63,14 @@ class ProviderSpec:
# Provider supports cache_control on content blocks (e.g. Anthropic prompt caching)
supports_prompt_caching: bool = False
# How to inject the thinking on/off toggle into extra_body.
# "" — no extra_body needed (default)
# "thinking_type" — {"thinking": {"type": "enabled"/"disabled"}}
# (DeepSeek, VolcEngine, BytePlus)
# "enable_thinking" — {"enable_thinking": true/false} (DashScope)
# "reasoning_split" — {"reasoning_split": true/false} (MiniMax)
thinking_style: str = ""
@property
def label(self) -> str:
return self.display_name or self.name.title()
@@ -143,6 +151,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_gateway=True,
detect_by_base_keyword="volces",
default_api_base="https://ark.cn-beijing.volces.com/api/v3",
thinking_style="thinking_type",
),
# VolcEngine Coding Plan (火山引擎 Coding Plan): same key as volcengine
@@ -155,6 +164,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_gateway=True,
default_api_base="https://ark.cn-beijing.volces.com/api/coding/v3",
strip_model_prefix=True,
thinking_style="thinking_type",
),
# BytePlus: VolcEngine international, pay-per-use models
@@ -168,6 +178,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
detect_by_base_keyword="bytepluses",
default_api_base="https://ark.ap-southeast.bytepluses.com/api/v3",
strip_model_prefix=True,
thinking_style="thinking_type",
),
# BytePlus Coding Plan: same key as byteplus
@@ -180,6 +191,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
is_gateway=True,
default_api_base="https://ark.ap-southeast.bytepluses.com/api/coding/v3",
strip_model_prefix=True,
thinking_style="thinking_type",
),
@@ -233,6 +245,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="DeepSeek",
backend="openai_compat",
default_api_base="https://api.deepseek.com",
thinking_style="thinking_type",
),
# Gemini: Google's OpenAI-compatible endpoint
ProviderSpec(
@@ -261,6 +274,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="DashScope",
backend="openai_compat",
default_api_base="https://dashscope.aliyuncs.com/compatible-mode/v1",
thinking_style="enable_thinking",
),
# Moonshot (月之暗面): Kimi K2.5 / K2.6 enforce temperature >= 1.0.
ProviderSpec(
@@ -283,6 +297,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
display_name="MiniMax",
backend="openai_compat",
default_api_base="https://api.minimax.io/v1",
thinking_style="reasoning_split",
),
# MiniMax Anthropic-compatible endpoint: supports thinking mode
ProviderSpec(
+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")