anomalyco / anomalyco/opencode

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

Open
#43,040 0 comments 0 reactions 1 assignee View on GitHub

@nexxeln is already working on this.

Since Aug 17, 2026.

2.0
Dominant language
TypeScript
Stars
209k
Forks
27.5k
PR merge metrics
PR metrics pending

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.