From 0f15797e01aac02ad4d11daace0b4caa34bdaa66 Mon Sep 17 00:00:00 2001 From: thepigdestroyer <166646515+thepigdestroyer@users.noreply.github.com> Date: Sun, 17 May 2026 23:36:57 +0100 Subject: [PATCH] fix(v1/messages): default to non-stream for Claude format when ambiguous (#2326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — fixes STREAM_EARLY_EOF on POST /v1/messages when stream is omitted --- open-sse/handlers/chatCore.ts | 5 ++- open-sse/utils/aiSdkCompat.ts | 25 +++++++++++++- .../t26-ai-sdk-accept-header-compat.test.ts | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 8029fdc9c3..6835115f7d 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -1683,10 +1683,13 @@ export async function handleChatCore({ // Codex /responses/compact is JSON-only: Codex CLI does not send stream=false, // so route shape must override the usual Accept/header fallback. + // sourceFormat="claude" applies the Anthropic Messages spec default (stream=false + // when body omits stream), preventing STREAM_EARLY_EOF on /v1/messages when + // clients send Accept: */* without an explicit stream flag. const stream = nativeCodexPassthrough && isCompactResponsesEndpoint(endpointPath) ? false - : resolveStreamFlag(body?.stream, acceptHeader); + : resolveStreamFlag(body?.stream, acceptHeader, sourceFormat); const settings = cachedSettings ?? (await getCachedSettings()); credentials = applyCodexGlobalFastServiceTier(provider, credentials, settings); effectiveServiceTier = resolveEffectiveServiceTier(body); diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 58ff08b738..2faaa6c5f1 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -17,12 +17,35 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean { * Accept header only acts as fallback when stream is not explicitly set. * Fixes #656: clients sending both `stream: true` and `Accept: application/json` * should still get streaming responses — body intent takes precedence. + * + * Optional `sourceFormat` argument lets callers apply spec-correct defaults + * when both `stream` and `Accept` are ambiguous. The Anthropic Messages API + * defaults to non-stream when the body omits `stream`, regardless of Accept + * header. Without this hint, OmniRoute previously routed Anthropic /v1/messages + * requests with a curl-default wildcard Accept header through the streaming + * branch even though upstream returned JSON, producing STREAM_EARLY_EOF / + * HTTP 502 errors. */ -export function resolveStreamFlag(bodyStream: unknown, acceptHeader: unknown): boolean { +export function resolveStreamFlag( + bodyStream: unknown, + acceptHeader: unknown, + sourceFormat?: string +): boolean { // Explicit body value always wins if (bodyStream === true) return true; if (bodyStream === false) return false; + // Anthropic Messages API spec: stream defaults to false when body omits it. + // Only honor an explicit text/event-stream Accept header as a streaming opt-in + // for /v1/messages — otherwise default to non-stream so upstream JSON responses + // are surfaced correctly instead of triggering stream_early_eof. + if (sourceFormat === "claude") { + if (typeof acceptHeader === "string" && /text\/event-stream/i.test(acceptHeader)) { + return true; + } + return false; + } + // No explicit stream param — preserve OmniRoute's streaming default unless // the client explicitly asks for JSON and does not also accept SSE. return !clientWantsJsonResponse(acceptHeader); diff --git a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts index 4b2b398316..81c91d8282 100644 --- a/tests/unit/t26-ai-sdk-accept-header-compat.test.ts +++ b/tests/unit/t26-ai-sdk-accept-header-compat.test.ts @@ -48,6 +48,39 @@ test("T26: explicit stream:false always prevents streaming", () => { assert.equal(resolveStreamFlag(false, undefined), false); }); +test("T26: sourceFormat=claude applies Anthropic Messages non-stream default (#2325)", () => { + // Anthropic Messages API spec: stream defaults to false when body omits it, + // regardless of Accept header. Previously OmniRoute defaulted to stream=true + // for Accept: */* or undefined, causing STREAM_EARLY_EOF on /v1/messages. + + // Ambiguous cases must default to non-stream when sourceFormat is claude + assert.equal(resolveStreamFlag(undefined, undefined, "claude"), false); + assert.equal(resolveStreamFlag(undefined, "*/*", "claude"), false); + assert.equal(resolveStreamFlag(undefined, "application/json", "claude"), false); + + // Explicit body stream still wins over format default + assert.equal(resolveStreamFlag(true, undefined, "claude"), true); + assert.equal(resolveStreamFlag(true, "*/*", "claude"), true); + assert.equal(resolveStreamFlag(false, "text/event-stream", "claude"), false); + + // Accept: text/event-stream is honored as an explicit SSE opt-in + assert.equal(resolveStreamFlag(undefined, "text/event-stream", "claude"), true); + assert.equal(resolveStreamFlag(undefined, "application/json, text/event-stream", "claude"), true); +}); + +test("T26: non-claude sourceFormat preserves pre-#2325 streaming default", () => { + // OpenAI / Gemini / Codex callers keep the existing streaming-by-default heuristic + // so we don't break SDKs that omit `stream` and expect SSE. + assert.equal(resolveStreamFlag(undefined, undefined, "openai"), true); + assert.equal(resolveStreamFlag(undefined, "*/*", "openai"), true); + assert.equal(resolveStreamFlag(undefined, "application/json", "openai"), false); + assert.equal(resolveStreamFlag(undefined, undefined, "gemini"), true); + assert.equal(resolveStreamFlag(undefined, undefined, "codex"), true); + // Omitting sourceFormat reproduces the legacy two-arg behavior exactly + assert.equal(resolveStreamFlag(undefined, undefined), true); + assert.equal(resolveStreamFlag(undefined, "application/json"), false); +}); + test("T26: explicit non-stream aliases are detected", () => { assert.equal(hasExplicitNoStreamParam({ non_stream: true }), true); assert.equal(hasExplicitNoStreamParam({ disable_stream: true }), true);