diff --git a/CHANGELOG.md b/CHANGELOG.md index 8368652fb2..3f7bed4e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### 🐛 Bug Fixes +- **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) - **fix(api):** non-streaming `/v1/completions` responses now echo `body.model` aligned with the `X-OmniRoute-Model` header ([#6426](https://github.com/diegosouzapw/OmniRoute/issues/6426)). Regression guard: `tests/unit/v1-completions-model-header-match-6426.test.ts`. (thanks @chirag127) - **fix(api):** unknown `/v1/*` routes now return a JSON `404 not_found` instead of the Next.js dashboard HTML shell ([#6405](https://github.com/diegosouzapw/OmniRoute/issues/6405)). Regression guard: `tests/unit/api/v1-catchall-json-404.test.ts`. (thanks @chirag127) diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index 470b78e8cf..07b577be5a 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -77,8 +77,11 @@ 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)) + await handleChat(newRequest, buildClientRawRequest(request, body)), + typeof body.model === "string" ? body.model : undefined ); } } @@ -88,5 +91,15 @@ export async function POST(request: Request) { // Standard path: body already has messages[] (chat format). Still emit the legacy // text-completion shape — this is the /v1/completions contract (#3571). - return await asTextCompletionResponse(await handleChat(request)); + // Re-read body.model so the response echoes the caller's requested identifier. + let requestedModel: string | undefined; + try { + const bodyForModel = await request.clone().json().catch(() => null); + if (bodyForModel && typeof bodyForModel.model === "string") { + requestedModel = bodyForModel.model; + } + } catch { + // ignore — asTextCompletionResponse falls back to upstream body.model + } + return await asTextCompletionResponse(await handleChat(request), requestedModel); } diff --git a/src/app/api/v1/completions/textCompletionTransform.ts b/src/app/api/v1/completions/textCompletionTransform.ts index e492734243..c2258aba67 100644 --- a/src/app/api/v1/completions/textCompletionTransform.ts +++ b/src/app/api/v1/completions/textCompletionTransform.ts @@ -24,8 +24,14 @@ function choiceText(choice: AnyRec): string { * Convert a single chat.completion / chat.completion.chunk object into the legacy * text-completion shape (`object: "text_completion"`, `choices[].text`). * Non-object input and already-text_completion objects are returned unchanged. + * + * When `requestedModel` is provided, the returned `model` echoes the caller's + * requested identifier rather than the upstream provider's post-routing model + * string. Legacy OpenAI Completions clients pin cache keys / observability to + * the model they asked for, and the `x-omniroute-model` response header + * already advertises the request-side identifier — the body must match. */ -export function toTextCompletionObject(obj: AnyRec): AnyRec { +export function toTextCompletionObject(obj: AnyRec, requestedModel?: string): AnyRec { if (!obj || typeof obj !== "object" || !Array.isArray(obj.choices)) return obj; const choices = obj.choices.map((c: AnyRec, i: number) => ({ @@ -39,7 +45,7 @@ export function toTextCompletionObject(obj: AnyRec): AnyRec { id: obj.id, object: "text_completion", created: obj.created, - model: obj.model, + model: requestedModel ?? obj.model, choices, }; if (obj.usage) out.usage = obj.usage; @@ -52,27 +58,29 @@ export function toTextCompletionObject(obj: AnyRec): AnyRec { * text-completion JSON, `"[DONE]"` unchanged, or the original payload when it is * not JSON we recognise. */ -export function transformSseData(payload: string): string { +export function transformSseData(payload: string, requestedModel?: string): string { const trimmed = payload.trim(); if (trimmed === "" || trimmed === "[DONE]") return trimmed; try { - return JSON.stringify(toTextCompletionObject(JSON.parse(trimmed))); + return JSON.stringify(toTextCompletionObject(JSON.parse(trimmed), requestedModel)); } catch { return trimmed; } } -function transformLine(line: string): string { +function transformLine(line: string, requestedModel?: string): string { if (!line.startsWith("data:")) return line; const payload = line.slice("data:".length).replace(/^ /, ""); - return "data: " + transformSseData(payload); + return "data: " + transformSseData(payload, requestedModel); } /** * A line-oriented SSE TransformStream that rewrites each `data:` event from chat * shape to text-completion shape, passing through blank separators and `[DONE]`. */ -export function createTextCompletionStreamTransformer(): TransformStream { +export function createTextCompletionStreamTransformer( + requestedModel?: string +): TransformStream { const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ""; @@ -83,11 +91,12 @@ export function createTextCompletionStreamTransformer(): TransformStream= 0) { const line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); - controller.enqueue(encoder.encode(transformLine(line) + "\n")); + controller.enqueue(encoder.encode(transformLine(line, requestedModel) + "\n")); } }, flush(controller) { - if (buffer.length > 0) controller.enqueue(encoder.encode(transformLine(buffer))); + if (buffer.length > 0) + controller.enqueue(encoder.encode(transformLine(buffer, requestedModel))); }, }); } @@ -95,8 +104,14 @@ export function createTextCompletionStreamTransformer(): TransformStream { +export async function asTextCompletionResponse( + res: Response, + requestedModel?: string +): Promise { if (!res.ok) return res; const contentType = res.headers.get("content-type") || ""; @@ -106,10 +121,13 @@ export async function asTextCompletionResponse(res: Response): Promise // the client), mirroring the JSON branch below. (#3821-review LEDGER-8) const headers = new Headers(res.headers); headers.delete("content-length"); - return new Response(res.body.pipeThrough(createTextCompletionStreamTransformer()), { - status: res.status, - headers, - }); + return new Response( + res.body.pipeThrough(createTextCompletionStreamTransformer(requestedModel)), + { + status: res.status, + headers, + } + ); } if (contentType.includes("application/json")) { @@ -117,7 +135,7 @@ export async function asTextCompletionResponse(res: Response): Promise if (obj === null) return res; const headers = new Headers(res.headers); headers.delete("content-length"); // body length changed after re-serialization - return new Response(JSON.stringify(toTextCompletionObject(obj)), { + return new Response(JSON.stringify(toTextCompletionObject(obj, requestedModel)), { status: res.status, headers, }); diff --git a/tests/unit/completions-body-model-echo.test.ts b/tests/unit/completions-body-model-echo.test.ts new file mode 100644 index 0000000000..b4c74004da --- /dev/null +++ b/tests/unit/completions-body-model-echo.test.ts @@ -0,0 +1,109 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + toTextCompletionObject, + transformSseData, + createTextCompletionStreamTransformer, + asTextCompletionResponse, +} from "../../src/app/api/v1/completions/textCompletionTransform.ts"; + +// Regression: `/v1/completions` response `body.model` must echo the caller's +// requested model identifier (matching the `x-omniroute-model` response +// header). Legacy OpenAI Completions clients (e.g. TabbyML) pin cache keys +// and observability to the requested model — an upstream-provider string like +// `deepseek-v4.1-flash-preview` in place of the requested `ds/deepseek-v4-flash` +// breaks caching, budgeting, and dashboards. + +test("body.model echoes requestedModel (non-stream, JSON)", () => { + const out = toTextCompletionObject( + { + id: "chatcmpl-1", + object: "chat.completion", + created: 1, + model: "deepseek-v4.1-flash-preview", // upstream provider's post-routing string + choices: [{ index: 0, message: { content: "hi" }, finish_reason: "stop" }], + }, + "ds/deepseek-v4-flash" // what the caller asked for + ); + assert.equal(out.model, "ds/deepseek-v4-flash"); + assert.equal(out.object, "text_completion"); +}); + +test("body.model falls back to upstream model when requestedModel omitted", () => { + const out = toTextCompletionObject({ + id: "chatcmpl-2", + object: "chat.completion", + created: 2, + model: "deepseek-v4.1-flash-preview", + choices: [{ index: 0, message: { content: "hi" }, finish_reason: "stop" }], + }); + assert.equal(out.model, "deepseek-v4.1-flash-preview"); // backward-compat +}); + +test("transformSseData rewrites SSE chunk model when requestedModel given", () => { + const rewritten = JSON.parse( + transformSseData( + '{"object":"chat.completion.chunk","model":"gpt-5.5-turbo-2026-01","choices":[{"delta":{"content":"X"}}]}', + "gpt-5.5" + ) + ); + assert.equal(rewritten.object, "text_completion"); + assert.equal(rewritten.model, "gpt-5.5"); +}); + +test("streaming transformer rewrites every chunk's model to requestedModel", async () => { + const encoder = new TextEncoder(); + const decoder = new TextDecoder(); + const chatSse = + 'data: {"object":"chat.completion.chunk","model":"upstream-inner-1","choices":[{"delta":{"content":"Hi"},"finish_reason":null}]}\n\n' + + 'data: {"object":"chat.completion.chunk","model":"upstream-inner-2","choices":[{"delta":{"content":"!"},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(chatSse)); + controller.close(); + }, + }); + + const out = source.pipeThrough(createTextCompletionStreamTransformer("caller/requested")); + const reader = out.getReader(); + const chunks: string[] = []; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value) chunks.push(decoder.decode(value)); + } + const text = chunks.join(""); + const dataLines = text.split("\n").filter((l) => l.startsWith("data:") && !l.includes("[DONE]")); + assert.equal(dataLines.length, 2); + for (const line of dataLines) { + const parsed = JSON.parse(line.slice("data:".length).trim()); + assert.equal(parsed.object, "text_completion"); + assert.equal(parsed.model, "caller/requested"); + } + // sanity: upstream ids replaced + assert.equal(text.includes("upstream-inner-1"), false); + assert.equal(text.includes("upstream-inner-2"), false); +}); + +test("asTextCompletionResponse (JSON path) rewrites body.model", async () => { + const upstream = new Response( + JSON.stringify({ + id: "chatcmpl-3", + object: "chat.completion", + created: 3, + model: "upstream-real-provider-id", + choices: [{ index: 0, message: { content: "ok" }, finish_reason: "stop" }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); + const wrapped = await asTextCompletionResponse(upstream, "caller/asked-for"); + const body = await wrapped.json(); + assert.equal(body.object, "text_completion"); + assert.equal(body.model, "caller/asked-for"); +});