workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
430 lines (397 loc) • 14.1 kB
text/typescript
import type {
LanguageModelV3FinishReason,
LanguageModelV3StreamPart,
LanguageModelV3Usage,
} from "@ai-sdk/provider";
import { SSEDecoder } from "@cloudflare/gateway-core";
import { generateId } from "ai";
import { mapWorkersAIFinishReason } from "./map-workersai-finish-reason";
import { mapWorkersAIUsage } from "./map-workersai-usage";
import {
createAISDKToolCallId,
getToolNames,
isForcedToolChoice,
parseLeakedToolCalls,
} from "./utils";
/**
* Prepend a stream-start event to an existing LanguageModelV3 stream.
* Uses pipeThrough for proper backpressure and error propagation.
*/
export function prependStreamStart(
source: ReadableStream<LanguageModelV3StreamPart>,
warnings: LanguageModelV3StreamPart extends { type: "stream-start" } ? never : unknown,
): ReadableStream<LanguageModelV3StreamPart> {
let sentStart = false;
return source.pipeThrough(
new TransformStream<LanguageModelV3StreamPart, LanguageModelV3StreamPart>({
transform(chunk, controller) {
if (!sentStart) {
sentStart = true;
controller.enqueue({
type: "stream-start",
warnings: warnings as [],
});
}
controller.enqueue(chunk);
},
flush(controller) {
if (!sentStart) {
controller.enqueue({
type: "stream-start",
warnings: warnings as [],
});
}
},
}),
);
}
/**
* Check if a streaming tool call chunk is a null-finalization sentinel.
*/
function isNullFinalizationChunk(tc: Record<string, unknown>): boolean {
const fn = tc.function as Record<string, unknown> | undefined;
const name = fn?.name ?? tc.name ?? null;
const args = fn?.arguments ?? tc.arguments ?? null;
const id = tc.id ?? null;
return !id && !name && (!args || args === "");
}
/**
* Maps a Workers AI SSE stream into AI SDK V3 LanguageModelV3StreamPart events.
*
* Uses a TransformStream pipeline for proper backpressure — chunks are emitted
* one at a time as the downstream consumer pulls, not buffered eagerly.
*
* Handles two distinct formats:
* 1. Native format: { response: "chunk", tool_calls: [...] }
* 2. OpenAI format: { choices: [{ delta: { content: "chunk" } }] }
*/
export function getMappedStream(
response: Response | ReadableStream<Uint8Array>,
salvageContext?: {
tools: Array<{ function: { name?: string } }> | undefined;
toolChoice: unknown;
},
): ReadableStream<LanguageModelV3StreamPart> {
const rawStream =
response instanceof ReadableStream
? response
: (response.body as ReadableStream<Uint8Array>);
if (!rawStream) {
throw new Error("No readable stream available for SSE parsing.");
}
// gpt-oss harmony quirk: a forced tool call can be streamed as `content`
// text deltas instead of structured tool calls. When a tool was forced,
// buffer the text content (rather than emitting it incrementally) so we can
// reinterpret it as a tool call at flush time. Text is unexpected in forced
// mode anyway, so buffering it does not regress a useful stream.
// See https://github.com/cloudflare/ai/issues/560.
const knownToolNames = getToolNames(salvageContext?.tools);
const bufferContentForSalvage =
isForcedToolChoice(salvageContext?.toolChoice) && knownToolNames.size > 0;
let contentBuffer = "";
let anyToolCallStarted = false;
// State shared across the transform
let usage: LanguageModelV3Usage = {
outputTokens: { total: 0, text: undefined, reasoning: undefined },
inputTokens: {
total: 0,
noCache: undefined,
cacheRead: undefined,
cacheWrite: undefined,
},
raw: { totalTokens: 0 },
};
let textId: string | null = null;
let reasoningId: string | null = null;
let finishReason: LanguageModelV3FinishReason | null = null;
let receivedDone = false;
let receivedAnyData = false;
// Track tool call streaming state per index.
// When we see the first chunk for a tool call index, we emit tool-input-start.
// Subsequent argument deltas emit tool-input-delta.
// tool-input-end is emitted eagerly when a new tool index starts or a null
// finalization chunk arrives; any remaining open calls are closed in flush().
const activeToolCalls = new Map<number, { id: string; toolName: string; args: string }>();
const closedToolCalls = new Set<number>();
let lastActiveToolIndex: number | null = null;
// Step 1: Decode bytes into SSE lines
const sseStream = rawStream.pipeThrough(new SSEDecoder());
// Step 2: Transform SSE events into LanguageModelV3StreamPart
return sseStream.pipeThrough(
new TransformStream<string, LanguageModelV3StreamPart>({
transform(data, controller) {
if (!data || data === "[DONE]") {
if (data === "[DONE]") receivedDone = true;
return;
}
receivedAnyData = true;
let chunk: Record<string, unknown>;
try {
chunk = JSON.parse(data);
} catch {
console.warn("[workers-ai-provider] failed to parse SSE event:", data);
return;
}
if (chunk.usage) {
usage = mapWorkersAIUsage(chunk as Parameters<typeof mapWorkersAIUsage>[0]);
}
// Extract finish_reason
const choices = chunk.choices as
| Array<{
finish_reason?: string;
delta?: Record<string, unknown>;
}>
| undefined;
const choiceFinishReason = choices?.[0]?.finish_reason;
const directFinishReason = chunk.finish_reason as string | undefined;
if (choiceFinishReason != null) {
finishReason = mapWorkersAIFinishReason(choiceFinishReason);
} else if (directFinishReason != null) {
finishReason = mapWorkersAIFinishReason(directFinishReason);
}
// --- Native format: top-level `response` field ---
const nativeResponse = chunk.response;
if (nativeResponse != null && nativeResponse !== "") {
const responseText = String(nativeResponse);
if (responseText.length > 0) {
if (bufferContentForSalvage) {
contentBuffer += responseText;
} else {
// Close active reasoning block before text starts
if (reasoningId) {
controller.enqueue({ type: "reasoning-end", id: reasoningId });
reasoningId = null;
}
if (!textId) {
textId = generateId();
controller.enqueue({ type: "text-start", id: textId });
}
controller.enqueue({
type: "text-delta",
id: textId,
delta: responseText,
});
}
}
}
// --- Native format: top-level `tool_calls` ---
if (Array.isArray(chunk.tool_calls)) {
// Close active reasoning block before tool calls start
if (reasoningId) {
controller.enqueue({ type: "reasoning-end", id: reasoningId });
reasoningId = null;
}
emitToolCallDeltas(chunk.tool_calls as Record<string, unknown>[], controller);
}
// --- OpenAI format: choices[0].delta ---
if (choices?.[0]?.delta) {
const delta = choices[0].delta;
const reasoningDelta = (delta.reasoning_content ?? delta.reasoning) as
| string
| undefined;
if (reasoningDelta && reasoningDelta.length > 0) {
if (!reasoningId) {
reasoningId = generateId();
controller.enqueue({
type: "reasoning-start",
id: reasoningId,
});
}
controller.enqueue({
type: "reasoning-delta",
id: reasoningId,
delta: reasoningDelta,
});
}
const textDelta = delta.content as string | undefined;
if (textDelta && textDelta.length > 0) {
if (bufferContentForSalvage) {
contentBuffer += textDelta;
} else {
// Close active reasoning block before text starts
if (reasoningId) {
controller.enqueue({ type: "reasoning-end", id: reasoningId });
reasoningId = null;
}
if (!textId) {
textId = generateId();
controller.enqueue({ type: "text-start", id: textId });
}
controller.enqueue({
type: "text-delta",
id: textId,
delta: textDelta,
});
}
}
const deltaToolCalls = delta.tool_calls as
| Record<string, unknown>[]
| undefined;
if (Array.isArray(deltaToolCalls)) {
// Close active reasoning block before tool calls start
if (reasoningId) {
controller.enqueue({ type: "reasoning-end", id: reasoningId });
reasoningId = null;
}
emitToolCallDeltas(deltaToolCalls, controller);
}
}
},
flush(controller) {
// Close any tool calls that weren't already closed during streaming
for (const [idx] of activeToolCalls) {
if (closedToolCalls.has(idx)) continue;
closeToolCall(idx, controller);
}
// Close open reasoning block before any salvaged tool calls.
if (reasoningId) {
controller.enqueue({ type: "reasoning-end", id: reasoningId });
}
// Salvage a forced tool call that streamed as buffered text.
let salvagedToolCalls = false;
if (bufferContentForSalvage && !anyToolCallStarted && contentBuffer.trim()) {
const salvaged = parseLeakedToolCalls(contentBuffer, knownToolNames);
if (salvaged.length > 0) {
for (const call of salvaged) {
controller.enqueue({
type: "tool-input-start",
id: call.toolCallId,
toolName: call.toolName,
});
controller.enqueue({
type: "tool-input-delta",
id: call.toolCallId,
delta: call.input,
});
controller.enqueue({ type: "tool-input-end", id: call.toolCallId });
controller.enqueue(call);
}
salvagedToolCalls = true;
// Stream warnings are fixed at stream-start, so surface the
// reinterpretation here for observability instead.
console.warn(
`[workers-ai-provider] Recovered ${salvaged.length} forced tool call(s) that the model streamed as text content instead of structured tool calls.`,
);
} else {
// Not a recoverable tool call — emit the buffered text as-is.
const id = generateId();
controller.enqueue({ type: "text-start", id });
controller.enqueue({ type: "text-delta", id, delta: contentBuffer });
controller.enqueue({ type: "text-end", id });
}
} else if (bufferContentForSalvage && contentBuffer.trim()) {
// Real tool calls were present alongside buffered text — emit text.
const id = generateId();
controller.enqueue({ type: "text-start", id });
controller.enqueue({ type: "text-delta", id, delta: contentBuffer });
controller.enqueue({ type: "text-end", id });
}
if (textId) {
controller.enqueue({ type: "text-end", id: textId });
}
// Detect premature termination
const effectiveFinishReason = salvagedToolCalls
? ({ unified: "tool-calls", raw: "stop" } as LanguageModelV3FinishReason)
: !receivedDone && receivedAnyData && !finishReason
? ({
unified: "error",
raw: "stream-truncated",
} as LanguageModelV3FinishReason)
: (finishReason ?? { unified: "stop", raw: "stop" });
controller.enqueue({
finishReason: effectiveFinishReason,
type: "finish",
usage,
});
},
}),
);
/**
* Emit tool-input-end + tool-call for a tool call that is complete.
*/
function closeToolCall(
index: number,
controller: TransformStreamDefaultController<LanguageModelV3StreamPart>,
) {
const tc = activeToolCalls.get(index);
if (!tc || closedToolCalls.has(index)) return;
closedToolCalls.add(index);
controller.enqueue({ type: "tool-input-end", id: tc.id });
controller.enqueue({
type: "tool-call",
toolCallId: tc.id,
toolName: tc.toolName,
input: tc.args,
});
}
/**
* Emit incremental tool call events from streaming chunks.
*
* Workers AI streams tool calls as:
* Chunk A: { id, type, index, function: { name } } — start
* Chunk B: { index, function: { arguments: "partial..." } } — args delta
* Chunk C: { index, function: { arguments: "rest..." } } — args delta
* Chunk D: { id: null, type: null, function: { name: null } } — finalize
*
* We emit tool-input-start on first sight, tool-input-delta for each
* argument chunk, and tool-input-end eagerly — either when a new tool
* index starts (closing the previous one) or on a null finalization
* chunk. Any remaining open calls are closed in flush().
*/
function emitToolCallDeltas(
toolCalls: Record<string, unknown>[],
controller: TransformStreamDefaultController<LanguageModelV3StreamPart>,
) {
for (const tc of toolCalls) {
if (isNullFinalizationChunk(tc)) {
// Null finalization sentinel — close the last active tool call
if (lastActiveToolIndex != null) {
closeToolCall(lastActiveToolIndex, controller);
}
continue;
}
const tcIndex = (tc.index as number) ?? 0;
const fn = tc.function as Record<string, unknown> | undefined;
const tcName = (fn?.name ?? tc.name ?? null) as string | null;
const tcArgs = (fn?.arguments ?? tc.arguments ?? null) as string | null;
const tcId = tc.id as string | null;
if (!activeToolCalls.has(tcIndex)) {
// A new tool call is starting — close the previous one first
if (lastActiveToolIndex != null && lastActiveToolIndex !== tcIndex) {
closeToolCall(lastActiveToolIndex, controller);
}
const id = createAISDKToolCallId(tcId);
const toolName = tcName || "";
activeToolCalls.set(tcIndex, { id, toolName, args: "" });
lastActiveToolIndex = tcIndex;
anyToolCallStarted = true;
controller.enqueue({
type: "tool-input-start",
id,
toolName,
});
if (tcArgs != null && tcArgs !== "") {
const delta = typeof tcArgs === "string" ? tcArgs : JSON.stringify(tcArgs);
activeToolCalls.get(tcIndex)!.args += delta;
controller.enqueue({
type: "tool-input-delta",
id,
delta,
});
}
} else {
const active = activeToolCalls.get(tcIndex)!;
lastActiveToolIndex = tcIndex;
if (tcArgs != null && tcArgs !== "") {
const delta = typeof tcArgs === "string" ? tcArgs : JSON.stringify(tcArgs);
active.args += delta;
controller.enqueue({
type: "tool-input-delta",
id: active.id,
delta,
});
}
}
}
}
}