Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/workers-ai-provider-streaming-no-double-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"workers-ai-provider": patch
---

Fix doubled streaming text. Workers AI's OpenAI-compatible streaming emits both the native top-level `response` field and `choices[0].delta.content` in the same SSE chunk with identical content; the stream mapper emitted a `text-delta` for each, doubling every token (e.g. `"Hello world"` → `"HelloHello world world"`). The two are now treated as mutually exclusive — `choices[0].delta.content` is used only when the native `response` field is absent for that chunk.
11 changes: 10 additions & 1 deletion packages/workers-ai-provider/src/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,16 @@ export function getMappedStream(
});
}

const textDelta = delta.content as string | undefined;
// Workers AI's OpenAI-compatible streaming emits BOTH the native
// top-level `response` field and `choices[0].delta.content` in the same
// chunk, with identical content. Emitting a text-delta for each doubles
// the output (e.g. "Hello" -> "HelloHello"). Treat them as mutually
// exclusive: only use `choices[].delta.content` when the native
// `response` field was absent for this chunk.
const textDelta =
nativeResponse != null && nativeResponse !== ""
? undefined
: (delta.content as string | undefined);
if (textDelta && textDelta.length > 0) {
if (bufferContentForSalvage) {
contentBuffer += textDelta;
Expand Down
46 changes: 46 additions & 0 deletions packages/workers-ai-provider/test/stream-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,52 @@ describe("REST API - Streaming Text Tests", () => {
expect(accumulatedText).toBe("Hello chunk1Hello chunk2");
});

it("should not duplicate text when a chunk carries BOTH `response` and `choices[].delta.content`", async () => {
// Workers AI's OpenAI-compatible streaming emits both the native `response`
// field and `choices[0].delta.content` in the same chunk, with identical
// content. The mapper must treat them as mutually exclusive, otherwise every
// token is emitted twice ("Hello world" -> "HelloHello world world").
server.use(
http.post(
`https://api.cloudflare.com/client/v4/accounts/${TEST_ACCOUNT_ID}/ai/run/${TEST_MODEL}`,
async () => {
return new Response(
[
`data: {"response":"Hello ","choices":[{"delta":{"content":"Hello "},"finish_reason":null}]}\n\n`,
`data: {"response":"world","choices":[{"delta":{"content":"world"},"finish_reason":null}]}\n\n`,
`data: {"response":"","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n`,
"data: [DONE]\n\n",
].join(""),
{
headers: {
"Content-Type": "text/event-stream",
"Transfer-Encoding": "chunked",
},
status: 200,
},
);
},
),
);

const workersai = createWorkersAI({
accountId: TEST_ACCOUNT_ID,
apiKey: TEST_API_KEY,
});

const result = streamText({
model: workersai(TEST_MODEL),
prompt: "Say hello world",
});

let accumulatedText = "";
for await (const chunk of result.textStream) {
accumulatedText += chunk;
}

expect(accumulatedText).toBe("Hello world");
});

it("should handle chunk without 'response' field gracefully", async () => {
server.use(
http.post(
Expand Down