From cb08cf221da25daade62c2d2dadc1d8d762e8831 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 10 Jun 2026 18:06:17 -0300 Subject: [PATCH] fix: /v1/completions returns legacy text-completion format, not chat (#3571) (#3596) --- CHANGELOG.md | 1 + src/app/api/v1/completions/route.ts | 12 +- .../v1/completions/textCompletionTransform.ts | 122 ++++++++++++++++++ .../unit/completions-text-format-3571.test.ts | 106 +++++++++++++++ 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 src/app/api/v1/completions/textCompletionTransform.ts create mode 100644 tests/unit/completions-text-format-3571.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2fd2baee..693e256e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### 🔧 Bug Fixes - **fix(routing):** a valid `max_tokens`-truncated upstream response is no longer misclassified as empty content and rewritten into a fake 502 — `isEmptyContentResponse()` flagged any Claude `content:[]` / OpenAI empty-choice payload regardless of `stop_reason`/`finish_reason`, so a Claude Code `max_tokens: 1` connectivity ping (HTTP 200, `stop_reason:"max_tokens"`, empty content) became a synthetic `502 "Provider returned empty content"` and triggered a needless family fallback. The guard now treats a terminal truncation/tool signal (Claude `stop_reason` `max_tokens`/`tool_use`, OpenAI `finish_reason` `length`/`tool_calls`) as a legitimate completion; genuinely empty responses (no terminal reason, or `stop`/`end_turn` with empty content) are still caught. ([#3572](https://github.com/diegosouzapw/OmniRoute/issues/3572)) +- **fix(api):** `/v1/completions` now returns the legacy OpenAI Completions shape (`object:"text_completion"`, `choices[].text`) instead of chat payloads (`choices[].message|delta.content`) — the endpoint routes internally through the chat pipeline, so legacy Completion clients like TabbyML's `openai/completion` backend crashed with `missing field "text"`. The response (both non-streaming JSON and the SSE stream) is now translated back to the text-completion shape; `[DONE]` and error bodies pass through unchanged. ([#3571](https://github.com/diegosouzapw/OmniRoute/issues/3571)) --- diff --git a/src/app/api/v1/completions/route.ts b/src/app/api/v1/completions/route.ts index a146e46c1b..470b78e8cf 100644 --- a/src/app/api/v1/completions/route.ts +++ b/src/app/api/v1/completions/route.ts @@ -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)); } diff --git a/src/app/api/v1/completions/textCompletionTransform.ts b/src/app/api/v1/completions/textCompletionTransform.ts new file mode 100644 index 0000000000..6f5001db47 --- /dev/null +++ b/src/app/api/v1/completions/textCompletionTransform.ts @@ -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; + +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 { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let buffer = ""; + return new TransformStream({ + 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 { + 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; +} diff --git a/tests/unit/completions-text-format-3571.test.ts b/tests/unit/completions-text-format-3571.test.ts new file mode 100644 index 0000000000..98deb9d313 --- /dev/null +++ b/tests/unit/completions-text-format-3571.test.ts @@ -0,0 +1,106 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + toTextCompletionObject, + transformSseData, + createTextCompletionStreamTransformer, +} from "../../src/app/api/v1/completions/textCompletionTransform.ts"; + +// #3571 — /v1/completions (legacy OpenAI Completions API) must return +// `object: "text_completion"` with `choices[].text`, not the chat shape +// (`chat.completion(.chunk)` with `choices[].message|delta.content`), which crashes +// TabbyML's `openai/completion` backend ("missing field `text`"). + +test("#3571 non-stream: chat.completion → text_completion with choices[].text", () => { + const out = toTextCompletionObject({ + id: "chatcmpl-1", + object: "chat.completion", + created: 1, + model: "ds/deepseek-v4-flash", + choices: [ + { index: 0, message: { role: "assistant", content: "public class Test {}" }, finish_reason: "stop" }, + ], + usage: { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }, + }); + assert.equal(out.object, "text_completion"); + assert.equal(out.choices[0].text, "public class Test {}"); + assert.equal(out.choices[0].finish_reason, "stop"); + assert.equal(out.choices[0].index, 0); + assert.equal(out.choices[0].logprobs, null); + assert.equal(out.choices[0].message, undefined); // no chat shape leaks + assert.deepEqual(out.usage, { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }); +}); + +test("#3571 stream chunk: chat.completion.chunk(delta) → text_completion with text", () => { + const out = toTextCompletionObject({ + id: "chatcmpl-2", + object: "chat.completion.chunk", + created: 2, + model: "gpt-5.5", + choices: [{ index: 0, delta: { content: "Hi" }, finish_reason: null }], + }); + assert.equal(out.object, "text_completion"); + assert.equal(out.choices[0].text, "Hi"); + assert.equal(out.choices[0].delta, undefined); +}); + +test("#3571 transformSseData: passes [DONE] and non-JSON through, rewrites chat JSON", () => { + assert.equal(transformSseData("[DONE]"), "[DONE]"); + assert.equal(transformSseData(" "), ""); + assert.equal(transformSseData("not json"), "not json"); + const rewritten = JSON.parse( + transformSseData('{"object":"chat.completion.chunk","choices":[{"delta":{"content":"X"}}]}') + ); + assert.equal(rewritten.object, "text_completion"); + assert.equal(rewritten.choices[0].text, "X"); +}); + +test("#3571 empty delta content → text:'' (never undefined → no 'missing field text')", () => { + const out = toTextCompletionObject({ + object: "chat.completion.chunk", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }); + assert.equal(out.choices[0].text, ""); +}); + +test("#3571 stream transformer end-to-end: chat SSE → text SSE", async () => { + const encoder = new TextEncoder(); + const chatSse = + 'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n' + + 'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":"stop"}]}\n\n' + + "data: [DONE]\n\n"; + + const source = new ReadableStream({ + start(controller) { + // split into two arbitrary byte chunks to exercise the line buffer across boundaries + const mid = Math.floor(chatSse.length / 2); + controller.enqueue(encoder.encode(chatSse.slice(0, mid))); + controller.enqueue(encoder.encode(chatSse.slice(mid))); + controller.close(); + }, + }); + + const out = source.pipeThrough(createTextCompletionStreamTransformer()); + const reader = out.getReader(); + const decoder = new TextDecoder(); + let result = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + result += decoder.decode(value, { stream: true }); + } + + const dataLines = result + .split("\n") + .filter((l) => l.startsWith("data:") && !l.includes("[DONE]")) + .map((l) => JSON.parse(l.slice("data:".length).trim())); + + assert.equal(dataLines.length, 2); + assert.ok(dataLines.every((o) => o.object === "text_completion")); + assert.equal(dataLines[0].choices[0].text, "Hello"); + assert.equal(dataLines[1].choices[0].text, " world"); + assert.equal(dataLines[1].choices[0].finish_reason, "stop"); + assert.ok(result.includes("data: [DONE]")); // [DONE] preserved + assert.ok(!result.includes("delta")); // no chat shape leaks +});