fix(deepseek): make extra_body.thinking opt-in to unblock V4 first call (#30818) - #30832
Open
xxxigm wants to merge 2 commits into
Open
fix(deepseek): make extra_body.thinking opt-in to unblock V4 first call (#30818)#30832xxxigm wants to merge 2 commits into
xxxigm wants to merge 2 commits into
Conversation
…ll (NousResearch#30818) ``provider: deepseek`` with any V4 family model (``deepseek-v4-flash``, ``deepseek-v4-pro``, …) returns HTTP 400 on the very first message — not on multi-turn, not on tool calls, just a plain "hello" against ``https://api.deepseek.com``. ``curl`` with the same key/model succeeds, and switching to ``provider: custom`` + ``api_mode: openai-completions`` — which bypasses this profile entirely — also succeeds. That points squarely at the profile's per-request extras. The profile was unconditionally injecting extra_body["thinking"] = {"type": "enabled" | "disabled"} for every V4-family model regardless of the user's ``reasoning_config``. The OpenAI SDK unwraps ``extra_body`` into the top-level request body, so DeepSeek's V4 native API saw an unrecognized top-level ``thinking`` field and rejected the request. The reasoning-content echo concern that originally motivated injecting ``extra_body.thinking`` (NousResearch#15700, NousResearch#17212, NousResearch#17825) is already covered on the RESPONSE side: ``build_assistant_message`` pads assistant tool-call messages with ``reasoning_content`` whenever the active provider is DeepSeek thinking mode (see ``_needs_deepseek_tool_reasoning`` in ``run_agent.py``). No request-side flag is needed to keep that path working. Behavior after this change: * Default (``reasoning_config`` not set, or set without an ``enabled`` key) — emit nothing. DeepSeek applies its server-side defaults and the request succeeds. Fixes the symptom in NousResearch#30818. * Explicit opt-in (``reasoning_config={"enabled": True/False, ...}``) — still forward the Kimi-style ``extra_body.thinking`` payload so the pre-NousResearch#30818 contract is preserved for users who actually configured it. * ``reasoning_effort`` — forwarded when the user sets ``reasoning_config.effort``, regardless of whether they opted into the thinking toggle. Effort and thinking are now independent parameters (they were coupled before only by accident of the unconditional injection above). Non-thinking models (``deepseek-chat`` / V3 family) remain untouched no-ops so V3 wire format is unchanged.
…arch#30818) 28 tests across five classes: * ``TestDefaultPathNoThinkingInjected`` (7) — the exact scenario the bug reporter hit: a V4 model with no ``reasoning_config`` (or with ``reasoning_config={}``, the shape ``hermes_cli/config.py`` produces when the ``reasoning:`` section is empty) emits NO ``extra_body.thinking`` and NO ``reasoning_effort``. Parametrised over ``deepseek-v4-flash``, ``-v4-pro``, ``-v4-experimental``, ``-v5-flash`` (forward compat for the next-gen family) and the legacy ``deepseek-reasoner``. Also pins the independence of ``reasoning_config.effort`` from the ``thinking`` toggle: setting effort alone forwards ``reasoning_effort`` but must not silently re-enable the smoking-gun ``extra_body.thinking`` field. * ``TestExplicitThinkingOptIn`` (8) — the legacy Kimi-style payload still works for users who configured ``reasoning_config.enabled`` explicitly: enabled-True → ``{"type": "enabled"}``, enabled-False → ``{"type": "disabled"}`` AND ``reasoning_effort`` is dropped (no point picking an effort when thinking is off), enabled-True with effort forwards both, enabled-True without effort lets the server pick its default, invalid effort levels are silently dropped. * ``TestV3AndNonThinkingModelsUnchanged`` (5) — V3 chat / coder models stay untouched in BOTH the default path and the opt-in path (a V3 user with an explicit thinking opt-in must not get ``extra_body.thinking`` either, because V3 has no thinking mode and forwarding the field would re-introduce the same class of HTTP 400 the fix avoids for V4). * ``TestProfileMetadataUnchanged`` (5) — static pins so a refactor that breaks the profile identity (rename, deletion, base_url change, alias loss, ``_model_supports_thinking`` mis-classification) fails here instead of silently breaking every DeepSeek user. * ``TestSourceGuards`` (2) — read the profile source and assert the structural contract: ``_user_opted_into_thinking_config`` still exists and the ``extra_body['thinking']`` assignment is preceded by it within a 200-char window. A future refactor that moves the assignment outside the guard would fail here even if it happened to slip past the behavioural tests above. Also pins that the docstring keeps citing NousResearch#30818.
Collaborator
This was referenced Jul 28, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes #30818:
provider: deepseekwith any V4-family model (deepseek-v4-flash,deepseek-v4-pro, …) returns HTTP 400 on the very first message — not on multi-turn, not on tool calls, just a plain "hello" againsthttps://api.deepseek.com.curlwith the same key/model succeeds, and switching toprovider: custom+api_mode: openai-completions(which bypasses the profile entirely) also succeeds. That points squarely at the profile's per-request extras.The DeepSeek profile was unconditionally injecting
extra_body["thinking"] = {"type": "enabled"}for every V4-family model regardless of the user'sreasoning_config. The OpenAI SDK unwrapsextra_bodyinto the top-level request body, so DeepSeek's V4 native API saw an unrecognized top-levelthinkingfield and rejected the request with 400.This PR makes the
extra_body.thinkinginjection opt-in — only forwarded when the user explicitly setsreasoning_config.enabled— so the default path emits no extras and DeepSeek's server-side defaults apply (matching thecurlbehaviour). Users who explicitly want the Kimi-style toggle still get it.Related Issue
Closes #30818
Type of Change
Changes Made
plugins/model-providers/deepseek/__init__.py(+88, −22):extra_body.thinkingis opt-in._user_opted_into_thinking_config(reasoning_config)helper that returns True iffreasoning_configis a dict containing anenabledkey. This is the single gate that distinguishes "user didn't ask" from "user passed an empty dict" — letting the default path stay quiet while still honouring an explicit opt-in.DeepSeekProfile.build_api_kwargs_extras:deepseek-reasoner+ opt-in (reasoning_config={"enabled": True/False, ...}) — still emit the Kimi-styleextra_body["thinking"] = {"type": "enabled" | "disabled"}payload.deepseek-reasoner+ DEFAULT (noreasoning_config, or noenabledkey) — emit NOTHING forextra_body.thinking. DeepSeek applies its server-side defaults and the request succeeds. This is the [Bug]: DeepSeek provider returns HTTP 400 on first message with deepseek-v4-flash (v0.14.0) #30818 fix.reasoning_effortis now independent of the thinking toggle — settingreasoning_config.effortalone forwardsreasoning_effortwithout re-introducing the smoking-gunextra_body.thinkingfield. Effort and thinking were coupled before only by accident of the unconditional injection.extra_body.thinking(DeepSeek API: Missing 'thinking: disabled' parameter causes 400 error #15700, DeepSeek direct API 400 "reasoning_content must be passed back" on multi-turn tool calls #17212, [Bug]: DeepSeek reasoning models crash with HTTP 400 on multi-turn sessions — reasoning_content dropped during session reload #17825) is unaffected —agent/chat_completion_helpers.build_assistant_messagealready pads assistant tool-call messages withreasoning_contentwhenever the active provider is DeepSeek thinking mode (see_needs_deepseek_tool_reasoninginrun_agent.py). No request-side flag is needed to keep that path working.tests/providers/test_deepseek_profile_30818.py(+365, new file) — 28 tests across five classes:TestDefaultPathNoThinkingInjected(7): the exact scenario the bug reporter hit. Parametrised overdeepseek-v4-flash,-v4-pro,-v4-experimental,-v5-flash(forward compat) anddeepseek-reasoner; also covers thereasoning_config={}shape thathermes_cli/config.pyproduces when thereasoning:section is empty, and pins independence betweenreasoning.effortand thethinkingtoggle.TestExplicitThinkingOptIn(8): legacy Kimi-style payload still works for users who configuredreasoning_config.enabledexplicitly:enabled=True→{"type": "enabled"},enabled=False→{"type": "disabled"}ANDreasoning_effortis dropped (no point picking an effort when thinking is off), enabled+effort forwards both, enabled-without-effort lets the server pick its default, invalid effort levels are silently dropped.TestV3AndNonThinkingModelsUnchanged(5): V3 chat / coder models stay untouched in BOTH default and opt-in paths.TestProfileMetadataUnchanged(5): static pins so a refactor that breaks profile identity (rename, deletion, base_url change, alias loss,_model_supports_thinkingmis-classification) fails here instead of silently breaking every DeepSeek user.TestSourceGuards(2): structural pin — theextra_body['thinking']assignment must be preceded by the_user_opted_into_thinking_configguard within a 200-char window, so a future refactor that moves the assignment outside the guard would fail here even if it slipped past the behavioural tests above. Also pins that the docstring keeps citing [Bug]: DeepSeek provider returns HTTP 400 on first message with deepseek-v4-flash (v0.14.0) #30818.How to Test
Run the new regression suite on its own:
Expected: 28 passed.
Run the wider provider + deepseek + runtime sweep to confirm no cross-file regressions:
scripts/run_tests.sh tests/providers/ \ tests/run_agent/test_deepseek_reasoning_content_echo.py \ tests/hermes_cli/test_runtime_provider_resolution.py \ tests/agent/test_auxiliary_named_custom_providers.pyExpected: 314 passed.
Manual reproduction of the original bug (mirrors the issue body):
Run
hermesand send "hello".BadRequestError [HTTP 400]on the very first message; the agent aborts withNon-retryable client error (HTTP 400).provider: custom+api_mode: openai-completionsworkaround now works directly withprovider: deepseek.Opt-in regression check (preserves the pre-fix behaviour for users who depend on it):
With this config the request body still contains
"thinking": {"type": "enabled"}and"reasoning_effort": "high", exactly as before.Checklist
Code
fix(deepseek): …,test(deepseek): …)scripts/run_tests.sh tests/providers/test_deepseek_profile_30818.pyand all tests pass (28/28)Documentation & Housekeeping
docs/, docstrings) — module docstring rewritten with full History / Current behavior sections citing [Bug]: DeepSeek provider returns HTTP 400 on first message with deepseek-v4-flash (v0.14.0) #30818cli-config.yaml.exampleif I added/changed config keys — N/A (no new config keys; the existingreasoning.enabled/reasoning.effortkeys retain their semantics)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs