Skip to content

DeepSeek thinking mode: reasoning_content must be passed back to API on conversation continuation #24104

Closed

Description

@fkyah3

Bug Report

Description

Using DeepSeek models with thinking mode enabled (thinking: {type: "enabled"}), every API call after the first assistant response fails with:

The reasoning_content in the thinking mode must be passed back to the API.

The session becomes stuck — you can't continue the conversation beyond the first assistant reply.

System Info

  • OpenCode version: 1.14.19 (running from source, branch anomalyco/opencode)
  • OS: Windows 11
  • Provider: @ai-sdk/openai-compatible (bundled)
  • DeepSeek API: api.deepseek.com, thinking mode enabled

Model Configuration

"deepseek-v4-flash": {
  "limit": { "context": 200000, "output": 393216 },
  "options": { "thinking": { "type": "enabled" } }
}

Also reproduced with deepseek-reasoner (R1) when conversation history contains assistant messages that were stored before thinking mode was enabled.

Steps to Reproduce

  1. Configure a DeepSeek V4 model (e.g. deepseek-v4-flash) in opencode.json with thinking: {type: "enabled"} in options
  2. Start a new session with this model
  3. Send a message — the first assistant reply works fine
  4. Send a follow-up message — API returns 400 with the reasoning_content error
  5. The session is now stuck; every retry produces the same error

Expected Behavior

Conversation should continue normally across multiple turns when using thinking mode models.

Actual Behavior

Second API call fails with reasoning_content must be passed back. The @ai-sdk/openai-compatible provider's convertToOpenAICompatibleChatMessages() function strips reasoning_content from assistant messages that don't have explicit reasoning parts in their content. On history replay (second turn), these messages lack the reasoning_content field that DeepSeek's API requires for all assistant messages in thinking mode.

Workaround

Add interleaved: { field: "reasoning_content" } to the model config in opencode.json, which causes the existing normalizeMessages() code to inject reasoning_content into providerOptions. But this only partially helps — messages replayed from DB history still get skipped by the narrow condition in normalizeMessages().

Related Issues

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    needs:complianceThis means the issue will auto-close after 2 hours.
    on Apr 24, 2026
  2. changed the title [-]fix: DeepSeek thinking mode reasoning_content must be passed back to API[/-] [+]DeepSeek thinking mode: reasoning_content must be passed back to API on conversation continuation[/+] on Apr 24, 2026
  3. github-actions commented on Apr 24, 2026

    @github-actions
    Contributor

    Thanks for updating the issue! It now follows the contributing guidelines and looks great. We appreciate the clear description, system info, and reproduction steps.

  4. bilbillm commented on Apr 24, 2026

    @bilbillm

    Same issue here with deepseek-v4-flash + thinking mode. The error "The reasoning_content in the thinking mode must be passed back to the API" occurs consistently on the second turn — the first assistant reply always succeeds, then every follow-up message fails with a 400 error.

    From my investigation, the root cause is:

    1. When thinking mode is enabled, DeepSeek API requires ALL assistant messages in the conversation history to include the
      easoning_content field (even if empty string ""), especially when tool calls are involved.
    2. The @ai-sdk/openai-compatible provider's convertToOpenAICompatibleChatMessages() function strips
      easoning_content from assistant messages on history replay, so when the second user message is sent, the replayed assistant messages from turn 1 lack the required field.
    3. The fix in PR Missing reasoning_content field in the assistant message for DeepSeek API implementation #17523/fix(opencode): Error 400 missing reasoning_content – transform reasoning according to DeepSeek API and aditional test cases #17529 partially addresses this in ProviderTransform.message, but the root issue in the AI SDK message conversion layer may need a separate fix.

    A temporary workaround I found: the error doesn't occur if no tool calls are made in the first turn (simple chat-only messages work). But as soon as tool calls happen (file read/write, etc.), the conversation deadlocks.

    Hope this gets prioritized — thinking mode is unusable with agent workflows right now.

  5. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    Thanks for confirming — same findings on my end. The tool-call trigger matches what I traced: tool-call messages replayed from history lack
    easoning_content\ because the inner condition in
    ormalizeMessages()\ (transform.ts) skipped them.

    I have a working fix that addresses both the entry condition and the narrow inner skip — committed at \�5b6ad05d\ on branch \ ix/permission-reasoning-truncation\ of my fork (\ kyah3/opencode-fkyah3). The key changes:

    1. Broaden the \hasReasoningContent\ check to fire for any model with \capabilities.reasoning\ (not just models with \interleaved\ configured)
    2. Remove the inner condition that filtered by presence of reasoning text / after-last-user / tool-calls — now ALL assistant messages unconditionally get
      easoning_content\ injected (empty string if no reasoning text), including string-content messages from old DB format

    So far stable across long conversations with heavy tool use. Hope this helps while a proper upstream fix lands.

  6. hypernewbie commented on Apr 24, 2026

    @hypernewbie

    same here

    Image
  7. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    If you want to try the fix now, it's on my fork branch fix/permission-reasoning-truncation at fkyah3/opencode-fkyah3. Commit b5b6ad05d in packages/opencode/src/provider/transform.ts — removes the narrow condition that skipped history-replayed assistant messages.

  8. quake commented on Apr 24, 2026

    @quake

    If you want to try the fix now, it's on my fork branch fix/permission-reasoning-truncation at fkyah3/opencode-fkyah3. Commit b5b6ad05d in packages/opencode/src/provider/transform.ts — removes the narrow condition that skipped history-replayed assistant messages.

    I tried your fix, it works smoothly, thanks!

  9. bilbillm commented on Apr 24, 2026

    @bilbillm

    老哥我在b站刷到您的视频了,这pr #17529就有人发了愣是没人管,您方便的话能提交一个修复的pr嘛,我看到一堆类似的issue了现在交个pr上去大概率人家会看

  10. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    PR #24150 has been submitted. It covers both the interleaved and non-interleaved paths (unlike #24146 which only handles interleaved models).

    In short: the if (reasoningText) guard was the immediate cause, but the interleaved-only outer condition and hardcoded provider key were also blocking DeepSeek from getting the fix.

    The fix is also available on our fork branch ix/permission-reasoning-truncation if you need it before upstream merges.


    PR #24150 已提交。相比 #24146(只修 interleaved 路径),我们的修复合盖了非 interleaved 场景(如 DeepSeek 通过 @ai-sdk/openai-compatible)。如果 upstream 合并前急用,可以直接切到 fork 的 ix/permission-reasoning-truncation 分支。

  11. hammerhoundai commented on Apr 24, 2026

    @hammerhoundai

    In my experience using the below config, it works fine in multiple turns unlike the issue raised by others here. However if I use a different provider and then switch mid-session to deepseek then I get the same error as mentioned here.

      "provider": {
        "deepseek": {
          "npm": "@ai-sdk/anthropic",
          "name": "DeepSeek",
          "options": {
            "baseURL": "https://api.deepseek.com/anthropic",
            "apiKey": "<DeepSeek API Key>"
          },
          "models": {
            "deepseek-v4-pro": {
              "name": "DeepSeek-V4-Pro",
              "limit": {
                "context": 1048576,
                "output": 262144
              },
              "options": {
                "reasoningEffort": "max",
                "thinking": {
                  "type": "enabled"
                }
              }
            }
          }
        }
      }
  12. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    This should be covered by PR #24150. The root cause is the same — the API requires
    easoning_content on every assistant message, and old messages from a different provider won't have it.

    What the fix does:

    1. Detects if the model has capabilities.reasoning or if any message in history has reasoning parts → if so, injects
      easoning_content (empty string for legacy messages) on ALL assistant messages
    2. This handles the mid-session provider switch case because model.capabilities.reasoning is true for DeepSeek, so every assistant message gets the empty field regardless of which provider created it

    One thing I'm less sure about for your setup: you're using @ai-sdk/anthropic as the transport. The providerOptionsKey() logic returns "anthropic" as the key, and defaults the field to "reasoning_content". So the empty value goes into providerOptions.anthropic.reasoning_content. I haven't tested whether the DeepSeek anthropic-compatible endpoint expects it under the "anthropic" namespace or the "openaiCompatible" namespace. If it only reads from openaiCompatible, you might still hit the issue. Would be good to know if your config has interleaved configured for the model — that would use the correct field name.

    (In the standard @ai-sdk/openai-compatible path this is well-tested and works.)

  13. luisifa commented on Apr 24, 2026

    @luisifa

    社区已经有现成的修复插件:opencode-deepseek-thinking-fix
    cd ~/.config/opencode
    bun add opencode-deepseek-thinking-fix
    (或者用 npm: npm i opencode-deepseek-thinking-fix)

    然后在 ~/.config/opencode/opencode.json 中添加插件配置:
    {
    "$schema": "https://opencode.ai/config.json",
    "plugin": ["opencode-deepseek-thinking-fix"]
    }

  14. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    Thanks @luisifa for the pointer! I took a look at the plugin source.

    The plugin wraps the provider fetch layer, caching and re-injecting actual thinking blocks (with signatures) across turns — that's a fundamentally different approach from our fix in transform.ts (which injects empty reasoning_content: "" unconditionally at the message serialization layer).

    They're complementary:

    • Plugin: preserves real thinking content for the model to reference, works at the HTTP layer
    • Our fix: ensures API format compliance (every assistant message has reasoning_content), works at the message normalization layer

    Going to test installing it today. If it works well, having both layers active means the plugin handles the rich thinking preservation and our fix acts as the fallback safeguard.

    One question: does this plugin handle the case where history messages are replayed from DB (e.g. after Magic Context compression)? That's the specific path where we found reasoning_content gets dropped in DeepSeek's thinking mode — the fingerprint-based cache approach might miss on cold replay.

  15. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    @LynnGuo666 @luisifa I tracked down the root cause. The plugin's design is correct for programmatic use, but there's an interaction with OpenCode's legacy plugin loader that causes the crash.

    Root cause chain (src/index.ts line 317):

    The plugin exports ThinkingCache, fingerprint, assistantTurnFingerprint as named exports — this is legitimate for the programmatic API documented in the README. However, OpenCode's getLegacyPlugins() in plugin/index.ts iterates ALL named exports and treats every function/class as a server plugin:

    // plugin/index.ts:100-102
    for (const server of getLegacyPlugins(load.mod)) {
        hooks.push(await server(input, load.options))
    }

    This causes two failures:

    1. ThinkingCache (class) → Cannot call a class constructor without |new|
    2. assistantTurnFingerprint → messages.slice is not a function (called with PluginInput instead of messages array)

    Two layers of the problem:

    Layer 1 — readV1Plugin doesn't handle function default exports:
    The plugin uses the legacy V1 pattern where export default is an async function returning { config, "chat.params" }. This IS a valid pattern, but readV1Plugin (shared.ts:272) checks isRecord(mod.default) which returns false for functions (typeof function !== "object"). In "detect" mode, it silently returns undefined, falling through to getLegacyPlugins.

    Layer 2 — getLegacyPlugins can't distinguish classes from functions:
    isServerPlugin just checks typeof === "function", which is true for both regular functions and ES6 classes. A class thrown when called without new.

    Suggested fix for the plugin (easiest):
    Restructure to V1 plugin pattern with explicit server export, avoiding the legacy loader entirely:

    const plugin = {
      id: "deepseek-thinking-fix",
      server: (input: PluginInput) => ({
        config: async (config: any) => { ... },
        "chat.params": async (...) => { ... },
      }),
    };
    export default plugin;
    export { wrapFetchForDeepSeekThinking, ThinkingCache, fingerprint };

    This way readV1Plugin picks it up directly (mod.default is an object with id and server) and getLegacyPlugins is never reached.

    Our temporary workaround (for immediate use):
    We removed the utility re-exports from dist/index.js:

    - export { ThinkingCache, fingerprint, assistantTurnFingerprint };
    + // removed (ThinkingCache import kept for internal use)
    

    After this change the plugin loads and works correctly on our fork.

  16. simonlearnscoding commented on Apr 24, 2026

    @simonlearnscoding

    This is not fixed for me. Using @ai-sdk/openai-compatible + thinking: {type: "enabled"}:

    ✅ First message works
    ❌ Second message fails: "reasoning_content must be passed back"

    PR #24146 only fixes interleaved models. PR #24150 fixes both — please merge that instead.

    /cc @rekram1-node

  17. rekram1-node commented on Apr 24, 2026

    @rekram1-node
    Contributor

    so you have a custom model definition? Is that the issue ur having?

  18. rekram1-node commented on Apr 24, 2026

    @rekram1-node
    Contributor

    deepseek is an interleaved reasoning model so yeah the config should prolly not be explicit but you can just configure the model to forward the reasoning correctly in your opencode.json

  19. fkyah3 commented on Apr 24, 2026

    @fkyah3
    Author

    That's the nuance — when using the built-in DeepSeek provider (which sets capabilities.interleaved = true), PR #24146's fix works fine. But when using @ai-sdk/openai-compatible as a generic adapter, the model definition lacks both interleaved and reasoning capability flags, so normalizeMessages() never enters the reasoning_content injection branch.

    The user's opencode.json model config may be correct, but the AI SDK's convertToOpenAICompatibleChatMessages() doesn't add reasoning_content to history messages (it only emits reasoning_content when reasoning.length > 0 at the SDK layer). So even with a proper model definition, the replay path still drops it.

    Our fix (PR #24150 / commit b5b6ad05d on our fork) approaches this at normalizeMessages() level — unconditional injection of reasoning_content: "" for any model that has either interleaved, reasoning capability, or any existing message with reasoning parts in the conversation. This covers the generic adapter case without requiring model-config-level changes.

  20. rekram1-node commented on Apr 24, 2026

    @rekram1-node
    Contributor

    Just put something like this in your opencode.json and it will behave as expected:

    "provider": {
        "custom-deepseek": {
          "models": {
            "deepseek-v4-pro": {
              "interleaved": { <<< THIS BLOCK HERE
                "field": "reasoning_content",
              },
            },
          },
        },
      },
    
  21. rekram1-node commented on Apr 24, 2026

    @rekram1-node
    Contributor

    If someone wants to open up a simple clean pr to make it happen for deepseek by default for openai compatible provider thats fine too

  22. added a commit that references this issue on Apr 25, 2026
    b6a7cdb
  23. dante4rt commented on May 13, 2026

    @dante4rt

    I’m seeing the same issue, but I’m not using DeepSeek. I’m using mimo-v.2.5-pro.

  24. md-rifatkhan commented on Aug 5, 2026

    @md-rifatkhan

    same issue

  25. 134579 commented on Oct 22, 2026

    @134579

    same issue when using vscode copilot + deepseek flash v4.1

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

coreAnything pertaining to core functionality of the application (opencode server stuff)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions