Skip to content

fix(sse): record partial token usage when a stream is aborted mid-answer - #16521

Merged
diegosouzapw merged 4 commits into
release/v3.8.52from
fix/port-pr-3348-aborted-stream-partial-usage
Oct 15, 2026
Merged

diegosouzapw merged 4 commits into
release/v3.8.52from
fix/port-pr-3348-aborted-stream-partial-usage

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Summary

  • When a client disconnects mid-stream (499 client_disconnected) or the upstream fails mid-answer, the request is now recorded with the tokens the provider had already generated, instead of zero.
  • Provider-reported usage wins; a side not reported yet (e.g. Claude's output count, sent only in the final message_delta) is estimated from the streamed output and flagged estimated. If nothing was reported and nothing was streamed, the zero-token record stays as it was.
  • Status 499 / 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 table registerStreamPartialUsage / readStreamPartialUsage (same pattern as registerStreamTiming), plus the pure resolvePartialStreamUsage({ reported, contentLength, body }).
  • open-sse/utils/stream.ts: createSSEStream registers a lazy reader that closes over the transform's live state.usage (translate) / usage (passthrough) and totalContentLength. It does no per-chunk work.
  • open-sse/utils/streamFailureFinalization.ts: createStreamFailureFinalizers takes an optional readPartialUsage (errors are swallowed and fall back to zero). When partial usage exists, it goes to persistFailureUsage(status, code, aggregate) and onto the completion payload as partialUsage. When there is none, the calls are byte-identical to before.
  • open-sse/handlers/chatCore/streamingTail.ts: wires readPartialUsage: () => readStreamPartialUsage(transformStream). The call log takes streamUsage || partialUsage for its token columns, and estimates are marked through the existing isEstimatedUsage path.

Design notes:

  • usage stays null on the failure completion payload on purpose. A non-null usage there would make recordStreamingUsageStats write a second usage row next to persistFailureUsage, and would also run cost and input-calibration (calibration against an estimate) for a failed stream.
  • Partial tokens are not added to API-key USD spend or token budgets. Those aggregations count only successful rows (success = 1 in apiKeyUsageLimits.ts, and recordTokenUsage runs only on 200). Counting aborted streams against budgets is a separate policy change.
  • usage_history has 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 keeps persistFailureUsage(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.
    • Against the base code (git show HEAD: of stream.ts + streamFailureFinalization.ts): ℹ tests 15 ℹ pass 9 ℹ fail 6, with the 6 failing tests being the ones that check carried partial usage.
    • With the fix: ℹ tests 15 ℹ pass 15 ℹ fail 0 (also exits cleanly without --test-force-exit).
  • Sibling sweep (75 test files that reference 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).

⚠️ base-red inherited: #15306 — unit/vitest/integration/package-artifact gates on release/v3.8.52

diegosouzapw and others added 2 commits October 13, 2026 12:49
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
@github-actions

Copy link
Copy Markdown
Contributor

CI Coverage Report

  • Coverage job: skipped
  • PR test policy: success

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
diegosouzapw merged commit a059d15 into release/v3.8.52 Oct 15, 2026
37 of 51 checks passed
@diegosouzapw
diegosouzapw deleted the fix/port-pr-3348-aborted-stream-partial-usage branch October 18, 2026 23:50
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