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