Skip to content

🤖 fix: filter streamText's synthesized default finish in StreamManager#3441

Merged
ethanndickson merged 4 commits into
mainfrom
fix/openai-silent-default-finish
Jun 4, 2026
Merged

🤖 fix: filter streamText's synthesized default finish in StreamManager#3441
ethanndickson merged 4 commits into
mainfrom
fix/openai-silent-default-finish

Conversation

@ethanndickson
Copy link
Copy Markdown
Member

@ethanndickson ethanndickson commented Jun 2, 2026

Summary

Drop ai's synthesized-default finish part inside StreamManager so that PR #3415's missing-terminal-event guard turns a clean upstream EOF into a retryable stream_truncated error for both OpenAI and Anthropic providers, instead of silently committing partial output as if the assistant finished cleanly.

Background

PR #3415 added a receivedTerminalEvent guard in StreamManager that surfaces a missing terminal SSE event as a retryable stream_truncated error. That guard only fires when the SDK stream ends without emitting any finish part at all. Empirically that branch was unreachable: every real OpenAI and Anthropic stream ends with a finish part — but on truncated upstreams the part is a synthesized default, not a real terminal signal.

The synthesis originates inside the ai package's streamText. Its internal runStep TransformStream initializes:

let stepFinishReason = "other";
let stepRawFinishReason = void 0;

and unconditionally emits those values from its own flush() at end-of-stream — even when the upstream SSE closed before any terminal event arrived. So every adapter ends up looking, at the StreamManager boundary, like it cleanly finished with (other, undefined) regardless of whether it actually did.

Per-provider truncation behavior, observed in the installed source:

  • OpenAI (@ai-sdk/openai): each adapter (Responses, Chat Completions, legacy Completions) initializes its own finishReason = { unified: "other", raw: undefined } and emits it from its own flush(). streamText normalizes that to (other, undefined) and forwards it.
  • Anthropic (@ai-sdk/anthropic): the adapter has no flush() and only emits its finish part on a real message_stop. On a truncated stream there is no adapter-level finish at all — and streamText's runStep.flush() then synthesizes the same (other, undefined) part.

Same symptom at the StreamManager layer, two different SDK-internal causes.

Implementation

Filter the synthesized default at the streamTextStreamManager boundary — the layer that actually produces it. In StreamManager.processStreamWithCleanup's case "finish": handler, treat a part whose normalized finishReason === "other" and rawFinishReason === undefined as a non-event: do not set receivedTerminalEvent = true. The existing !receivedTerminalEvent branch below then routes the stream to handleTruncatedStreamCompletion, which writes a retryable stream_truncated partial with the streamed text preserved.

Why the discriminator is safe (empirical):

  • OpenAImapOpenAIResponseFinishReason and mapOpenAIFinishReason only return unified: "other" from their default: branches, which are reached via isResponseFinishedChunk / isResponseFailedChunk, both of which carry a defined raw value. The (other, undefined) shape is therefore unreachable as a real OpenAI finish.
  • AnthropicmapAnthropicStopReason only returns "other" for the "compaction" case and the default: fallback. Both call sites in the adapter (message_delta and message_start handlers) pair the unified reason with raw: value.message.stop_reason (a defined string from the API). (other, undefined) is unreachable as a real Anthropic finish.
  • streamText's own flush — the only path in this layer that produces (other, undefined) is the synthesized default in runStep's end-of-stream flush.

So the discriminator distinguishes precisely between "the SDK fabricated a finish to keep the type system happy" and "the model genuinely finished with other". A defensive test guards the false-positive surface: a real (other, "compaction") finish must pass through as a clean completion.

Risks

Behavioral change is localized to streams that previously committed partial output silently on a clean truncated EOF. After this change those surface as retryable stream_truncated errors — the UX PR #3415 originally intended.

Regression surface is the synthesized-default discriminator itself: a false positive would treat a legitimate (other, undefined) finish as truncated, triggering an unnecessary retry. We mitigate by:

  1. Tying the discriminator to the empirically-unreachable (other, undefined) shape, verified against the OpenAI and Anthropic mappers (see Implementation).
  2. A regression test that asserts real (other, <raw>) finishes (e.g. Anthropic's "compaction") still complete cleanly.

If a future provider adapter does emit (other, undefined) as a real terminal finish, the worst case is a retry — preferable to silently committing partial output as a clean completion.

Pains

The first revision moved this same heuristic into StreamManager and was correctly flagged by Codex as theoretically too broad — the public LanguageModelV2 contract permits any adapter to emit (other, undefined) as a legitimate terminal finish. The second revision scoped a similar filter to the @ai-sdk/openai adapter callsites, which was contract-safe but did not actually fix the bug: streamText's runStep.flush() re-synthesizes the identical part one layer up, and it produced no fix at all for Anthropic where the adapter has no flush() to filter in the first place.

This revision returns the fix to StreamManager but now with concrete evidence — gathered from reading node_modules/{ai,@ai-sdk/openai,@ai-sdk/anthropic}/dist/index.js — that the (other, undefined) shape is unreachable from the two real adapter mappers we care about, and is uniquely produced by streamText's own flush. The discriminator's safety is a property of the two SDKs in use, not a guarantee of the public V2 contract.


Generated with mux • Model: anthropic:claude-opus-4-7 • Thinking: xhigh • Cost: $60.15

The OpenAI Responses adapter in @ai-sdk/openai initializes its finish
reason to { unified: "other", raw: undefined } and flushes that default
when the SSE stream closes without a terminal event. PR #3415's
truncation guard only fires when no finish part is emitted at all, so
this synthesized finish bypassed the guard and committed partial output
as a normal assistant message with no UI feedback.

Use rawFinishReason (exposed by the AI SDK on finish parts) to
discriminate the synthesized default ("other" + undefined raw) from a
legitimate unmapped "other" finish, which always carries a raw reason
string. Only the synthesized default skips
receivedTerminalEvent = true, so the existing handleTruncatedStreamCompletion
path then turns it into a retryable stream_truncated error.

Normal "stop" finishes also have rawFinishReason: undefined, but they're
not "other", so they remain unaffected.
@ethanndickson
Copy link
Copy Markdown
Member Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 34f0d47ac7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/streamManager.ts Outdated
Codex pointed out that the previous discriminator lived in StreamManager
and treated every `finish` part with `(unified: "other", raw: undefined)`
as truncated. The LanguageModelV2 contract permits adapters to emit
that shape as a legitimate terminal event, so the heuristic was too
broad for the provider-agnostic StreamManager.

Move the fix to the boundary where the synthesized default originates.
The @ai-sdk/openai adapter family — Responses, Chat Completions, legacy
Completions — initializes its internal finishReason to
`{ unified: "other", raw: undefined }` and unconditionally emits that
value from its TransformStream.flush() at end-of-stream, even when no
terminal SSE event arrived. The SDK's mappers only return
`unified: "other"` paired with a defined `raw`, so within this adapter
family the `(other, undefined)` shape is unreachable except as the
uninitialized default. Dropping it is safe and intentionally scoped to
the OpenAI provider construction path (and the Copilot path, which
reuses the same adapter).

Implementation: introduce
src/node/services/openAISynthesizedFinishFilter.ts which exposes
`wrapOpenAIModelToFilterSynthesizedFinish(model)`. The wrapper pipes
`doStream`'s output through a TransformStream that drops only the
synthesized-default finish part; all other parts pass through unchanged.
Apply the wrapper at the two `createOpenAI(...)` callsites in
providerModelFactory.ts. With the synthesized finish dropped, the
existing `!receivedTerminalEvent` branch in StreamManager handles a
clean upstream EOF as `stream_truncated` exactly as PR #3415 intended.

Revert the StreamManager-side heuristic and tests from the previous
commit so StreamManager stays provider-agnostic.
@ethanndickson
Copy link
Copy Markdown
Member Author

@codex review

Restructured the fix per your feedback. The discriminator is now scoped to the @ai-sdk/openai adapter boundary instead of living in StreamManager:

  • StreamManager is reverted — it stays provider-agnostic.
  • New src/node/services/openAISynthesizedFinishFilter.ts exposes wrapOpenAIModelToFilterSynthesizedFinish(model), which pipes doStream's output through a TransformStream that drops only the @ai-sdk/openai adapter family's uninitialized { unified: "other", raw: undefined } default finish part.
  • The wrapper is applied at the two createOpenAI(...) callsites in providerModelFactory.ts (direct OpenAI + GitHub Copilot, which routes through the same adapter). No other providers are touched.
  • Within this adapter family, the SDK's mapOpenAIResponseFinishReason / mapOpenAIFinishReason only return unified: "other" paired with a defined raw, so (other, undefined) is unreachable in legitimate code paths and dropping it is safe. The heuristic does not generalize to other providers' adapters, which the LanguageModelV2 contract permits to emit (other, undefined) legitimately.
  • With the synthesized finish dropped, PR 🤖 fix: require provider stream terminal events #3415's existing !receivedTerminalEvent guard turns a clean upstream EOF into stream_truncated exactly as intended.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Another round soon, please!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@ethanndickson ethanndickson changed the title 🤖 fix: treat AI SDK synthesized "other" finish as stream_truncated 🤖 fix: scope @ai-sdk/openai synthesized-finish filter to the adapter boundary Jun 2, 2026
The previous adapter-boundary filter (commit f019a60) targeted only the
@ai-sdk/openai adapters' synthesized default finish part. While correct as
far as it went, it did not actually fix the bug: ai's streamText wraps every
adapter in its own runStep TransformStream which initializes
stepFinishReason="other" / stepRawFinishReason=undefined and unconditionally
emits those values from its own flush() at end-of-stream. So even after
dropping the OpenAI adapter's synthesized finish, an identical synthesized
finish reappears one layer up.

The same bug affects @ai-sdk/anthropic: its TransformStream has no flush(),
so a clean upstream EOF (no message_stop) emits no finish part at all from
the adapter — at which point streamText's runStep flush synthesizes the same
(other, undefined) part. Symptoms are identical to the OpenAI bug: partial
output is committed silently as a normal assistant message instead of
surfacing a retryable stream_truncated error.

Move the filter to the streamText → StreamManager boundary, which is where
the synthesized default actually originates. Drop the OpenAI adapter wrapper
module entirely.

Discriminator: a finish part whose normalized finishReason is "other" and
rawFinishReason is undefined. Empirically, the public mapping functions in
both @ai-sdk/openai (mapOpenAIResponseFinishReason / mapOpenAIFinishReason)
and @ai-sdk/anthropic (mapAnthropicStopReason) only ever produce
unified="other" paired with a defined raw value (e.g. Anthropic's
"compaction"). The (other, undefined) shape is unreachable from the
adapters' own finish-reason mappers and is therefore a reliable signal that
streamText's flush synthesized the finish.
@ethanndickson ethanndickson changed the title 🤖 fix: scope @ai-sdk/openai synthesized-finish filter to the adapter boundary 🤖 fix: filter streamText's synthesized default finish in StreamManager Jun 2, 2026
@ethanndickson
Copy link
Copy Markdown
Member Author

@codex review

Heads-up for this revision — the fix has moved back into StreamManager, which directly contradicts your earlier feedback on the first revision (resolved thread PRRT_kwDOPxxmWM6GVQKi). Bringing the empirical evidence up-front so we can short-circuit a re-litigation:

Why the in-StreamManager filter is now justified (the earlier objection was theoretical; this revision is grounded in two specific SDKs):

  1. The previous adapter-boundary filter did not actually fix the bug. ai's streamText.runStep initializes stepFinishReason = "other" / stepRawFinishReason = void 0 and unconditionally emits those values from its own flush() at end-of-stream (node_modules/ai/dist/index.js line ~7361 init, line ~7670 emission). Even after dropping the OpenAI adapter's synthesized finish, an identical synthesized finish reappears one layer up. The wrapper produced zero behavior change.
  2. The bug also exists for @ai-sdk/anthropic, where the adapter has no flush() at all (only message_stop emits a finish). A truncated stream produces no adapter-level finish, so streamText.runStep.flush() synthesizes (other, undefined). There is no Anthropic-adapter boundary to filter.
  3. The (other, undefined) shape is unreachable from the two real adapter mappers in use:
    • @ai-sdk/openai's mapOpenAIResponseFinishReason / mapOpenAIFinishReason only return unified: "other" from default: branches reached via isResponseFinishedChunk / isResponseFailedChunk, both of which pair it with a defined raw.
    • @ai-sdk/anthropic's mapAnthropicStopReason returns "other" only for "compaction" and default:; both adapter call sites pair it with raw: value.message.stop_reason (a defined string from the API).
  4. A regression test guards the false-positive surface: a real (other, "compaction") finish must still complete cleanly.

The LanguageModelV2 contract objection from the prior round is correct in the abstract, but is not currently realized by either real provider, and the alternative is silently committing partial output as a clean assistant message — which is the original bug. A false positive's worst case is one extra retry; the current behavior loses data.

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Adds observability for the unaudited-adapter risk: provider adapters
outside @ai-sdk/openai and @ai-sdk/anthropic (xai, openai-compatible,
bedrock, ollama-ai-provider, etc.) were not source-audited for their
finish-reason mappers. If one legitimately emits (other, undefined) as
a real terminal finish, the discriminator misfires and the user sees a
spurious truncated-stream retry. A log line at the firing site makes
misfires diagnosable instead of silent.
@ethanndickson ethanndickson added this pull request to the merge queue Jun 4, 2026
Merged via the queue into main with commit 5fa0c3b Jun 4, 2026
24 checks passed
@ethanndickson ethanndickson deleted the fix/openai-silent-default-finish branch June 4, 2026 08:45
@mux-bot mux-bot Bot mentioned this pull request Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant