Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 88 additions & 22 deletions plugins/model-providers/deepseek/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,55 @@
"""DeepSeek provider profile.

DeepSeek's V4 family (and the legacy ``deepseek-reasoner``) defaults to
thinking-mode ON when ``extra_body.thinking`` is unset. The API then returns
``reasoning_content`` and starts enforcing the contract that subsequent turns
echo it back; combined with how Hermes replays history this lands on the
notorious HTTP 400 ``reasoning_content must be passed back`` error after the
first tool call (#15700, #17212, #17825).
History
-------

This profile overrides :meth:`build_api_kwargs_extras` to mirror the Kimi /
Moonshot wire shape that DeepSeek's OpenAI-compat endpoint expects:
The original version of this profile (#15700, #17212, #17825) mirrored the
Kimi / Moonshot wire shape DeepSeek's OpenAI-compat endpoint used to
accept:

{"reasoning_effort": "<low|medium|high|max>",
"extra_body": {"thinking": {"type": "enabled" | "disabled"}}}

Non-thinking models (only ``deepseek-chat`` today, which is V3) are left as
and injected ``extra_body.thinking = {"type": "enabled"}`` on every
request that targeted a V4-family or ``deepseek-reasoner`` model, so the
``reasoning_content`` echo-back contract enforced by the API would line
up with Hermes' history-replay code.

#30818 — what changed
---------------------

The DeepSeek V4 API (``deepseek-v4-flash`` / ``deepseek-v4-pro``, all
plans, both ``api.deepseek.com`` and ``api.deepseek.com/v1``) now
rejects the ``thinking`` field with HTTP 400 on the very first message,
before any tool calls or history exist. A literal ``curl`` against the
same endpoint with the same key and message succeeds, and switching to
``provider: custom`` + ``api_mode: openai-completions`` — which bypasses
this profile entirely — also succeeds, confirming the smoking gun is
the unconditional ``extra_body.thinking`` injection.

The reasoning-content echo concern that motivated injecting
``extra_body.thinking`` in the first place is already covered on the
RESPONSE side: ``agent/chat_completion_helpers.build_assistant_message``
pads assistant tool-call messages with ``reasoning_content = " "`` (or
the captured reasoning text) for every thinking-mode provider when the
SDK didn't surface it explicitly — see ``_needs_deepseek_tool_reasoning``
in ``run_agent.py``. No request-side flag is needed to keep that path
working.

Current behavior
----------------

* Default (no ``reasoning_config`` provided) — emit nothing extra. The
DeepSeek API applies its server-side defaults and the request succeeds.
* User opt-in (``reasoning_config={"enabled": True/False}``) — still
forward the Kimi-style ``extra_body.thinking`` shape. Users who
explicitly want to control thinking can do so; users who didn't ask
for it don't get a 400.
* ``reasoning_effort`` (top-level) — only forwarded when the user
configured ``reasoning_config.effort`` explicitly, never injected
by default.

Non-thinking models (only ``deepseek-chat`` today, which is V3) remain
no-ops so we don't perturb the V3 wire format.
"""

Expand Down Expand Up @@ -44,8 +80,32 @@ def _model_supports_thinking(model: str | None) -> bool:
return False


def _user_opted_into_thinking_config(reasoning_config: dict | None) -> bool:
"""Return True when the user explicitly configured thinking mode.

Distinguishing "user didn't ask" from "user passed an empty dict" is
what lets the default path stay quiet (no ``extra_body.thinking``
emitted at all — see #30818) while still honouring an explicit opt-in.

Only ``reasoning_config.enabled`` toggles the legacy Kimi-style
``thinking`` payload; ``reasoning_config.effort`` controls
``reasoning_effort`` separately and does NOT imply
``extra_body.thinking`` on its own (effort can be set with the
server default thinking behavior left untouched).
"""
if not isinstance(reasoning_config, dict):
return False
return "enabled" in reasoning_config


class DeepSeekProfile(ProviderProfile):
"""DeepSeek — extra_body.thinking + top-level reasoning_effort."""
"""DeepSeek — opt-in extra_body.thinking + opt-in reasoning_effort.

See module docstring for the full rationale. The short version: the
DeepSeek V4 native API returns HTTP 400 when an unconfigured client
sends ``extra_body.thinking``, so we only forward it when the user
explicitly opted in via ``reasoning_config.enabled``.
"""

def build_api_kwargs_extras(
self, *, reasoning_config: dict | None = None, model: str | None = None, **context
Expand All @@ -57,21 +117,27 @@ def build_api_kwargs_extras(
# V3 / unknown — leave wire format untouched, current behavior.
return extra_body, top_level

# Determine enabled/disabled. Default is enabled to match DeepSeek's
# API default; the API requires this to be set explicitly to avoid the
# reasoning_content echo trap on subsequent turns.
enabled = True
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is False:
enabled = False

extra_body["thinking"] = {"type": "enabled" if enabled else "disabled"}

if not enabled:
return extra_body, top_level
# #30818 — only emit ``extra_body.thinking`` when the user
# explicitly configured ``reasoning_config.enabled``. The
# DeepSeek V4 native API rejects the field outright on first
# use, so injecting it by default would break ``provider:
# deepseek`` for every user with the default config. Users who
# depend on the Kimi-style explicit toggle can still opt in.
if _user_opted_into_thinking_config(reasoning_config):
enabled = reasoning_config.get("enabled") is not False
extra_body["thinking"] = {
"type": "enabled" if enabled else "disabled"
}
if not enabled:
# Disabled thinking → no reasoning_effort either.
return extra_body, top_level

# Effort mapping. Pass low/medium/high through; xhigh/max → max.
# When no effort is set we omit reasoning_effort so DeepSeek applies
# its server default (currently high).
# its server default (currently high). This branch fires
# whether or not the user opted into ``extra_body.thinking`` —
# effort can be tuned independently as long as the user knows
# the model supports it.
if isinstance(reasoning_config, dict):
effort = (reasoning_config.get("effort") or "").strip().lower()
if effort in {"xhigh", "max"}:
Expand Down
Loading
Loading