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

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

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

View File

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