Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fixes/16614-numeric-delta-content.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(sse):** some Cloudflare Workers AI models stream a digit as a JSON number (`"delta": {"content": 7}`). Only string content counted as text in the passthrough, so those chunks were dropped and the digits vanished from the reply («ABC123» came out as «ABC»). A finite numeric `delta.content` is now turned into its string before the chunk is forwarded, recorded or counted ([#16614](https://github.com/diegosouzapw/OmniRoute/pull/16614)) — thanks @pacocartones. Refs #16554.
11 changes: 10 additions & 1 deletion open-sse/utils/arrayContentDelta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
* which strict Responses clients reject ("malformed response.output_text.delta delta").
*
* These helpers fold such a delta back into the OpenAI shape in place: text parts
* become the `content` string, thinking parts are appended to `reasoning_content`.
* become the `content` string, thinking parts are appended to `reasoning_content`. A numeric
* `content` (some Cloudflare Workers AI models stream digits as JSON numbers) becomes its string.
*/

type JsonRecord = Record<string, unknown>;
Expand All @@ -30,6 +31,14 @@ function partsText(value: unknown): string {

/** Normalize one `delta` object. Returns true when it was changed. */
export function normalizeArrayContentDelta(delta: JsonRecord): boolean {
// Cloudflare Workers AI sometimes streams a digit as a JSON number (`"content": 7`). Keep it as
// the text it stands for: a non-string content is not counted as text further down the
// pipeline, so the digit would be dropped (OmniRoute#16554).
if (typeof delta.content === "number") {
if (!Number.isFinite(delta.content)) return false;
delta.content = String(delta.content);
return true;
}
if (!Array.isArray(delta.content)) return false;

let text = "";
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/openai-numeric-delta-content.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Cloudflare Workers AI (for example `@cf/mistralai/mistral-small-3.1-24b-instruct`) sometimes
* streams `choices[].delta.content` as a JSON number instead of a string:
*
* {"delta":{"content":"A"}} {"delta":{"content":7}} {"delta":{"content":"."}}
*
* The passthrough only counted string content as text, so those chunks were dropped and the
* digits disappeared from the reply (OmniRoute#16554).
*/

import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-numeric-delta-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");

const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { normalizeArrayContentDelta } = await import("../../open-sse/utils/arrayContentDelta.ts");

const textEncoder = new TextEncoder();

test.after(() => {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});

function chunk(delta: Record<string, unknown>, finishReason: string | null = null) {
return `data: ${JSON.stringify({
id: "cmpl_cf",
object: "chat.completion.chunk",
created: 1,
model: "@cf/mistralai/mistral-small-3.1-24b-instruct",
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
}

// "A", "B", 7, ".", 1, ".", 9, then a zero, which must not be mistaken for an empty delta.
const STREAM = [
chunk({ role: "assistant", content: "" }),
chunk({ content: "A" }),
chunk({ content: "B" }),
chunk({ content: 7 }),
chunk({ content: "." }),
chunk({ content: 1 }),
chunk({ content: "." }),
chunk({ content: 9 }),
chunk({ content: 0 }),
chunk({}, "stop"),
"data: [DONE]\n\n",
];

async function run(options: object): Promise<string> {
const source = new ReadableStream({
start(controller) {
for (const c of STREAM) controller.enqueue(textEncoder.encode(c));
controller.close();
},
});
return new Response(source.pipeThrough(createSSEStream(options))).text();
}

test("passthrough keeps numeric delta.content as string text", async () => {
const text = await run({
mode: "passthrough",
provider: "cloudflare-ai",
model: "@cf/mistralai/mistral-small-3.1-24b-instruct",
clientResponseFormat: FORMATS.OPENAI,
body: { messages: [{ role: "user", content: "copy 7.1.9" }] },
});
const deltas = text
.split("\n")
.filter((l) => l.startsWith("data: ") && !l.includes("[DONE]"))
.map((l) => JSON.parse(l.slice(6)))
.flatMap((e) =>
((e.choices as Array<{ delta?: Record<string, unknown> }>) ?? []).map((c) => c.delta ?? {})
);
for (const d of deltas) {
if ("content" in d) assert.equal(typeof d.content, "string", JSON.stringify(d));
}
assert.equal(deltas.map((d) => d.content ?? "").join(""), "AB7.1.9" + "0");
});

test("normalizeArrayContentDelta turns a finite number into its decimal string", () => {
const seven: Record<string, unknown> = { content: 7 };
assert.equal(normalizeArrayContentDelta(seven), true);
assert.deepEqual(seven, { content: "7" });

const zero: Record<string, unknown> = { content: 0 };
assert.equal(normalizeArrayContentDelta(zero), true);
assert.deepEqual(zero, { content: "0" });

const notFinite: Record<string, unknown> = { content: Number.NaN };
assert.equal(normalizeArrayContentDelta(notFinite), false);

const plain = { content: "x" };
assert.equal(normalizeArrayContentDelta(plain), false);
assert.deepEqual(plain, { content: "x" });
});
Loading