mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
109
tests/unit/completions-body-model-echo.test.ts
Normal file
109
tests/unit/completions-body-model-echo.test.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user