Skip to content

fix(sse): skip model-only lockout for an isolated 5xx after the stream relayed output - #15186

Merged
diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/stream-output-model-lockout
Oct 2, 2026
Merged

diegosouzapw merged 4 commits into
diegosouzapw:release/v3.8.52from
QuangBlue:fix/stream-output-model-lockout

Conversation

@QuangBlue

Copy link
Copy Markdown
Contributor

Problem

A streamed response that has already sent text, reasoning or a tool call to the client can still fail upstream: for example Internal error during token generation 45–80 s into the answer, or a stream active timeout / terminated pipeline error. That failure goes onStreamFailure → markAccountUnavailable, and for a per-model-scoped connection (passthroughModels) it records a model-only lockout.

The client response is already committed, so the lock cannot help that request. It only takes the model away from every other client, and on a single-account provider it removes the model entirely until the lock expires.

In our deployment, over 8 days almost all 5xx model-only lockouts on one single-account provider were of this kind: 27 of 31 came after the stream had already relayed content. Hundreds of concurrent streams on the same model succeeded during the same period. Of the remaining 4, 3 were caught by the combo quality peek before any content, and 1 was a non-stream HTML 520.

Fix

  • streamTiming.ts: each SSE transform's StreamTiming is registered in a WeakMap. A new streamEmittedOutput(stream) reads the existing firstOutputAt marker (the first forwarded chunk carrying text, reasoning or a tool call).
  • streamFailureFinalization.ts and chatCore.ts: stream failures now carry outputEmitted, read at failure time. This covers pipeline errors (terminated, stream active timeout) as well as in-stream upstream errors.
  • chatHelpers.ts and auth.ts: onStreamFailure passes streamOutputEmitted to markAccountUnavailable.
  • A model that fails every stream mid-way is still broken, so these failures are counted per provider/connection/model (accountFallback/postOutputFailureStreak.ts). The lock is recorded when 3 arrive in a row with no completed stream in between. A stream that completes with 200 resets the count, and a streak older than 10 min starts over.
  • buildExhaustionOptions now reuses the markAccountUnavailable option type instead of repeating it, so auth.ts shrinks by 7 lines.

Unchanged

  • A 5xx before any output, including a combo peek that sees the error before content and fails over.
  • 429 / 404 model lockouts.
  • Connection cooldown for connection-scoped providers.
  • The combo quality-failure lockout.

File-size ceilings

stream.ts (+1), chatCore.ts (+3) and chatHelpers.ts (+1) only gain irreducible call-site wiring. The ceilings are bumped in file-size-baseline.json, with a _rebaseline_… entry explaining why. The logic lives in streamTiming.ts, postOutputFailureStreak.ts and syntheticEmptyStream.ts.

Tests

  • tests/unit/stream-failure-after-output-no-model-lockout.test.ts:
    • isolated post-output 5xx → no lockout, connection active, lastErrorType recorded;
    • three in a row → lockout; a completed stream in between resets the streak; streak keys and expiry;
    • pre-output 5xx and post-output 429 → lockout kept;
    • the stream registry: no output before a content chunk or after a role-only chunk, output after a content chunk;
    • the finalizer forwards outputEmitted;
    • a real in-stream error frame, in passthrough and translate modes: an error after content counts as output, an error frame alone does not.
  • Also run: typecheck:core, check-open-sse-typecheck, check-api-typecheck, check-file-size, and ~900 neighbouring unit tests (lockout, stream, chat, combo). The only failures are the same ones on the base without this change.
  • End-to-end with a local OpenAI-compatible mock and a single-target combo:
    • Before: a mid-stream error locked the model and the next request got 503 all targets were skipped.
    • After: the next request succeeds, a pre-output error still locks, and three mid-stream failures in a row lock (isolated ones with successes in between never do).

⚠️ base-red inherited: #15100

…m relayed output

A streamed response that already sent text, reasoning or a tool call to the
client and then fails upstream (for example "Internal error during token
generation" 45-80 s into the answer, or a stream timeout) reached
markAccountUnavailable through onStreamFailure and recorded a model-only
lockout. The client response is already committed, so the lock cannot help
that request; it only takes the model away from every other client, and on a
single-account provider it removes the model entirely until the lock expires.
In production this was the source of most 5xx model-only lockouts for one
such provider, while hundreds of concurrent streams on the same model
succeeded in between.

A model that fails every stream mid-way is still broken, so such failures are
counted per provider/connection/model and the lock is recorded when 3 arrive
in a row with no completed stream in between (a streak older than 10 min
starts over).

- streamTiming: register each SSE transform's timing in a WeakMap and expose
  streamEmittedOutput(stream), based on the existing firstOutputAt marker.
- chatCore / streamFailureFinalization: stream failures now carry
  outputEmitted, read at failure time, so pipeline errors (terminated, stream
  active timeout) are covered as well as in-stream upstream errors. A stream
  that completes with 200 resets the streak.
- accountFallback/postOutputFailureStreak: the streak counter.
- chatHelpers / auth: pass streamOutputEmitted to markAccountUnavailable; the
  per-model branch treats an isolated post-output 5xx like the existing
  bare-500 and synthetic empty-stream cases (isRequestScopedServerFailure):
  lastError is still recorded, no lockout. buildExhaustionOptions now reuses
  the markAccountUnavailable option type instead of repeating it.

Unchanged: a 5xx before any output (including a combo peek that sees the
error before content and fails over), 429/404 lockouts, connection cooldown
for connection-scoped providers, and the combo quality-failure lockout.

stream.ts, chatCore.ts and chatHelpers.ts only gain call-site wiring; their
file-size ceilings are bumped with a _rebaseline entry explaining each line.

Tests: tests/unit/stream-failure-after-output-no-model-lockout.test.ts
(isolated post-output 5xx: no lockout; 3 in a row: lockout; a completed
stream resets the streak; streak keys and expiry; pre-output 5xx and
post-output 429: lockout; registry in passthrough and translate modes,
including an upstream error frame before any content not counting as output;
finalizer plumbing). Reproduced end to end with a local OpenAI-compatible
mock: before, a mid-stream error locked the model and the next request got
503; after, the next request succeeds, a pre-output error still locks, and
three mid-stream failures in a row lock.
@QuangBlue
QuangBlue force-pushed the fix/stream-output-model-lockout branch from b7e9331 to d51fcbe Compare September 30, 2026 08:22
@diegosouzapw
diegosouzapw merged commit f690548 into diegosouzapw:release/v3.8.52 Oct 2, 2026
46 of 51 checks passed
@diegosouzapw

Copy link
Copy Markdown
Owner

Thanks @QuangBlue — merged into release/v3.8.52; it ships in the next release.

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.

2 participants