Skip to content

fix(sse): surface an error for a truly empty Claude stream (#12398) - #13285

Merged
diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12398-fable-empty-stream
Sep 12, 2026
Merged

diegosouzapw merged 1 commit into
release/v3.8.51from
fix/12398-fable-empty-stream

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Closes #12398

Root cause

open-sse/utils/stream.ts's Claude empty-response detector
(hasClaudeAssistantLifecycle()) only fired once at least one Claude SSE
lifecycle event (message_start / message_delta / message_stop) had
been observed. When an upstream Claude-format response closes having sent
literally zero bytes (HTTP 200, not even a message_start — the shape
a connection held open for ~14.5s and then closed with nothing on it would
produce), transform() is never even invoked, so none of the lifecycle
flags get set and the flush path silently completed the client stream with
a 200 and no content instead of surfacing a 502. This matches the reported
symptom for claude-fable-5-max past ~1800 messages ("the request does not
error; it completes with no content").

The other variant (message_start arrives, then message_delta/
message_stop with no content block) was already correctly detected and
turned into a 502 — that path (#3685) is unchanged and still covered by
tests/unit/claude-empty-stream-error-3685.test.ts.

Fix

Extended the detector so a Claude-format client that never saw a single
upstream chunk (timing.firstByteAt === null at flush time) is also
aborted with the existing emitClaudeEmptyStreamErrorAndAbort() 502 path,
at both flush call sites (PASSTHROUGH and TRANSLATE). The combined check
lives in a new open-sse/utils/streamClaudeEmptyBody.ts module so the
frozen open-sse/utils/stream.ts file size does not grow (net line count
unchanged — an old single-purpose helper was folded into the new combined
one).

Per the plan's own risk note, this fix does not attempt an automatic
upstream retry-on-empty-stream — that needs response buffering before any
bytes reach the client and is a materially larger change; left as a
follow-up if wanted.

Regression test

tests/unit/claude-stream-truly-empty-body.test.ts — RED before the fix,
GREEN after:

✖ #12398 truly empty upstream Claude stream (zero bytes, no message_start) surfaces an error
  AssertionError: a truly empty (zero-byte) upstream Claude stream must be surfaced as an error ...
  false !== true
✔ #12398 truly empty upstream Claude stream (zero bytes, no message_start) surfaces an error
✔ #12398 companion: partial-lifecycle empty Claude stream (message_start + message_stop, no content) still errors

(RED confirmed by running the test against origin/release/v3.8.51's
unmodified open-sse/utils/stream.ts via git show, then restoring the
fix — never via git stash.)

Gates run

  • node scripts/check/check-file-size.mjs — OK (stream.ts net line count
    unchanged: 3097 → 3097)
  • node scripts/check/check-complexity.mjs — OK, 2798 violations (baseline 3218)
  • node scripts/check/check-cognitive-complexity.mjs — OK, 1265 violations (baseline 1437)
  • npm run typecheck:core — clean
  • npx eslint --suppressions-location config/quality/eslint-suppressions.json on all changed files — clean
  • New test: node --import tsx/esm --test tests/unit/claude-stream-truly-empty-body.test.ts — 2/2 pass
  • Existing Claude/stream-area tests re-run (not just the new one): 179 tests across
    claude-empty-stream-error-3685, stream-claude-delta-contract,
    claude-stream-reconstructed-body, empty-choices-no-inject,
    stream-onfailure-callback-logging, stream-utils, stream-utilities,
    responses-continuation-*, stream-numeric-ids,
    stream-passthrough-error-redaction, sse-comments-optout-9305,
    sse-finish-reason-synthesis-7800, stream-non-json-sse,
    10017-sse-control-lines-leak-openai-clients, stream-translate-usage-trailing,
    nvidia-tool-compatibility-2840, stream-prompt-tokens-zero-upstream,
    codex-tool-handoff-disconnect-499, stream-passthrough-usage-estimation,
    transform-stream-hwm, responses-commentary-* — all 179 pass, including
    the idle-timeout test (confirms the new check does not race the idle watchdog).

VPS follow-up (not part of this PR)

The plan flags a separate VPS validation to confirm why Anthropic's
upstream produces this specific shape for claude-fable-5-max at ~1800
messages — that requires live OAuth traffic at scale and doesn't change
the code-level fix, which is scoped to always surfacing an error instead
of a silent empty 200 regardless of the exact upstream trigger.

⚠️ base-red inherited: #12732 — unit #12058, integration codex-cache, package-artifact, tarball-smoke, agent-skills-sync

Extend the Claude empty-response detector in open-sse/utils/stream.ts to
also catch an upstream connection that opens (HTTP 200) and closes having
sent literally zero bytes -- no message_start at all. The existing
hasClaudeAssistantLifecycle() gate only fired once a lifecycle event had
been observed, so this shape silently completed the client stream with a
200 and no content instead of surfacing a 502, matching the reported
symptom for claude-fable-5-max past ~1800 messages.

New streamClaudeEmptyBody.ts module keeps the frozen stream.ts file size
unchanged while adding the combined partial-lifecycle + truly-empty check.
@diegosouzapw
diegosouzapw merged commit 7a938fe into release/v3.8.51 Sep 12, 2026
15 of 21 checks passed
Githab-capibara added a commit to Githab-capibara/OmniRoute that referenced this pull request Sep 17, 2026
…nference#12398) (cheaper-inference#13285)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on cheaper-inference#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for cheaper-inference#13278 and cheaper-inference#13276, annotated and landed on cheaper-inference#13243

⚠️ base-red inherited: cheaper-inference#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…nference#12398) (cheaper-inference#13285)

Merged as part of the 39-PR owner batch of 2026-09-11, validated as a unit.

Boarded into one consolidated worktree cut from `release/v3.8.51` with the other 38 — zero conflicts between them.

- ESLint over every changed file: no errors (the only finding was one suppression entry the batch emptied, pruned on cheaper-inference#13243)
- `typecheck:core` clean; `check:dashboard-typecheck` OK (206 pre-existing, within baseline); `check:changelog-integrity` OK
- complexity 2821 / baseline 3218 and cognitive-complexity 1272 / baseline 1437 — both under baseline
- 256 assertions green: 246 under node:test and 10 under vitest, which is where `tests/unit/**/*.test.tsx` actually runs
- `check-file-size`: `chatCore.ts` rebaselined 6144 → 6146 for cheaper-inference#13278 and cheaper-inference#13276, annotated and landed on cheaper-inference#13243

⚠️ base-red inherited: cheaper-inference#12732 — the provider count (356 in the docs vs the 358 the modules define) and `open-sse/utils/stream.ts` at 3115 > frozen 3098 both reproduce on the pure tip with zero contribution from this batch.
@diegosouzapw
diegosouzapw deleted the fix/12398-fable-empty-stream branch October 18, 2026 23:48
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.

fix(claude): claude-fable-5-max returns an empty stream past ~1800 messages when stream=true

1 participant