Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion open-sse/handlers/chatCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
25 changes: 24 additions & 1 deletion open-sse/utils/aiSdkCompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: sourceFormat parameter typed as string is too loose

The parameter accepts any string value, making it easy to accidentally pass an incorrect format identifier. Consider typing it more strictly, e.g. sourceFormat?: keyof typeof FORMATS | string or importing a dedicated format type from formats.ts. This would improve type safety and IDE autocomplete for callers.

): 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") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Hardcoded string literal "claude" instead of FORMATS.CLAUDE constant

The rest of chatCore.ts consistently uses FORMATS.CLAUDE (lines 545, 556, 580, 587, 941, 2409, 2599, 4102, 4176) to check for the Claude format. If FORMATS.CLAUDE is ever renamed in open-sse/translator/formats.ts, this hardcoded string will silently break without a compile-time error.

Consider using FORMATS.CLAUDE here or at least typing sourceFormat as a union of known format values to catch mismatches.

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);
Expand Down
33 changes: 33 additions & 0 deletions tests/unit/t26-ai-sdk-accept-header-compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down