mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 06:12:10 +03:00
fix(v1/messages): default to non-stream for Claude format when ambiguous (#2326)
Integrated into release/v3.8.0 — fixes STREAM_EARLY_EOF on POST /v1/messages when stream is omitted
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user