Repository navigation
fix(sse): record partial token usage when a stream is aborted mid-answer - #16521
Merged
diegosouzapw merged 4 commits intoOct 15, 2026
Merged
Conversation
A client disconnect (499 client_disconnected) or a mid-stream upstream failure finalized the request with zero tokens, even when the provider had already generated (and billed) part of the answer: the SSE transform's flush() never ran, and the failure finalizer recorded usage: null and a zeroed usage row. createSSEStream now registers a lazy partial-usage reader (WeakMap side table, like registerStreamTiming; nothing computed per chunk). The failure finalizer reads it once and passes it to the failure usage row (as the token aggregate) and to the call log (as partialUsage). Provider-reported usage wins; a side not reported yet (e.g. Claude's output count) is estimated from the streamed output and flagged estimated. Nothing streamed and nothing reported keeps the zero-token record. Status 499 / success:false, the #9653 grace window and the completion-wins guard are unchanged; usage stays null on the completion payload so the success-only usage row, cost and calibration do not fire. Co-authored-by: brimob-sowax <brimob-sowax@users.noreply.github.com> Inspired-by: decolua/9router#3348 Co-authored-by: Nguyen Thanh Dat <ntdat812.dev@gmail.com> Inspired-by: decolua/9router#3513 Co-authored-by: Nguyen Thanh Dat <ntdat812@gmail.com> Inspired-by: decolua/9router#3907
Contributor
CI Coverage Report
Coverage artifact was not available for this run. |
Move the partial-usage reader assembly into trackStreamPartialUsage() in open-sse/utils/streamPartialUsage.ts so the createSSEStream call site is a single line, and collapse the transformer cancel hook to an arrow (its unused reason param was one of the two frozen no-unused-vars suppressions; ratchet that count to 1), keeping open-sse/utils/stream.ts at its frozen file-size baseline.
diegosouzapw
deleted the
fix/port-pr-3348-aborted-stream-partial-usage
branch
October 18, 2026 23:50
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
client_disconnected) or the upstream fails mid-answer, the request is now recorded with the tokens the provider had already generated, instead of zero.message_delta) is estimated from the streamed output and flaggedestimated. If nothing was reported and nothing was streamed, the zero-token record stays as it was.success:false, the fix(backend): Completed SSE streams recorded as 499 client-disconnected with 0 tokens (finalization race) #9653 disconnect grace window and the completion-wins guard stay as they are. Only the token columns of the failure records change.Attribution
Thanks to @brimob-sowax, @ntdat812 and @ntdatt812 for the original implementations (three PRs merged into one port here).
Changes
open-sse/utils/streamPartialUsage.ts(new): WeakMap side tableregisterStreamPartialUsage/readStreamPartialUsage(same pattern asregisterStreamTiming), plus the pureresolvePartialStreamUsage({ reported, contentLength, body }).open-sse/utils/stream.ts:createSSEStreamregisters a lazy reader that closes over the transform's livestate.usage(translate) /usage(passthrough) andtotalContentLength. It does no per-chunk work.open-sse/utils/streamFailureFinalization.ts:createStreamFailureFinalizerstakes an optionalreadPartialUsage(errors are swallowed and fall back to zero). When partial usage exists, it goes topersistFailureUsage(status, code, aggregate)and onto the completion payload aspartialUsage. When there is none, the calls are byte-identical to before.open-sse/handlers/chatCore/streamingTail.ts: wiresreadPartialUsage: () => readStreamPartialUsage(transformStream). The call log takesstreamUsage || partialUsagefor its token columns, and estimates are marked through the existingisEstimatedUsagepath.Design notes:
usagestaysnullon the failure completion payload on purpose. A non-nullusagethere would makerecordStreamingUsageStatswrite a second usage row next topersistFailureUsage, and would also run cost and input-calibration (calibration against an estimate) for a failed stream.success = 1inapiKeyUsageLimits.ts, andrecordTokenUsageruns only on 200). Counting aborted streams against budgets is a separate policy change.usage_historyhas no "estimated" column, so an estimated count shows up there as a plain number. The call log keeps the estimated provenance.Test plan
tests/unit/stream-aborted-partial-usage.test.ts(15 tests): finalizer carries reported usage on 499 and on a mid-stream 502; no usage keepspersistFailureUsage(status, code)with exactly 2 args; throwing reader still finalizes; completion-wins guard reads/writes nothing; resolver cases (verbatim, estimate, null, Claude input-only + estimated output, input/output_tokens shape); real passthrough and translate (OpenAI → Claude) streams pumped without flush; end-to-end disconnect → 499 row with non-zero tokens.git show HEAD:ofstream.ts+streamFailureFinalization.ts):ℹ tests 15 ℹ pass 9 ℹ fail 6, with the 6 failing tests being the ones that check carried partial usage.ℹ tests 15 ℹ pass 15 ℹ fail 0(also exits cleanly without--test-force-exit).streamFailureFinalization/createStreamFailureFinalizers/streamingTail/persistFailureUsage/ the SSE stream factories):ℹ tests 563 ℹ pass 556 ℹ fail 2. Neither failure comes from this change:integration-wiring"keeps usage quota wired through A2A and MCP tools" (an MCP server source regex) is inherited, and a fixture file was picked up by the glob.npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>: clean.npx prettier --check <changed files>: clean.npm run typecheck:core: exit 0.GITHUB_BASE_REF=release/v3.8.52 node scripts/check/check-pr-test-policy.mjs: PASS.npm run check:test-masking: OK (0 modified / 0 deleted).