157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
toTextCompletionObject,
|
|
transformSseData,
|
|
createTextCompletionStreamTransformer,
|
|
asTextCompletionResponse,
|
|
} 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
|
|
});
|
|
|
|
// #3821-review LEDGER-8 — both response branches rewrite the body, so a stale upstream
|
|
// content-length must be dropped (a buffered SSE body with content-length would otherwise
|
|
// advertise the pre-rewrite length and truncate/hang the client).
|
|
test("#3571/#3821 asTextCompletionResponse drops content-length on the SSE branch", async () => {
|
|
const sseBody =
|
|
'data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}]}\n\n';
|
|
const upstream = new Response(sseBody, {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "text/event-stream",
|
|
// A (deliberately wrong) content-length that must NOT survive the rewrite.
|
|
"content-length": String(sseBody.length),
|
|
},
|
|
});
|
|
|
|
const out = await asTextCompletionResponse(upstream);
|
|
assert.equal(out.headers.get("content-length"), null, "content-length must be stripped");
|
|
assert.match(out.headers.get("content-type") || "", /text\/event-stream/);
|
|
const text = await out.text();
|
|
assert.ok(text.includes('"object":"text_completion"'));
|
|
assert.ok(text.includes('"text":"hi"'));
|
|
});
|
|
|
|
test("#3571/#3821 asTextCompletionResponse drops content-length on the JSON branch", async () => {
|
|
const jsonBody = JSON.stringify({
|
|
object: "chat.completion",
|
|
choices: [{ index: 0, message: { content: "hi" }, finish_reason: "stop" }],
|
|
});
|
|
const upstream = new Response(jsonBody, {
|
|
status: 200,
|
|
headers: { "content-type": "application/json", "content-length": String(jsonBody.length) },
|
|
});
|
|
|
|
const out = await asTextCompletionResponse(upstream);
|
|
assert.equal(out.headers.get("content-length"), null);
|
|
const obj = await out.json();
|
|
assert.equal(obj.object, "text_completion");
|
|
assert.equal(obj.choices[0].text, "hi");
|
|
});
|
|
|
|
test("#3571/#3821 asTextCompletionResponse passes error responses through untouched", async () => {
|
|
const upstream = new Response(JSON.stringify({ error: { message: "boom" } }), {
|
|
status: 500,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
const out = await asTextCompletionResponse(upstream);
|
|
assert.equal(out, upstream, "non-ok responses are returned as-is");
|
|
});
|