UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

430 lines (397 loc) 14.1 kB
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, }); } } } } }