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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions open-sse/utils/aiSdkCompat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
17 changes: 7 additions & 10 deletions src/sse/handlers/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
25 changes: 23 additions & 2 deletions tests/unit/chat-route-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
Expand All @@ -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({
Expand Down
69 changes: 69 additions & 0 deletions tests/unit/sse-nonstream-accept-5305.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The repository uses Vitest as its primary testing framework (as indicated by vitest.config.ts in the root and the repository style guide references to Vitest files). Importing from node:test and node:assert introduces inconsistency and can cause issues with Vitest's test runner, coverage tools, or mocking features. Using vitest instead of the Node.js built-in test runner ensures consistency across the test suite.

Suggested change
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { describe, it, assert } from "vitest";

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
);
});
});
Loading