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:
@@ -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