From d4c54f600b70cfd323a98b74ecdcbbd40ed92a7c Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Mon, 29 Jun 2026 05:05:36 -0300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + open-sse/utils/aiSdkCompat.ts | 27 ++++++++ src/sse/handlers/chat.ts | 17 ++--- tests/unit/chat-route-coverage.test.ts | 25 ++++++- tests/unit/sse-nonstream-accept-5305.test.ts | 69 ++++++++++++++++++++ 5 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 tests/unit/sse-nonstream-accept-5305.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ec4439a35..53c512e26e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 GPT‑5.2 / GPT‑4.5 models from the direct **ChatGPT‑web** and **Codex** surfaces (OpenAI removed them there), so OmniRoute stops advertising/routing models that no longer exist. Scoped on purpose to those two providers — third‑party 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) diff --git a/open-sse/utils/aiSdkCompat.ts b/open-sse/utils/aiSdkCompat.ts index 088e898b82..fb26394a4b 100644 --- a/open-sse/utils/aiSdkCompat.ts +++ b/open-sse/utils/aiSdkCompat.ts @@ -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); diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 588a3dbb25..f2db6caff6 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -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", diff --git a/tests/unit/chat-route-coverage.test.ts b/tests/unit/chat-route-coverage.test.ts index 28ae908116..6accc32901 100644 --- a/tests/unit/chat-route-coverage.test.ts +++ b/tests/unit/chat-route-coverage.test.ts @@ -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({ diff --git a/tests/unit/sse-nonstream-accept-5305.test.ts b/tests/unit/sse-nonstream-accept-5305.test.ts new file mode 100644 index 0000000000..05b2106280 --- /dev/null +++ b/tests/unit/sse-nonstream-accept-5305.test.ts @@ -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 + ); + }); +});