fix(sse): non-stream OpenAI-compatible requests no longer coerced to SSE when Accept lists application/json (#5305) (#5309)

Integrated into release/v3.8.40
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-29 05:05:36 -03:00
committed by GitHub
parent dbe750f2ae
commit d4c54f600b
5 changed files with 127 additions and 12 deletions

View File

@@ -38,6 +38,7 @@ _In development — bullets added per PR; finalized at release._
- **dashboard:** disabled no-auth providers no longer vanish from the All Providers page. Disabling a no-auth provider (the "No authentication required" toggle, which adds it to `blockedProviders`) silently removed its card because the page _dropped_ blocked no-auth entries from its render list — the only way back was buried under Settings → Security → Blocked Providers. The page now **partitions** no-auth entries: visible providers render as before, blocked ones appear in a "Disabled" sub-group with an **Enable** button that un-blocks them in place. Aggregates, counts and `/v1/models` still consume the visible-only list (blocked providers stay out of routing). Regression guard: `tests/unit/noauth-blocked-partition-5183.test.ts`. ([#5183](https://github.com/diegosouzapw/OmniRoute/issues/5183), follow-up from [#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166) — thanks @WslzGmzs)
- **dashboard:** add a parent `/dashboard/context` page so RSC prefetches of the compression-context hub no longer 404. The route only had sub-routes (`settings`, `combos`, `ultra`, …) and no parent page, so the App Router returned 404 for the bare segment. The parent now redirects to its canonical sub-route (`/dashboard/context/settings`), honoring a legacy `?tab=` query for deep links. Regression guard: `tests/unit/dashboard/context-parent-redirect-5298.test.ts` ([#5298](https://github.com/diegosouzapw/OmniRoute/issues/5298) — thanks @KooshaPari)
- **i18n:** add the missing `sidebar.gamificationGroup` message across all 42 locales — the Gamification sidebar group referenced a `titleKey` that existed in no locale, logging `MISSING_MESSAGE: sidebar.gamificationGroup (en)` at runtime (the group still rendered via its `titleFallback`). The key is now present everywhere so the warning is gone and locale coverage is unaffected ([#5298](https://github.com/diegosouzapw/OmniRoute/issues/5298) — thanks @KooshaPari)
- **api(stream):** `/v1/chat/completions` no longer returns SSE for a non-stream OpenAI-compatible request when `stream` is omitted and the client sends `Accept: application/json, text/event-stream` — the Vercel AI SDK / OpenAI SDK non-stream signature (`doGenerate()`/`generateText()`), which then failed with `Invalid JSON response` (`Unexpected token 'd', "data: {"id"...`). The route-level Accept override (#302) and `resolveStreamFlag` now treat an Accept header that explicitly lists `application/json` as a JSON opt-in even when it also lists `text/event-stream`; only a pure `Accept: text/event-stream` (no `application/json`) still opts an omitted-`stream` request into SSE, and an explicit body `stream` value always wins. The shared decision now lives in `acceptHeaderForcesStream`. Regression guard: `tests/unit/sse-nonstream-accept-5305.test.ts`. ([#5305](https://github.com/diegosouzapw/OmniRoute/issues/5305) — thanks @md-riaz)
- **providers:** drop the retired GPT5.2 / GPT4.5 models from the direct **ChatGPTweb** and **Codex** surfaces (OpenAI removed them there), so OmniRoute stops advertising/routing models that no longer exist. Scoped on purpose to those two providers — thirdparty proxies that still expose the ids are untouched. ([#5280](https://github.com/diegosouzapw/OmniRoute/pull/5280) — thanks @backryun)
- **codex:** drop the deprecated `local_shell` hosted tool type before forwarding to OpenAI's Responses API, resolving the omni-combo `400 "The local_shell tool is no longer supported."` spike. Inbound Responses `local_shell` is still accepted and mapped to a caller-side Chat `shell` function for compatibility. ([#5250](https://github.com/diegosouzapw/OmniRoute/pull/5250), [#5256](https://github.com/diegosouzapw/OmniRoute/pull/5256) — thanks @KooshaPari)
- **antigravity:** retry excluded accounts via the fallback LRU. The combo same-model retry loop accumulates excluded Antigravity connection ids after account-level failures, but auth selection only treated a single `excludeConnectionId` as a fallback scenario — once exclusions accumulated through `excludedConnectionIds`, selection could fall back to normal sticky/priority behavior instead of LRU-selecting the next eligible account for the same model/family. Any non-empty accumulated exclude set is now treated as fallback mode. Builds on the family-scoped lockout work in #5180 (v3.8.39). ([#5222](https://github.com/diegosouzapw/OmniRoute/pull/5222) — thanks @Ardem2025)

View File

@@ -40,6 +40,22 @@ export function clientWantsJsonResponse(acceptHeader: unknown): boolean {
return normalized.includes("application/json") && !normalized.includes("text/event-stream");
}
/**
* Route-level Accept-header streaming opt-in (#302). A client that OMITS `stream`
* in the body but sends `Accept: text/event-stream` is asking for SSE (curl/httpx
* and similar non-SDK clients). But a client that ALSO lists `application/json`
* is using the OpenAI / Vercel AI SDK non-stream signature
* (`Accept: application/json, text/event-stream` with the body omitting `stream`)
* and expects a JSON object — do NOT force SSE for it (#5305). An explicit body
* `stream` value (true or false) always wins and is never overridden.
*/
export function acceptHeaderForcesStream(acceptHeader: unknown, bodyStream: unknown): boolean {
if (bodyStream !== undefined) return false;
if (typeof acceptHeader !== "string") return false;
const normalized = acceptHeader.toLowerCase();
return normalized.includes("text/event-stream") && !normalized.includes("application/json");
}
/**
* Resolves stream behavior from request body + Accept header.
* Priority: explicit `stream: true/false` in body wins, UNLESS the provider
@@ -103,6 +119,17 @@ export function resolveStreamFlag(
return false;
}
// An Accept header that explicitly lists `application/json` is a JSON opt-in,
// even when it ALSO lists `text/event-stream`. That is the OpenAI / Vercel AI
// SDK non-stream signature (`Accept: application/json, text/event-stream` with
// the body omitting `stream`): doGenerate()/generateText() send it and parse
// the response as JSON. Default such requests to non-stream so they don't get
// an SSE body they can't parse (#5305). Pure-SSE clients (text/event-stream
// with no application/json) and clients with no/`*/*` Accept still stream.
if (typeof acceptHeader === "string" && /application\/json/i.test(acceptHeader)) {
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);

View File

@@ -19,6 +19,7 @@ import {
import { getModelInfo, getComboForModel } from "../services/model";
import { resolveBareModelToConnectionDefault } from "@omniroute/open-sse/services/model.ts";
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat.ts";
import { isSelfInflictedUpstreamTimeout } from "@omniroute/open-sse/handlers/chatCore/cooldownClassification.ts";
import { applyNoThinkingAlias } from "@omniroute/open-sse/utils/noThinkingAlias.ts";
import { handleComboChat } from "@omniroute/open-sse/services/combo.ts";
@@ -243,17 +244,13 @@ export async function handleChat(
clientRawRequest = buildClientRawRequest(request, rawClientBody);
}
// T01 — Accept header negotiation
// If client asks for text/event-stream via the Accept header AND the JSON body
// does not explicitly set stream=false, treat it as stream=true.
// This ensures compatibility with curl/httpx and similar non-OpenAI clients.
//
// FIX #302: OpenAI Python SDK sends Accept: application/json, text/event-stream
// in every request — even when called with stream=False. We must NOT override
// an explicit stream=false body field, as that silently breaks tool_calls and
// structured completions for SDK users who rely on non-streaming mode.
// T01 — Accept-header streaming opt-in (#302 / #5305). A bare `Accept:
// text/event-stream` with `stream` omitted opts a curl/httpx-style client into
// SSE; a client that ALSO lists application/json (OpenAI / Vercel AI SDK
// non-stream signature) does NOT — it expects a JSON object. An explicit body
// `stream` value (true or false) always wins. See acceptHeaderForcesStream.
const acceptHeader = request.headers.get("accept") || "";
if (acceptHeader.includes("text/event-stream") && body.stream === undefined) {
if (acceptHeaderForcesStream(acceptHeader, body.stream)) {
body = { ...body, stream: true };
log.debug(
"STREAM",

View File

@@ -140,14 +140,14 @@ test("handleChat redacts PII before sending the upstream request", async () => {
assert.equal(json.choices[0].message.content, "Redacted response");
});
test("handleChat treats Accept text/event-stream as stream=true and returns a session header", async () => {
test("handleChat treats a pure Accept: text/event-stream as stream=true and returns a session header", async () => {
await seedConnection("openai", { apiKey: "sk-openai-stream" });
globalThis.fetch = async () => buildOpenAIStreamResponse("Accept header stream");
const response = await handleChat(
buildRequest({
headers: { Accept: "application/json, text/event-stream" },
headers: { Accept: "text/event-stream" },
body: {
model: "openai/gpt-4.1",
messages: [{ role: "user", content: "stream please" }],
@@ -163,6 +163,27 @@ test("handleChat treats Accept text/event-stream as stream=true and returns a se
assert.match(raw, /\[DONE\]/);
});
test("handleChat returns JSON (not SSE) for the OpenAI/Vercel SDK non-stream signature — Accept: application/json, text/event-stream with stream omitted (#5305)", async () => {
await seedConnection("openai", { apiKey: "sk-openai-5305" });
globalThis.fetch = async () => buildOpenAIResponse("non-stream json");
const response = await handleChat(
buildRequest({
headers: { Accept: "application/json, text/event-stream" },
body: {
model: "openai/gpt-4.1",
messages: [{ role: "user", content: "no stream field" }],
},
})
);
const json = (await response.json()) as any;
assert.equal(response.status, 200);
assert.match(response.headers.get("Content-Type") || "", /application\/json/);
assert.equal(json.choices[0].message.content, "non-stream json");
});
test("handleChat rejects requests without a model", async () => {
const response = await handleChat(
buildRequest({

View File

@@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
resolveStreamFlag,
acceptHeaderForcesStream,
} from "../../open-sse/utils/aiSdkCompat.ts";
// #5305: the Vercel AI SDK / OpenAI SDK non-stream path (doGenerate/generateText)
// OMITS `stream` in the body and sends `Accept: application/json, text/event-stream`,
// then parses the response as JSON. OmniRoute was coercing such requests into SSE
// (route-level Accept override in src/sse/handlers/chat.ts + resolveStreamFlag),
// so the caller got `data: {...}` and failed with "Invalid JSON response".
//
// Rule: an Accept header that explicitly lists `application/json` is a JSON opt-in,
// even when it ALSO lists `text/event-stream`. Only a PURE SSE Accept header
// (text/event-stream WITHOUT application/json) forces streaming when `stream` is
// omitted. An explicit body `stream` value always wins.
describe("#5305 acceptHeaderForcesStream — route-level Accept streaming opt-in", () => {
it("does NOT force stream for the Vercel/OpenAI SDK non-stream signature (json + sse, stream omitted)", () => {
assert.equal(acceptHeaderForcesStream("application/json, text/event-stream", undefined), false);
});
it("forces stream for a pure-SSE Accept header (text/event-stream only, stream omitted)", () => {
assert.equal(acceptHeaderForcesStream("text/event-stream", undefined), true);
});
it("does NOT force stream for a pure-JSON Accept header", () => {
assert.equal(acceptHeaderForcesStream("application/json", undefined), false);
});
it("never overrides an explicit body stream value (false or true)", () => {
assert.equal(acceptHeaderForcesStream("text/event-stream", false), false);
assert.equal(acceptHeaderForcesStream("text/event-stream", true), false);
});
it("does not force stream when there is no Accept header", () => {
assert.equal(acceptHeaderForcesStream(undefined, undefined), false);
assert.equal(acceptHeaderForcesStream("", undefined), false);
});
});
describe("#5305 resolveStreamFlag — openai non-stream with mixed Accept", () => {
it("defaults to NON-stream (JSON) for openai + omitted stream + `application/json, text/event-stream`", () => {
assert.equal(resolveStreamFlag(undefined, "application/json, text/event-stream", "openai"), false);
});
it("keeps streaming for openai + omitted stream + pure text/event-stream Accept", () => {
assert.equal(resolveStreamFlag(undefined, "text/event-stream", "openai"), true);
});
it("keeps streaming for openai + omitted stream + no/`*/*` Accept (legacy default unchanged)", () => {
assert.equal(resolveStreamFlag(undefined, undefined, "openai"), true);
assert.equal(resolveStreamFlag(undefined, "*/*", "openai"), true);
});
it("explicit body stream:true still wins over a json-leaning Accept", () => {
assert.equal(resolveStreamFlag(true, "application/json, text/event-stream", "openai"), true);
});
it("a forceStream provider still streams regardless of the json Accept (#2081 preserved)", () => {
assert.equal(
resolveStreamFlag(undefined, "application/json, text/event-stream", "openai", {
providerRequiresStreaming: true,
}),
true
);
});
});