🤖 fix: filter streamText's synthesized default finish in StreamManager#3441
Conversation
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.
|
@codex review |
There was a problem hiding this comment.
💡 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".
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.
|
@codex review Restructured the fix per your feedback. The discriminator is now scoped to the
|
|
Codex Review: Didn't find any major issues. Another round soon, please! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
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.
|
@codex review Heads-up for this revision — the fix has moved back into Why the in-
The |
|
Codex Review: Didn't find any major issues. Can't wait for the next one! ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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.
Summary
Drop
ai's synthesized-defaultfinishpart insideStreamManagerso that PR #3415's missing-terminal-event guard turns a clean upstream EOF into a retryablestream_truncatederror for both OpenAI and Anthropic providers, instead of silently committing partial output as if the assistant finished cleanly.Background
PR #3415 added a
receivedTerminalEventguard inStreamManagerthat surfaces a missing terminal SSE event as a retryablestream_truncatederror. That guard only fires when the SDK stream ends without emitting anyfinishpart at all. Empirically that branch was unreachable: every real OpenAI and Anthropic stream ends with afinishpart — but on truncated upstreams the part is a synthesized default, not a real terminal signal.The synthesis originates inside the
aipackage'sstreamText. Its internalrunStepTransformStream initializes: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:
@ai-sdk/openai): each adapter (Responses, Chat Completions, legacy Completions) initializes its ownfinishReason = { unified: "other", raw: undefined }and emits it from its ownflush().streamTextnormalizes that to(other, undefined)and forwards it.@ai-sdk/anthropic): the adapter has noflush()and only emits itsfinishpart on a realmessage_stop. On a truncated stream there is no adapter-level finish at all — andstreamText'srunStep.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
streamText→StreamManagerboundary — the layer that actually produces it. InStreamManager.processStreamWithCleanup'scase "finish":handler, treat a part whose normalizedfinishReason === "other"andrawFinishReason === undefinedas a non-event: do not setreceivedTerminalEvent = true. The existing!receivedTerminalEventbranch below then routes the stream tohandleTruncatedStreamCompletion, which writes a retryablestream_truncatedpartial with the streamed text preserved.Why the discriminator is safe (empirical):
mapOpenAIResponseFinishReasonandmapOpenAIFinishReasononly returnunified: "other"from theirdefault:branches, which are reached viaisResponseFinishedChunk/isResponseFailedChunk, both of which carry a definedrawvalue. The(other, undefined)shape is therefore unreachable as a real OpenAI finish.mapAnthropicStopReasononly returns"other"for the"compaction"case and thedefault:fallback. Both call sites in the adapter (message_deltaandmessage_starthandlers) pair the unified reason withraw: value.message.stop_reason(a defined string from the API).(other, undefined)is unreachable as a real Anthropic finish.(other, undefined)is the synthesized default inrunStep'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_truncatederrors — 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:(other, undefined)shape, verified against the OpenAI and Anthropic mappers (see Implementation).(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
StreamManagerand was correctly flagged by Codex as theoretically too broad — the publicLanguageModelV2contract permits any adapter to emit(other, undefined)as a legitimate terminal finish. The second revision scoped a similar filter to the@ai-sdk/openaiadapter callsites, which was contract-safe but did not actually fix the bug:streamText'srunStep.flush()re-synthesizes the identical part one layer up, and it produced no fix at all for Anthropic where the adapter has noflush()to filter in the first place.This revision returns the fix to
StreamManagerbut now with concrete evidence — gathered from readingnode_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 bystreamText'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