fix: /v1/completions returns legacy text-completion format, not chat (#3571) (#3596)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-10 18:06:17 -03:00
committed by GitHub
parent 1bc858837a
commit cb08cf221d
4 changed files with 238 additions and 3 deletions

View File

@@ -2,6 +2,7 @@ import { CORS_HEADERS } from "@/shared/utils/cors";
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";
let initPromise = null;
const injectionGuard = createInjectionGuard();
@@ -74,13 +75,18 @@ export async function POST(request: Request) {
headers: request.headers,
body: JSON.stringify(normalized),
});
return await handleChat(newRequest, buildClientRawRequest(request, body));
// #3571 — translate the chat-pipeline response back to the legacy
// text-completion shape so OpenAI Completion clients (e.g. TabbyML) work.
return await asTextCompletionResponse(
await handleChat(newRequest, buildClientRawRequest(request, body))
);
}
}
} catch (error) {
console.error("[SECURITY] Prompt injection guard failed:", error);
}
// Standard path: body already has messages[] (chat format)
return await handleChat(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));
}

View File

@@ -0,0 +1,122 @@
/**
* #3571 — `/v1/completions` is the legacy OpenAI Completions API. OmniRoute routes
* it internally through the chat pipeline, which emits chat-shaped payloads
* (`chat.completion` / `chat.completion.chunk` with `choices[].message|delta.content`).
* Legacy Completion clients (e.g. TabbyML's `openai/completion` backend) require
* `choices[].text` and `object: "text_completion"`, and crash on the chat shape
* (`Error("missing field 'text'")`).
*
* These helpers translate a chat-shaped object/stream back to the legacy
* text-completion shape so the endpoint honours its OpenAI Completions contract.
*/
type AnyRec = Record<string, any>;
function choiceText(choice: AnyRec): string {
if (!choice || typeof choice !== "object") return "";
// chat.completion → message.content; chat.completion.chunk → delta.content;
// already-text shape → text.
const text = choice.message?.content ?? choice.delta?.content ?? choice.text ?? "";
return typeof text === "string" ? text : "";
}
/**
* 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.
*/
export function toTextCompletionObject(obj: AnyRec): AnyRec {
if (!obj || typeof obj !== "object" || !Array.isArray(obj.choices)) return obj;
const choices = obj.choices.map((c: AnyRec, i: number) => ({
text: choiceText(c),
index: typeof c?.index === "number" ? c.index : i,
logprobs: c?.logprobs ?? null,
finish_reason: c?.finish_reason ?? null,
}));
const out: AnyRec = {
id: obj.id,
object: "text_completion",
created: obj.created,
model: obj.model,
choices,
};
if (obj.usage) out.usage = obj.usage;
if (obj.system_fingerprint !== undefined) out.system_fingerprint = obj.system_fingerprint;
return out;
}
/**
* Transform the payload of a single SSE `data:` line. Returns the re-serialized
* text-completion JSON, `"[DONE]"` unchanged, or the original payload when it is
* not JSON we recognise.
*/
export function transformSseData(payload: string): string {
const trimmed = payload.trim();
if (trimmed === "" || trimmed === "[DONE]") return trimmed;
try {
return JSON.stringify(toTextCompletionObject(JSON.parse(trimmed)));
} catch {
return trimmed;
}
}
function transformLine(line: string): string {
if (!line.startsWith("data:")) return line;
const payload = line.slice("data:".length).replace(/^ /, "");
return "data: " + transformSseData(payload);
}
/**
* 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> {
const decoder = new TextDecoder();
const encoder = new TextEncoder();
let buffer = "";
return new TransformStream<Uint8Array, Uint8Array>({
transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
let idx: number;
while ((idx = buffer.indexOf("\n")) >= 0) {
const line = buffer.slice(0, idx);
buffer = buffer.slice(idx + 1);
controller.enqueue(encoder.encode(transformLine(line) + "\n"));
}
},
flush(controller) {
if (buffer.length > 0) controller.enqueue(encoder.encode(transformLine(buffer)));
},
});
}
/**
* Wrap a chat-pipeline Response so `/v1/completions` returns the legacy
* text-completion shape. Error responses and non-JSON/non-SSE bodies pass through.
*/
export async function asTextCompletionResponse(res: Response): Promise<Response> {
if (!res.ok) return res;
const contentType = res.headers.get("content-type") || "";
if (contentType.includes("text/event-stream") && res.body) {
return new Response(res.body.pipeThrough(createTextCompletionStreamTransformer()), {
status: res.status,
headers: res.headers,
});
}
if (contentType.includes("application/json")) {
const obj = await res.json().catch(() => null);
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)), {
status: res.status,
headers,
});
}
return res;
}