Repository navigation
Description
Environment
@opencode-ai/cliv0.0.0-beta-17519(opencode 2 preview)- Provider: SenseNova OpenAI-compatible endpoint (
https://token.sensenova.cn/v1) - Model:
deepseek-v4-flashwithreasoning_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
- Configure any OpenAI-compatible provider that emits a
reasoning_contentfield in SSE deltas (SenseNova / Aliyun DashScope / DeepSeek's own endpoint / Moonshot Kimi / etc. all do this). - Trigger a request that produces reasoning + content with high reasoning effort.
- 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
- Many OpenAI-compatible providers (SenseNova, Aliyun DashScope, DeepSeek, Moonshot Kimi, etc.) emit
finish_reasonin the same delta as the last content delta, OR send trailingreasoning_contentafter the finish chunk whenreasoning_effortis high/xhigh. None of these are bugs by the providers' own behavior — they're the pragmatic way these endpoints stream. - The previous opencode 1.x was lenient and absorbed these quirks (this is why users migrating from opencode 1.x suddenly hit this regression).
- 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
finishafterusage/[DONE]regardless of trailing deltas (matches 1.x behavior). - B. Loosen to ignore trailing deltas that don't include
contentand (reasoningorreasoning_details) — i.e., still error on actual mixed-content streams, but allow empty trailing pings. - C. Add a per-provider setting
stream.absorbTrailing(defaulttrue) that consumes up to N trailing non-finish_reasondeltas 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
markyu0401 commentedon Oct 9, 2026
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 completedFINISH=lengthat 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_messagetable): persisted withfinish: "stop",rawFinish: "stop"and text truncated mid-sentence, sometimestokens.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-guardas a stopgap (the plugin that absorbs trailing chunks): it loads fine, butfinish_guard_statsshowsretries: 0and deaths continued — so the late chunk arrives in a shape the plugin'shttp.responsetransform doesn't recognize (or the V2 drain path has another variant; one of our deaths hadfinish: 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.