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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user