Skip to content

DeepSeek V4: reasoning_content lost on second interleaved pass (regression from #24146) #24442

Closed

Description

@claudianus

Bug Description

PR #24146 fixed empty reasoning_content for DeepSeek V4 by unconditionally setting providerOptions[sdk][field] = reasoningText in the interleaved transform. However, this introduced a regression: on the second pass through the transform (after DB storage), content parts no longer have reasoning blocks (already extracted in first pass), so reasoningText is empty. The unconditional set overwrites the previously correct providerOptions.reasoning_content, causing DeepSeek 400:

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

Steps to Reproduce

  1. Configure DeepSeek V4 Pro (deepseek/deepseek-v4-pro) with thinking mode
  2. Run a multi-turn conversation with tool calls (e.g., Sisyphus agent making multiple delegate_task calls)
  3. After the first tool-result → assistant response round-trip, the second request to DeepSeek will fail with 400

Root Cause

The interleaved transform runs on every request:

  • First pass: Content has reasoning parts → extracts reasoning → stores in providerOptions → removes from content ✅
  • Second pass (after DB round-trip): Content has no reasoning parts → reasoningText = "" → unconditionally overwrites providerOptions.reasoning_content → reasoning lost ❌

Expected Behavior

On the second pass, when reasoningText is empty, fall back to the existing providerOptions[sdk][field] value instead of overwriting with empty string.

Related

Environment

  • DeepSeek V4 Pro (deepseek-v4-pro)
  • OpenCode dev branch
  • models.dev provider (@ai-sdk/openai-compatible)

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Apr 26, 2026
  2. edulml98 commented on Apr 26, 2026

    @edulml98

    the issue is on openrouter side: OpenRouterTeam/ai-sdk-provider#487
    already merged, waiting for the release: #24435

  3. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    Not an OpenRouter issue — we reproduced the exact same 400 error using DeepSeek's official API (api.deepseek.com/v1), not OpenRouter.

    Repro steps with official API:

    • Direct curl to https://api.deepseek.com/v1/chat/completions with deepseek-reasoner (V4 Pro) thinking mode
    • First turn: model returns reasoning_content in response — ✅
    • Second turn: we pass history back WITHOUT reasoning_content in the assistant message → 400: "The reasoning_content in the thinking mode must be passed back to the API"

    Root cause: OpenCode's interleaved transform (in provider.ts) unconditionally overwrites providerOptions.reasoning_content on the second pass. First pass extracts reasoning from content parts → stores in providerOptions ✅. After DB round-trip, content has no reasoning parts → reasoningText = "" → unconditionally overwrites existing providerOptions.reasoning_content → reasoning lost → 400 ❌

    This is a provider-layer bug in OpenCode, not specific to OpenRouter. The interleaved transform needs to fall back to existing providerOptions[sdk][field] when reasoningText is empty on subsequent passes.

    (We also tested OpenRouter as a control — same 400, plus OpenRouter has an additional bug where it strips empty reasoning: "" strings before forwarding to DeepSeek.)

  4. edulml98 commented on Apr 26, 2026

    @edulml98

    I have use Opencode v1.14.25 using deepseek official api with a 14m tokens sessions and i didnt encounter any issue. Using deepseek v4 pro max effort

    Image

    deepseek-reasoner routes to deepseek-v4-flash as you can see in their docs: https://api-docs.deepseek.com/quick_start/pricing "* The model names deepseek-chat and deepseek-reasoner will be deprecated in the future. For compatibility, they correspond to the non-thinking mode and thinking mode of deepseek-v4-flash, respectively."

  5. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    Reproduced with DeepSeek official API + correct model ID deepseek-v4-pro.

    @edulml98 — the 400 does NOT occur in simple text multi-turn, but DOES occur with tool calls, which is the exact OpenCode bug scenario:

    Reproduction (official API, deepseek-v4-pro)

    Turn 1: tool call triggered → reasoning_content returned, content empty ✅
    Turn 2: assistant msg passed WITHOUT reasoning_content + tool_result → 400: The reasoning_content in the thinking mode must be passed back to the API.

    Why simple text works but tool calls don't

    When there's no tool call, Turn 1 content is non-empty, and DeepSeek may not strictly validate reasoning_content in history. But with tool calls, content is empty and the API REQUIRES reasoning_content to be preserved in subsequent turns.

    Conclusion

    This is NOT an OpenRouter-only issue. The bug is in OpenCode's interleaved transform dropping reasoning_content on the second pass — fix needed in provider.ts, not just an SDK bump.

  6. added a commit that references this issue on Apr 26, 2026
    41eb35a
  7. knefenk commented on Apr 26, 2026

    @knefenk

    Reproduced and confirmed this regression.

    Trace:

    Pass 1 (raw API response): Content has reasoning parts -> transform extracts reasoningText -> sets providerOptions.reasoning_content -> strips reasoning from content ✓

    Pass 2 (after DB round-trip): Content has NO reasoning parts (stripped in pass 1) -> reasoningText = "" -> unconditionally overwrites existing providerOptions.reasoning_content -> empty string sent to DeepSeek -> 400 ✗

    Wrote standalone reproduction testing all three code paths (upstream dev post-#24146, PR #24250, fixed version). Results:

    Fix pushed to PR #24250: sets [field]: reasoningText first, then spreads existing providerOptions[sdk] over it. This is equivalent to the reasoningText || existingField || "" approach in #24443 but using JS spread semantics. Either PR works — maintainer just needs to merge one.

  8. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    @knefenk — thanks for the independent reproduction and trace. Your analysis matches ours exactly:

    Pass 1 extracts reasoningText → sets providerOptions → strips from content ✓
    Pass 2 content has no reasoning parts → reasoningText = "" → unconditionally overwrites → 400 ✗

    The fix in PR #24443 (swap spread order: set [field]: reasoningText first, then spread existing providerOptions[sdk] over it) covers the same ground as your #24250 fix. Either approach works — the key insight is that existing providerOptions values must take priority over the (potentially empty) re-extracted reasoningText.

    cc @edulml98 — this confirms the bug is in OpenCode's provider layer (not OpenRouter-specific), reproducible with official DeepSeek API + deepseek-v4-pro + tool calls.

  9. kipropbrian commented on Apr 26, 2026

    @kipropbrian

    There is definitely a bug in openrouter too, this bug affects all other coding agents too if you check their recent issue logs, as rekram stated in #24190 he spoke to the open router guys and they fixed the issue on their side too. So lets wait for him to do the release.

  10. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    @kipropbrian — respectfully, this is not primarily an OpenRouter issue. Heres why:

    1. Reproduced with DeepSeek official API (api.deepseek.com/v1, model deepseek-v4-pro)

    The 400 error was reproduced with DeepSeeks official API endpoint — no OpenRouter involved:

    • Turn 1: tool call triggered → reasoning_content returned, content empty ✅
    • Turn 2: assistant msg passed WITHOUT reasoning_content + tool_result → 400: The reasoning_content in the thinking mode must be passed back to the API.

    2. Why simple text chats dont trigger it

    When content is non-empty (no tool calls), DeepSeek may not strictly validate reasoning_content in history — explaining why 14M-token sessions work fine. But with tool calls (content is empty), the API requires reasoning_content to be preserved in subsequent turns.

    3. Two separate bugs exist

    • OpenRouter bug (fix: preserve empty reasoning_details arrays in multi-turn conversations OpenRouterTeam/ai-sdk-provider#487): OpenRouter strips empty reasoning: "" strings before forwarding to DeepSeek — this is what rekram discussed and they fixed.
    • OpenCode provider-layer bug (this issue): OpenCodes interleaved transform in provider.ts unconditionally overwrites providerOptions.reasoning_content on the second pass with an empty string. After DB round-trip, content has no reasoning parts → reasoningText = "" → overwrites existing providerOptions.reasoning_content → 400.

    4. Fixing OpenRouter alone is insufficient

    Even after OpenRouter releases their fix, users on DeepSeeks official API will still hit this bug. The OpenCode provider-layer fix (PR #24443 or #24250) is needed regardless.

    5. Independent confirmation

    @knefenk independently reproduced and traced the exact bug path in OpenCodes provider.ts, confirming the unconditional overwrite on pass 2.


    Bottom line: Two bugs, two fixes. OpenRouters release fixes their side; OpenCode needs its own fix for the provider layer. Waiting for OpenRouter alone leaves official API users broken.

  11. runnig commented on Apr 26, 2026

    @runnig

    When is the release for the fixes?

  12. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    Hey @runnig — good question.

    There are two fixes in play:

    1. OpenRouter SDK fix (fix: preserve empty reasoning_details arrays in multi-turn conversations OpenRouterTeam/ai-sdk-provider#487): Already merged by OpenRouter team. Waiting for their release cycle. This fixes the OpenRouter-specific bug where empty reasoning: "" strings were stripped before forwarding.

    2. OpenCode provider-layer fix (PR fix(provider): preserve reasoning_content on second interleaved pass (#24146 follow-up) #24443, also PR fix(provider): complete DeepSeek reasoning_content round-trip for multi-turn conversations #24250 by @knefenk): Open and ready for review. Both PRs fix the same core issue — the interleaved transform in provider.ts unconditionally overwrites providerOptions.reasoning_content on the second pass. This affects users on DeepSeek official API as well as OpenRouter.

    Timeline: The OpenCode fix needs a maintainer (@rekram or team) to review and merge one of the two PRs. I do not have visibility into their release schedule. Once merged, it would go out in the next OpenCode release.

    In the meantime:

    • If you are on OpenRouter: their upcoming release should mitigate the issue for non-tool-call scenarios
    • If you are on DeepSeek official API or use heavy tool calls: the OpenCode provider fix is required regardless

    Hope that helps clarify!

  13. rekram1-node commented on Apr 26, 2026

    @rekram1-node
    Collaborator

    I think there is misdiagnosis, if you are using the official deepseek api everything works as expected, if not I need to see your exact config and a session where you reproduced it opencode export > session.json < run this exact command in your terminal and send me the json file.

    As for openrouter yes they had a bug on their side and i will release the fix.

    I am going to close this as completed since the openrouter fix has been merged and will release, but I will continue monitoring this issue if more people are having issues. But once again I need to know:

    • exact provider you are using
    • if this is a custom provider show me your opencode.json config for it
    • if you encountered an error send me the session
  14. 7 remaining items

  15. edulml98 commented on Apr 26, 2026

    @edulml98

    Just an update to my 14m coding session.
    it was a session with tool calls usage, skills and subagents and I did no find any issue.
    @kipropbrian the openrouter sdk updated is not included in the v1.14.25, I am waiting fot the next release too.

  16. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    Hey @rekram1-node — I checked the actual models.dev data for DeepSeek. This changes things.

    models.dev says interleaved IS active

    From /Users/modumaru/.cache/opencode/models.json:

    "deepseek/deepseek-v4-pro": {
      "reasoning": true,
      "interleaved": {"field": "reasoning_content"},
      "tool_call": true
    }
    "deepseek/deepseek-v4-flash": {
      "reasoning": true,
      "interleaved": {"field": "reasoning_content"},
      "tool_call": true
    }
    "deepseek/deepseek-reasoner": {
      "reasoning": true,
      "interleaved": {"field": "reasoning_content"},
      "tool_call": true
    }

    Full code path

    models.dev → interleaved={field:"reasoning_content"}
      ↓
    provider.ts:1009: interleaved: model.interleaved ?? false → {field:"reasoning_content"}
      ↓
    transform.ts:197: typeof === "object" && .field → TRUE → interleaved transform RUNS
      ↓
    Pass 1: extract reasoning → providerOptions.reasoning_content ✅
    Pass 2 (DB round-trip): reasoningText="" → [field]: reasoningText → overwrites → 400 ❌
    

    Conclusion

    model.interleaved ?? false does not default to false for deepseek-v4-pro. models.dev provides {field: "reasoning_content"}. The interleaved transform is ACTIVE. The bug is real.

    The session export I posted didn't trigger 400 because opencode run (single-shot non-interactive) may not hit the DB round-trip condition. But in normal TUI multi-turn usage with tool calls, the transform runs twice across the same messages → pass 2 overwrites → 400.

    This confirms the curl reproduction is valid — it matches the exact code path that OpenCode hits when models.dev provides interleaved for deepseek-v4-pro.

  17. rekram1-node commented on Apr 26, 2026

    @rekram1-node
    Collaborator

    @kipropbrian the session you shared is on .25 which didn't include the fix, you need to be on .26.

  18. rekram1-node commented on Apr 26, 2026

    @rekram1-node
    Collaborator

    @claudianus please stop spamming giant AI generated comments or I will have to mute you

  19. rekram1-node commented on Apr 26, 2026

    @rekram1-node
    Collaborator

    @claudianus the session you shared had no errors, the models are correctly configured I see no bugs I think you may be misunderstanding?

    The curl reproduction is actually the cleanest demonstration because it strips away the model capability resolution and shows the pure API behavior: DeepSeek requires reasoning_content on every assistant message in thinking mode, and losing it causes 400.

    how is this relevant I already stated that we set reasoning_content on every assistant message lol

  20. claudianus commented on Apr 26, 2026

    @claudianus
    ContributorAuthor

    Re-analyzed the full code path. You were right — on the standard DeepSeek official API path, convertToModelMessages preserves reasoning in content parts, so the interleaved transform always has reasoning text to extract. The 400 does not reproduce here.

    A cron job of mine was running way too trigger-happy and generated the wall of text above before I properly verified. My apologies — that was AI slop and I should have done more careful analysis first.

    PR #24443's reasoningText || existing fallback is still a decent defensive pattern, but the urgency I claimed was wrong. Closing this out.

  21. knefenk commented on Apr 27, 2026

    @knefenk

    @rekram1-node thanks. Re-checked against the latest dev convertToModelMessages preserves reasoning in content parts, so the second-pass overwrite scenario my commit 41eb35a was guarding against doesn't actually trigger on the standard path. That fix was unnecessary.
    That leaves PR #24250 with three layers — the only one that's a clear code fix is the dynamic SDK key (sdkKey(model.api.npm) instead of hardcoded openaiCompatible in the interleaved transform). The rest (auto-enable interleaved, fallback injection) are behavioral changes, agreed they don't belong in a bug fix PR.

    The SDK key fix is currently masked by the OpenRouter exclusion guard on the interleaved block, so it has no practical effect today — but it makes the interleaved block consistent with how the rest of transform.ts handles provider keys (the cache-control block already uses openrouter etc. correctly).

    Happy to either narrow #24250 to just the SDK key fix, or close it since it's not blocking anything right now.

  22. rekram1-node commented on Apr 27, 2026

    @rekram1-node
    Collaborator

    @knefenk what non openai compatible provider requires it? Maybe it should just be gated to only run for the openai compat sdk pkg or something but that "bug" seems weird but all non openai compatible packages should handle this logic internally

  23. knefenk commented on Apr 27, 2026

    @knefenk

    @rekram1-node currently none — the OpenRouter exclusion guard blocks before the interleaved code path. The sdkKey change was just for consistency with the rest of transform.ts. No practical effect.

    Rechecked against latest dev: convertToModelMessages preserves reasoning in content parts, so the two-pass overwrite I claimed doesn't actually trigger. My reproduction missed the full pipeline, my bad.

    Closing #24250 since nothing's broken. Happy to revisit if needed.

  24. n-prat commented on May 27, 2026

    @n-prat

    I am still having the same issue with latest opencode on Arch, at least when using

     "openrouter": {
          "apiKey": "{env:OPENROUTER_API_KEY}",
          "models": {
            "deepseek/deepseek-v4-flash": {
              "options": {
                "preset": "@preset/force-ai-studio-deep-seek"
              }
            },
            "deepseek/deepseek-v4-pro": {
              "options": {
                "preset": "@preset/force-ai-studio-deep-seek"
              }
            }
          }
        }
    

    ie it could be due to using @preset?

    Anyway, using DeepSeek API directly works fine.

    opencode --version
    1.15.11
    
  25. FelixDamrau commented on May 28, 2026

    @FelixDamrau

    @n-prat
    I had the same issue...

    Here my someone half baked analysis and a fix that seems to work for me.

    Tested with 1.15.11

    The interleaved round-trip transform is hard-gated against the OpenRouter SDK - packages/opencode/src/provider/transform.ts lines 308ff:

    if (
      typeof model.capabilities.interleaved === "object" &&
      model.capabilities.interleaved.field &&
      model.api.npm !== "@openrouter/ai-sdk-provider"
    ) {
      const field = model.capabilities.interleaved.field;
      // ...
    }

    Fix

    Per-model SDK override in opencode.json

    {
      "provider": {
        "openrouter": {
          "options": { "apiKey": "{env:OPENROUTER_API_KEY}" },
          "models": {
            "deepseek/deepseek-v4-pro": {
              "provider": {
                "npm": "@ai-sdk/openai-compatible",             // <--- THIS HERE
                "api": "https://openrouter.ai/api/v1",          // <--- THIS HERE
              },
              "interleaved": { "field": "reasoning_content" },  // <--- THIS HERE
            },
          },
        },
      },
    }
    • opencode resolves per-model npm as f.provider?.npm ?? C.npm ?? b?.api.npm ?? …
      (see packages/opencode/src/provider/provider.ts)
    • the model-level provider.npm wins over the built-in @openrouter/ai-sdk-provider.
      Same OpenRouter endpoint, but now via the openai-compatible SDK
    • api.npm !== "@openrouter/ai-sdk-provider" gate passes -> the interleaved round-trip code runs
    • the explicit interleaved.field activates the round-trip
    • this is a hack ¯\_(ツ)_/¯ -- not sure what else will break.
  26. rekram1-node commented on May 29, 2026

    @rekram1-node
    Collaborator

    it intentionally gates the openrouter path because openrouters apis dont use reasoning_content, im sure behind the scenes they are backed by providers that do, but their api uses reasoning_details (a object) and reasoning (a string)

    So the gate is there intentionally

  27. rekram1-node commented on May 29, 2026

    @rekram1-node
    Collaborator

    If ur hitting this bug then plz send me a session: opencode export > session.json

  28. n-prat commented on May 29, 2026

    @n-prat

    I could really reliably reproduce a couple days ago with a really simple prompt "What is the current branch doing? Use gh cli to find the matching PR" cf

    session.json

    But I have just tried it again and it seems fine ??

  29. FelixDamrau commented on May 30, 2026

    @FelixDamrau

    Well... I cannot reproduce the problem anymore as well.
    Maybe deepseek changed sth on their end?

    Kinda weired. Worked for weeks, then was broke for some days, now seems to work again.

    Sorry for the fuss and thanks for the help! <3

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