mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 12:22:14 +03:00
This commit is contained in:
committed by
GitHub
parent
1bc858837a
commit
cb08cf221d
@@ -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))
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
122
src/app/api/v1/completions/textCompletionTransform.ts
Normal file
122
src/app/api/v1/completions/textCompletionTransform.ts
Normal 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;
|
||||
}
|
||||
106
tests/unit/completions-text-format-3571.test.ts
Normal file
106
tests/unit/completions-text-format-3571.test.ts
Normal 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
|
||||
});
|
||||
Reference in New Issue
Block a user