diff --git a/CHANGELOG.md b/CHANGELOG.md index ef0ca1d484..09d7cd2203 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **fix(api):** the `X-OmniRoute-Compression` response header is now echoed on `/v1/chat/completions` and `/v1/completions` ([#6422](https://github.com/diegosouzapw/OmniRoute/issues/6422)). Regression guard: `tests/unit/compression-header-echo-6422.test.ts`. (thanks @chirag127) - **fix(api):** concurrent `GET /v1/models` requests are coalesced into a single catalog build ([#6408](https://github.com/diegosouzapw/OmniRoute/issues/6408)). Regression guard: `tests/unit/v1-models-concurrent-6408.test.ts`. (thanks @chirag127) - **fix(api):** `/v1/completions` now echoes the requested `body.model` in its JSON + streamed responses. Regression guard: `tests/unit/completions-body-model-echo.test.ts`. (thanks @chirag127) - **fix(api):** env-var master keys now see the full `/v1/models` catalog ([#6406](https://github.com/diegosouzapw/OmniRoute/issues/6406)). Regression guard: `tests/unit/models-catalog-envkey-6406.test.ts`. (thanks @chirag127) diff --git a/src/app/api/v1/chat/completions/route.ts b/src/app/api/v1/chat/completions/route.ts index 0bc62035d7..5edf8da0d8 100644 --- a/src/app/api/v1/chat/completions/route.ts +++ b/src/app/api/v1/chat/completions/route.ts @@ -8,6 +8,10 @@ import { acceptHeaderForcesStream } from "@omniroute/open-sse/utils/aiSdkCompat. import { withEarlyStreamKeepalive } from "@omniroute/open-sse/utils/earlyStreamKeepalive"; import { resolveKeepaliveThreshold } from "@omniroute/open-sse/utils/keepaliveThreshold"; import { checkChatAdmission } from "@/shared/middleware/chatBodyAdmission"; +import { + readCompressionRequestHeader, + withCompressionHeaderEcho, +} from "@/shared/utils/compressionHeaderEcho"; let initPromise = null; @@ -99,14 +103,26 @@ export async function POST(request) { parsedBodyIsRecord && acceptHeaderForcesStream(acceptHeader, parsedBody.stream); const wantsStreaming = (parsedBodyIsRecord && parsedBody.stream === true) || acceptForcesStream; + // #6422 — capture the compression request header once so we can echo it back + // on the response when internal early-returns (idempotency cache, some combo + // paths) drop the meta the docs promise. + const compressionRequestHeader = readCompressionRequestHeader(request); + if (wantsStreaming) { const reqId = generateRequestId(); - return await withEarlyStreamKeepalive(handleChat(request, null, parsedBody, reqId), { - signal: request.signal, - thresholdMs: resolveKeepaliveThreshold(parsedBody?.model), - extraHeaders: { "X-Correlation-Id": reqId }, - }); + const streamedResponse = await withEarlyStreamKeepalive( + handleChat(request, null, parsedBody, reqId), + { + signal: request.signal, + thresholdMs: resolveKeepaliveThreshold(parsedBody?.model), + extraHeaders: { "X-Correlation-Id": reqId }, + } + ); + return withCompressionHeaderEcho(streamedResponse, compressionRequestHeader); } - return await handleChat(request, null, parsedBody); + return withCompressionHeaderEcho( + await handleChat(request, null, parsedBody), + compressionRequestHeader + ); } diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index 07b577be5a..d6a2254b9c 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -3,6 +3,10 @@ import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; import { createInjectionGuard } from "@/middleware/promptInjectionGuard"; import { asTextCompletionResponse } from "./textCompletionTransform.ts"; +import { + readCompressionRequestHeader, + withCompressionHeaderEcho, +} from "@/shared/utils/compressionHeaderEcho"; let initPromise = null; const injectionGuard = createInjectionGuard(); @@ -40,6 +44,10 @@ export async function OPTIONS() { export async function POST(request: Request) { await ensureInitialized(); + // #6422 — capture the compression request header once so we can echo it back + // on the response when internal early-returns drop the meta the docs promise. + const compressionRequestHeader = readCompressionRequestHeader(request); + // Prompt injection guard try { const cloned = request.clone(); @@ -78,10 +86,14 @@ export async function POST(request: Request) { // #3571 — translate the chat-pipeline response back to the legacy // text-completion shape so OpenAI Completion clients (e.g. TabbyML) work. // Thread `body.model` so response `body.model` echoes the caller's - // requested identifier, matching the `x-omniroute-model` header. - return await asTextCompletionResponse( - await handleChat(newRequest, buildClientRawRequest(request, body)), - typeof body.model === "string" ? body.model : undefined + // requested identifier, matching the `x-omniroute-model` header, and + // echo the compression header on the way out. + return withCompressionHeaderEcho( + await asTextCompletionResponse( + await handleChat(newRequest, buildClientRawRequest(request, body)), + typeof body.model === "string" ? body.model : undefined + ), + compressionRequestHeader ); } } @@ -101,5 +113,8 @@ export async function POST(request: Request) { } catch { // ignore — asTextCompletionResponse falls back to upstream body.model } - return await asTextCompletionResponse(await handleChat(request), requestedModel); + return withCompressionHeaderEcho( + await asTextCompletionResponse(await handleChat(request), requestedModel), + compressionRequestHeader + ); } diff --git a/src/shared/utils/compressionHeaderEcho.ts b/src/shared/utils/compressionHeaderEcho.ts new file mode 100644 index 0000000000..e5ec4beee5 --- /dev/null +++ b/src/shared/utils/compressionHeaderEcho.ts @@ -0,0 +1,59 @@ +/** + * Compression response header echo (#6422). + * + * When a request carries `x-omniroute-compression`, docs promise the response echoes + * `X-OmniRoute-Compression: ; source=`. Internal paths (idempotency + * cache short-circuit, some combo/fusion assembly paths) build response headers + * without threading `compressionResponseMeta` — so the promised echo silently + * disappears. This helper is the outermost safety net: if the response is missing + * the header and the request supplied one, echo a best-effort value derived + * directly from the request header. Existing header values from the inner pipeline + * (which carry richer `tokens=...; rules: ...` annotations) are never overwritten. + */ +import { OMNIROUTE_RESPONSE_HEADERS } from "@/shared/constants/headers"; + +const COMPRESSION_REQUEST_HEADER = "x-omniroute-compression"; +const COMPRESSION_RESPONSE_HEADER = OMNIROUTE_RESPONSE_HEADERS.compression; + +function normalizeRequestValue(raw: string): string { + const trimmed = raw.trim(); + const lower = trimmed.toLowerCase(); + if (lower === "off" || lower === "default") return lower; + if (lower.startsWith("engine:")) return lower; + // Named combo — preserve the operator's casing on the mode field. Source is always + // request-header when the header drove the choice. + return trimmed; +} + +/** + * Read the compression request header (case-insensitive). Returns null on absent/blank. + */ +export function readCompressionRequestHeader(request: { + headers: { get(name: string): string | null }; +}): string | null { + const raw = request.headers.get(COMPRESSION_REQUEST_HEADER); + return typeof raw === "string" && raw.trim() ? raw : null; +} + +/** + * Wrap a Response so it carries `X-OmniRoute-Compression: ; source=request-header` + * when the request supplied `x-omniroute-compression` and the inner pipeline did not + * already set it. Never overwrites an existing value — the inner pipeline may have + * attached a richer annotation. A best-effort echo covers idempotency-cache, + * fusion-envelope, and any other early-return path that dropped the meta. + */ +export function withCompressionHeaderEcho( + response: Response, + requestHeaderValue: string | null +): Response { + if (!requestHeaderValue) return response; + if (response.headers.has(COMPRESSION_RESPONSE_HEADER)) return response; + const mode = normalizeRequestValue(requestHeaderValue); + const headers = new Headers(response.headers); + headers.set(COMPRESSION_RESPONSE_HEADER, `${mode}; source=request-header`); + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} diff --git a/tests/unit/compression-header-echo-6422.test.ts b/tests/unit/compression-header-echo-6422.test.ts new file mode 100644 index 0000000000..d60e1ea43e --- /dev/null +++ b/tests/unit/compression-header-echo-6422.test.ts @@ -0,0 +1,89 @@ +/** + * #6422 — X-OmniRoute-Compression response header echo. + * + * Docs promise that when a request supplies `x-omniroute-compression`, the response + * echoes `X-OmniRoute-Compression: ; source=`. Internal early-returns + * (idempotency-cache short-circuit, some combo/fusion assembly paths) omit that + * header, so the outermost route layer echoes a best-effort value from the request. + * These tests lock the helper's contract so a future refactor cannot silently drop + * the echo again. + */ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + readCompressionRequestHeader, + withCompressionHeaderEcho, +} from "../../src/shared/utils/compressionHeaderEcho"; + +function makeRequest(headerValue: string | null): { + headers: { get(name: string): string | null }; +} { + return { + headers: { + get(name: string) { + if (name.toLowerCase() === "x-omniroute-compression") return headerValue; + return null; + }, + }, + }; +} + +describe("compressionHeaderEcho (#6422)", () => { + it("reads the request header (case-insensitive, trimmed)", () => { + assert.equal(readCompressionRequestHeader(makeRequest("engine:rtk")), "engine:rtk"); + assert.equal(readCompressionRequestHeader(makeRequest(" off ")), " off "); + assert.equal(readCompressionRequestHeader(makeRequest(null)), null); + assert.equal(readCompressionRequestHeader(makeRequest("")), null); + assert.equal(readCompressionRequestHeader(makeRequest(" ")), null); + }); + + it("echoes the request header value onto the response when missing", () => { + const inner = new Response("body", { status: 200 }); + const wrapped = withCompressionHeaderEcho(inner, "engine:rtk"); + assert.equal(wrapped.headers.get("X-OmniRoute-Compression"), "engine:rtk; source=request-header"); + assert.equal(wrapped.status, 200); + }); + + it("normalizes off / default / engine:* to lowercase", () => { + assert.equal( + withCompressionHeaderEcho(new Response(""), "OFF").headers.get("X-OmniRoute-Compression"), + "off; source=request-header" + ); + assert.equal( + withCompressionHeaderEcho(new Response(""), "Default").headers.get("X-OmniRoute-Compression"), + "default; source=request-header" + ); + assert.equal( + withCompressionHeaderEcho(new Response(""), "Engine:Rtk").headers.get( + "X-OmniRoute-Compression" + ), + "engine:rtk; source=request-header" + ); + }); + + it("preserves operator casing on named-combo values", () => { + const wrapped = withCompressionHeaderEcho(new Response(""), " MyCombo "); + assert.equal(wrapped.headers.get("X-OmniRoute-Compression"), "MyCombo; source=request-header"); + }); + + it("never overwrites an existing X-OmniRoute-Compression header set by the inner pipeline", () => { + const inner = new Response("", { + headers: { + "X-OmniRoute-Compression": "stacked; source=routing; tokens=100->42; rules: rtk-nl x2", + }, + }); + const wrapped = withCompressionHeaderEcho(inner, "engine:rtk"); + assert.equal( + wrapped.headers.get("X-OmniRoute-Compression"), + "stacked; source=routing; tokens=100->42; rules: rtk-nl x2" + ); + }); + + it("is a no-op when the request did not supply the header", () => { + const inner = new Response("", { headers: { "X-Other": "keep" } }); + const wrapped = withCompressionHeaderEcho(inner, null); + assert.equal(wrapped, inner); + assert.equal(wrapped.headers.get("X-OmniRoute-Compression"), null); + assert.equal(wrapped.headers.get("X-Other"), "keep"); + }); +});