From 5eede28fa29dbcbaeeda54b3bf2e3c8047eba3c8 Mon Sep 17 00:00:00 2001 From: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:01:56 -0300 Subject: [PATCH] fix(sse): surface an error for a truly empty Claude stream (#12398) 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. --- .../fixes/12398-claude-truly-empty-stream.md | 1 + open-sse/utils/stream.ts | 14 +- open-sse/utils/streamClaudeEmptyBody.ts | 34 ++++ .../claude-stream-truly-empty-body.test.ts | 153 ++++++++++++++++++ 4 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 changelog.d/fixes/12398-claude-truly-empty-stream.md create mode 100644 open-sse/utils/streamClaudeEmptyBody.ts create mode 100644 tests/unit/claude-stream-truly-empty-body.test.ts diff --git a/changelog.d/fixes/12398-claude-truly-empty-stream.md b/changelog.d/fixes/12398-claude-truly-empty-stream.md new file mode 100644 index 0000000000..39a86ec94f --- /dev/null +++ b/changelog.d/fixes/12398-claude-truly-empty-stream.md @@ -0,0 +1 @@ +- fix(sse): surface an error instead of a silent empty 200 when a Claude stream closes with zero bytes (#12398) diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index a5d761063c..42f2d01b76 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -27,6 +27,7 @@ import { injectThinkingSignature, } from "./streamHelpers.ts"; import { rejectEmptyChoicesStream, buildEmptyChoicesStreamError } from "./streamEmptyChoices.ts"; +import { shouldAbortEmptyClaudeStream } from "./streamClaudeEmptyBody.ts"; import { calculateCost } from "@/lib/usage/costCalculator"; import { buildOmniRouteSseMetadataComment } from "@/domain/omnirouteResponseMeta"; import { sseCommentsEnabled } from "./sseHeartbeat.ts"; @@ -502,11 +503,6 @@ function shouldInjectClaudeEmptyResponseBeforeCurrentEvent( return type === "message_delta" || type === "message_stop"; } -function shouldInjectClaudeEmptyResponseOnFlush(lifecycle: ClaudeEmptyResponseLifecycle): boolean { - if (lifecycle.hasError || lifecycle.hasContentBlock) return false; - return hasClaudeAssistantLifecycle(lifecycle); -} - function shouldInjectClaudeMissingFinalizersOnFlush( lifecycle: ClaudeEmptyResponseLifecycle ): boolean { @@ -875,6 +871,10 @@ export function createSSEStream(options: StreamOptions = {}) { let idleTimer: ReturnType | null = null; let streamTimedOut = false; const claudeEmptyResponseLifecycle = createClaudeEmptyResponseLifecycle(); + // #12398: `timing.firstByteAt` doubles as "any upstream chunk ever arrived". + const shouldAbortClaudeStream = () => + clientExpectsClaudeStream && + shouldAbortEmptyClaudeStream(claudeEmptyResponseLifecycle, timing.firstByteAt !== null); // `event:` framing is only part of the SSE protocol for OpenAI Responses API // and Claude Messages API passthrough; a plain OpenAI Chat-Completions-format // client has no `event:` field at all, so it is dropped to stop upstream @@ -2487,7 +2487,7 @@ export function createSSEStream(options: StreamOptions = {}) { } } - if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { + if (shouldAbortClaudeStream()) { emitClaudeEmptyStreamErrorAndAbort(controller); return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { @@ -2840,7 +2840,7 @@ export function createSSEStream(options: StreamOptions = {}) { } if (sourceFormat === FORMATS.CLAUDE) { - if (shouldInjectClaudeEmptyResponseOnFlush(claudeEmptyResponseLifecycle)) { + if (shouldAbortClaudeStream()) { emitClaudeEmptyStreamErrorAndAbort(controller); return; } else if (shouldInjectClaudeMissingFinalizersOnFlush(claudeEmptyResponseLifecycle)) { diff --git a/open-sse/utils/streamClaudeEmptyBody.ts b/open-sse/utils/streamClaudeEmptyBody.ts new file mode 100644 index 0000000000..7daa687608 --- /dev/null +++ b/open-sse/utils/streamClaudeEmptyBody.ts @@ -0,0 +1,34 @@ +/** + * #12398 — decides whether a Claude-format stream must be aborted with an + * upstream error at flush time because the client got no usable content. + * + * Covers two shapes: + * - "partial lifecycle": message_start (and optionally message_delta / + * message_stop) arrived but no content block ever did — this was already + * correctly handled before #12398 and is preserved here unchanged. + * - "truly empty": the upstream connection closed having sent literally + * zero bytes (HTTP 200, not even a message_start). The lifecycle flags + * above can never catch this shape since none of them are ever set — the + * caller must additionally know whether ANY upstream chunk ever arrived. + * + * Callers must additionally require a Claude-format client (this function + * does not take that flag — both call sites in stream.ts only ever reach + * here already scoped to a Claude-format response). + */ +type ClaudeEmptyLifecycleLike = { + hasError: boolean; + hasContentBlock: boolean; + hasMessageStart: boolean; + hasMessageDelta: boolean; + hasMessageStop: boolean; +}; + +export function shouldAbortEmptyClaudeStream( + lifecycle: ClaudeEmptyLifecycleLike, + sawAnyUpstreamPayload: boolean +): boolean { + if (lifecycle.hasError || lifecycle.hasContentBlock) return false; + const hasPartialLifecycle = + lifecycle.hasMessageStart || lifecycle.hasMessageDelta || lifecycle.hasMessageStop; + return hasPartialLifecycle || !sawAnyUpstreamPayload; +} diff --git a/tests/unit/claude-stream-truly-empty-body.test.ts b/tests/unit/claude-stream-truly-empty-body.test.ts new file mode 100644 index 0000000000..ab915b35a6 --- /dev/null +++ b/tests/unit/claude-stream-truly-empty-body.test.ts @@ -0,0 +1,153 @@ +/** + * Regression test for issue #12398 — claude-fable-5-max returns an empty + * stream past ~1800 messages when stream=true. + * + * `createSSEStream()`'s Claude-empty-response detector used to only fire + * when at least one Claude SSE lifecycle event (message_start / + * message_delta / message_stop) had been observed. When the upstream + * connection closes having sent + * LITERALLY ZERO bytes (no message_start at all — e.g. the connection is + * held open, then closes with nothing on it, matching the reporter's + * "~14.5s before flush" timing), the flush path used to silently complete + * the client stream with a 200 and no content instead of surfacing a 502 — + * exactly the reported symptom ("The request does not error; it completes + * with no content"). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { createPassthroughStreamWithLogger } = await import("../../open-sse/utils/stream.ts"); +const { FORMATS } = await import("../../open-sse/translator/formats.ts"); + +async function drainTransform( + transform: TransformStream, + upstream: ReadableStream +) { + const writer = transform.writable.getWriter(); + const pump = (async () => { + const reader = upstream.getReader(); + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + await writer.write(value); + } + await writer.close(); + })(); + + const reader = transform.readable.getReader(); + const chunks: Uint8Array[] = []; + let readError: unknown = null; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + } + } catch (e) { + readError = e; + } + try { + await pump; + } catch (e) { + readError = readError ?? e; + } + const decoded = new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))); + return { chunks, decoded, readError }; +} + +test("#12398 truly empty upstream Claude stream (zero bytes, no message_start) surfaces an error", async () => { + let failureCalled: unknown = null; + let completeCalled: unknown = null; + + const transform = createPassthroughStreamWithLogger( + "claude", + null, + null, + "claude-fable-5-max", + null, + { stream: true }, + (payload: unknown) => { + completeCalled = payload; + }, + null, + (failure: unknown) => { + failureCalled = failure; + return false; + }, + FORMATS.CLAUDE + ); + + // Upstream connection opens (HTTP 200) but closes having emitted literally + // zero bytes — the "held open ~14s then closed with nothing on it" case + // from the issue report. + const upstream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + const { decoded, readError } = await drainTransform(transform, upstream); + + const sawClientVisibleError = + decoded.includes('"type":"error"') || decoded.includes("event: error"); + const surfacedAsFailure = readError !== null || failureCalled !== null || sawClientVisibleError; + + assert.equal( + surfacedAsFailure, + true, + "a truly empty (zero-byte) upstream Claude stream must be surfaced as an error " + + "(readError, onFailure callback, or a client-visible error SSE event) instead of " + + "silently completing with 200 and no content" + ); + assert.equal( + completeCalled, + null, + "onComplete must not fire with a fabricated 200 success payload for a truly empty stream" + ); +}); + +test("#12398 companion: partial-lifecycle empty Claude stream (message_start + message_stop, no content) still errors", async () => { + let failureCalled: unknown = null; + + const transform = createPassthroughStreamWithLogger( + "claude", + null, + null, + "claude-fable-5-max", + null, + { stream: true }, + () => {}, + null, + (failure: unknown) => { + failureCalled = failure; + return false; + }, + FORMATS.CLAUDE + ); + + const encoder = new TextEncoder(); + const upstream = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode( + `event: message_start\ndata: ${JSON.stringify({ + type: "message_start", + message: { id: "msg_1", model: "claude-fable-5-max", usage: {} }, + })}\n\n` + ) + ); + controller.enqueue( + encoder.encode(`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`) + ); + controller.close(); + }, + }); + + const { readError } = await drainTransform(transform, upstream); + + assert.equal( + readError !== null || failureCalled !== null, + true, + "the pre-existing partial-lifecycle empty-response detector (#3685) must keep working" + ); +});