Skip to content

OpenAI Chat adapter throws eceived content after the finish reason on OpenAI-compatible providers with reasoning fields #43040

Closed

Description

@JochenYang

Environment

  • @opencode-ai/cli v0.0.0-beta-17519 (opencode 2 preview)
  • Provider: SenseNova OpenAI-compatible endpoint (https://token.sensenova.cn/v1)
  • Model: deepseek-v4-flash with reasoning_effort: xhigh
  • opencode.json provider block (V1 config, auto-migrated to V2):
"sensenova": {
  "name": "SenseNova",
  "env": ["SENSENOVA_API_KEY"],
  "npm": "@ai-sdk/openai-compatible",
  "options": { "baseURL": "https://token.sensenova.cn/v1" },
  "models": {
    "deepseek-v4-flash": {
      "name": "DeepSeek V4 Flash",
      "limit": { "context": 1000000, "output": 65536 },
      "variants": {
        "none":   { "body": { "reasoning_effort": "none"   } },
        "low":    { "body": { "reasoning_effort": "low"    } },
        "medium": { "body": { "reasoning_effort": "medium" } },
        "high":   { "body": { "reasoning_effort": "high"   } },
        "xhigh":  { "body": { "reasoning_effort": "xhigh"  } }
      }
    }
  }
}

After V1→V2 auto-migration: package: "aisdk:@ai-sdk/openai-compatible", settings.baseURL, all variants preserved. Provider is correctly listed by opencode models and selected in the UI.

Reproduction

  1. Configure any OpenAI-compatible provider that emits a reasoning_content field in SSE deltas (SenseNova / Aliyun DashScope / DeepSeek's own endpoint / Moonshot Kimi / etc. all do this).
  2. Trigger a request that produces reasoning + content with high reasoning effort.
  3. Result: Every request fails with AI.Error: OpenAI Chat received content after the finish reason.

Stack trace (from ~/.local/share/opencode/log/opencode.log)

ERROR AI.Error: ProviderShared.stream: OpenAI Chat received content after the finish reason
    at le (../ai/src/protocols/shared.ts:91:7)
    at <anonymous> (../ai/src/protocols/openai-chat.ts:717:38)
    at SessionRunner.callModel (../core/src/session/runner/llm.ts:218:32)
    at SessionRunner.runSteps (../core/src/session/runner/llm.ts:147:31)
    at SessionRunner.drain (...)

The session that reproduced it: ses_ff0d40785ffesgvVHCsuvFR6bQ, request at 2026-08-17T10:10:06.889Z.

Root cause

packages/ai/src/protocols/openai-chat.ts (per source map chunk-h4jw75en.js.map shipped in @opencode-ai/cli-windows-x64) has a strict guard in step():

const hasLateContent =
  Boolean(delta?.content) ||
  reasoning !== undefined ||
  (Array.isArray(delta?.reasoning_details) && delta.reasoning_details.length > 0) ||
  toolDeltas.some((tool) =>
    Boolean(tool.id) || Boolean(tool.function?.name) || Boolean(tool.function?.arguments))
if (state.finishReason !== undefined) {
  if (hasLateContent)
    return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat received content after the finish reason")
  return [{ ...state, usage }, events] as const
}

The check fires when any of content / reasoning / reasoning_details / partial tool_calls arrive in any delta after state.finishReason has been set — strict per the OpenAI streaming spec.

Why this is broken in practice

  1. Many OpenAI-compatible providers (SenseNova, Aliyun DashScope, DeepSeek, Moonshot Kimi, etc.) emit finish_reason in the same delta as the last content delta, OR send trailing reasoning_content after the finish chunk when reasoning_effort is high/xhigh. None of these are bugs by the providers' own behavior — they're the pragmatic way these endpoints stream.
  2. The previous opencode 1.x was lenient and absorbed these quirks (this is why users migrating from opencode 1.x suddenly hit this regression).
  3. OpenRouter and similar gateways silently strip trailing deltas. The native adapter should do the same.

Proposed fix (any one is acceptable)

  • A. Drop the strict check; emit finish after usage/[DONE] regardless of trailing deltas (matches 1.x behavior).
  • B. Loosen to ignore trailing deltas that don't include content and (reasoning or reasoning_details) — i.e., still error on actual mixed-content streams, but allow empty trailing pings.
  • C. Add a per-provider setting stream.absorbTrailing (default true) that consumes up to N trailing non-finish_reason deltas before throwing.

Suggested workaround until fixed

Run sensenova through an OpenAI-compatible proxy (LiteLLM, OpenRouter, or a 30-line Node proxy) that buffers the stream and forwards only well-formed deltas.

Activity

  1. markyu0401 commented on Oct 9, 2026

    @markyu0401

    Hit this in production on the exact model the PR author mentions — z-ai/glm-5.3-flash (TensorFold EXL3 on 2× DGX Spark, behind a LiteLLM gateway), OpenCode v2.0.26 stable. The turn dies mid-generation with no visible error; reproducibly on long turns with large reasoning blocks.

    What makes our case useful: we verified the server side is clean with a bypass test — 4× streaming requests with reasoning_effort: "max", 6000 max_tokens, through the same full chain (same endpoint the client uses): all 4 completed FINISH=length at 5990 chunks, max inter-chunk gap 0.55 s, zero truncation, via-gateway and direct-to-engine identical. So the killer is the client-side guard from #40798 rejecting a rare late chunk — matching @marwanvx's "the late chunk only shows up sometimes" exactly.

    SQLite evidence from the killed turns (session_message table): persisted with finish: "stop", rawFinish: "stop" and text truncated mid-sentence, sometimes tokens.output: 0; one variant had no finish at all. One turn ran 219 s, produced ~35k chars of reasoning + 302 chars of text, then was cut right before its tool call — six times in one day.

    We also tried @bandonker/opencode-finish-guard as a stopgap (the plugin that absorbs trailing chunks): it loads fine, but finish_guard_stats shows retries: 0 and deaths continued — so the late chunk arrives in a shape the plugin's http.response transform doesn't recognize (or the V2 drain path has another variant; one of our deaths had finish: null).

    @marwanvx the PR is green and review-clean since Sep 15 — any chance this can be merged? Happy to test a canary build against our setup.

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions