fix(completions): echo requested body.model on /v1/completions to match x-omniroute-model header (#6429)

echo body.model on /v1/completions (5/5). Integrated into release/v3.8.46.
This commit is contained in:
Chirag Singhal
2026-07-07 03:05:25 +05:30
committed by GitHub
parent 6ea7c68e6e
commit ac96c0dd02
4 changed files with 158 additions and 17 deletions

View File

@@ -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)

View File

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

View File

@@ -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<Uint8Array, Uint8Array> {
export function createTextCompletionStreamTransformer(
requestedModel?: string
): TransformStream<Uint8Array, Uint8Array> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
@@ -83,11 +91,12 @@ export function createTextCompletionStreamTransformer(): TransformStream<Uint8Ar
while ((idx = buffer.indexOf("\n")) >= 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<Uint8Ar
/**
* Wrap a chat-pipeline Response so `/v1/completions` returns the legacy
* text-completion shape. Error responses and non-JSON/non-SSE bodies pass through.
*
* When `requestedModel` is provided, response `body.model` echoes the caller's
* requested identifier (matching the `x-omniroute-model` response header).
*/
export async function asTextCompletionResponse(res: Response): Promise<Response> {
export async function asTextCompletionResponse(
res: Response,
requestedModel?: string
): Promise<Response> {
if (!res.ok) return res;
const contentType = res.headers.get("content-type") || "";
@@ -106,10 +121,13 @@ export async function asTextCompletionResponse(res: Response): Promise<Response>
// 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<Response>
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,
});

View File

@@ -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<Uint8Array>({
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");
});