UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

1,530 lines 84.9 kB
import { C as GatewayDelegateError, S as headersToObject, _ as detectProviderByUrl, a as WORKERS_AI_ERROR_CODE_TO_STATUS, b as asText, c as parseWorkersAIErrorCode, d as isForcedToolChoice, f as normalizeMessagesForBinding$1, g as GATEWAY_PROVIDERS, h as createResumableStream, i as WorkersAIGatewayError, l as SSEDecoder, m as processText, n as createGatewayProvider, o as isAbortError, p as parseLeakedToolCalls$1, r as WorkersAIFallbackError, s as messageOf, t as createGatewayFetch, u as getToolNames, v as findProviderBySlug, w as _defineProperty, x as buildGatewayEntry, y as wireableProviders } from "./gateway-provider-CQU-v2IO.mjs"; import { APICallError, TooManyEmbeddingValuesForCallError, UnsupportedFunctionalityError } from "@ai-sdk/provider"; import { generateId } from "ai"; //#region src/workersai-error.ts /** * Normalize an error thrown by the Workers AI **binding** (`env.AI.run`) into an * `APICallError` so the AI SDK can classify and retry it. * * Cancellations (`AbortError` / `TimeoutError` / `ResponseAborted`, including * `DOMException` aborts) and errors that are already an `APICallError` pass * through unchanged. Everything else becomes an * `APICallError`; when the internal code maps to a known HTTP status, that * `statusCode` is attached and `APICallError` derives `isRetryable` from it. * Unrecognized errors get no `statusCode`, so they stay non-retryable (the * prior behavior). */ function normalizeBindingError(error, context) { if (APICallError.isInstance(error) || isAbortError(error)) return error; const code = parseWorkersAIErrorCode(error); const statusCode = code != null ? WORKERS_AI_ERROR_CODE_TO_STATUS[code] : void 0; const message = messageOf(error); return new APICallError({ message, url: `workers-ai:binding/run/${context.model}`, requestBodyValues: context.requestBodyValues, statusCode, responseBody: message, cause: error, ...code != null ? { data: { workersAIErrorCode: code } } : {} }); } /** * Build an `APICallError` from a non-OK Workers AI **REST** response. The HTTP * status is authoritative here, so `APICallError` derives `isRetryable` from it * directly (429 / 5xx → retryable). Response headers are preserved so the AI * SDK can honor `Retry-After`. The message keeps the historical * `"Workers AI API error (<status> <statusText>): <body>"` shape. */ function apiCallErrorFromResponse(response, errorBody, context) { return new APICallError({ message: `Workers AI API error (${response.status} ${response.statusText}): ${errorBody}`, url: context.url, requestBodyValues: context.requestBodyValues, statusCode: response.status, responseHeaders: headersToObject(response.headers), responseBody: errorBody }); } //#endregion //#region src/utils.ts /** * Normalize messages before passing to the Workers AI binding. * * The binding has strict schema validation that differs from the OpenAI API: * - `content` must not be null */ function normalizeMessagesForBinding(messages) { return normalizeMessagesForBinding$1(messages); } /** * Creates a run method that emulates the Cloudflare Workers AI binding, * but uses the Cloudflare REST API under the hood. */ function createRun(config) { const { accountId, apiKey } = config; const fetchFn = config.fetch ?? globalThis.fetch; return async function run(model, inputs, options) { const { gateway, prefix: _prefix, extraHeaders, returnRawResponse, signal, ...passthroughOptions } = options || {}; const urlParams = new URLSearchParams(); for (const [key, value] of Object.entries(passthroughOptions)) { if (value === void 0 || value === null) throw new Error(`Value for option '${key}' is not able to be coerced into a string.`); try { const valueStr = String(value); if (!valueStr) continue; urlParams.append(key, valueStr); } catch { throw new Error(`Value for option '${key}' is not able to be coerced into a string.`); } } const queryString = urlParams.toString(); const modelPath = String(model).startsWith("run/") ? model : `run/${model}`; const url = gateway?.id ? `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway.id}/workers-ai/${modelPath}${queryString ? `?${queryString}` : ""}` : `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/${modelPath}${queryString ? `?${queryString}` : ""}`; const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", ...extraHeaders && typeof extraHeaders === "object" ? extraHeaders : {} }; if (gateway) { if (gateway.skipCache) headers["cf-aig-skip-cache"] = "true"; if (typeof gateway.cacheTtl === "number") headers["cf-aig-cache-ttl"] = String(gateway.cacheTtl); if (gateway.cacheKey) headers["cf-aig-cache-key"] = gateway.cacheKey; if (gateway.metadata) headers["cf-aig-metadata"] = JSON.stringify(gateway.metadata); } const response = await fetchFn(url, { body: JSON.stringify(inputs), headers, method: "POST", signal }); if (!response.ok && !returnRawResponse) { let errorBody; try { errorBody = await response.text(); } catch { errorBody = "<unable to read response body>"; } throw apiCallErrorFromResponse(response, errorBody, { url, requestBodyValues: inputs }); } if (returnRawResponse) return response; if (inputs.stream === true) { const contentType = response.headers.get("content-type") || ""; if (contentType.includes("event-stream") && response.body) return response.body; if (response.body && !contentType.includes("json")) return response.body; const retryResponse = await fetchFn(url, { body: JSON.stringify({ ...inputs, stream: false }), headers, method: "POST", signal }); if (!retryResponse.ok) { let errorBody; try { errorBody = await retryResponse.text(); } catch { errorBody = "<unable to read response body>"; } throw apiCallErrorFromResponse(retryResponse, errorBody, { url, requestBodyValues: inputs }); } return (await retryResponse.json()).result; } return (await response.json()).result; }; } /** * Make a binary REST API call to Workers AI. * * Some models (e.g. `@cf/deepgram/nova-3`) require raw audio bytes * with an appropriate `Content-Type` header instead of JSON. * * @param config Credentials config * @param model Workers AI model name * @param audioBytes Raw audio bytes * @param contentType MIME type (e.g. "audio/wav") * @param signal Optional AbortSignal * @returns The parsed JSON response body */ async function createRunBinary(config, model, audioBytes, contentType, signal) { const url = `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/ai/run/${model}`; const response = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": contentType }, body: audioBytes, signal }); if (!response.ok) { let errorBody; try { errorBody = await response.text(); } catch { errorBody = "<unable to read response body>"; } throw apiCallErrorFromResponse(response, errorBody, { url, requestBodyValues: { contentType, byteLength: audioBytes.byteLength } }); } const data = await response.json(); return data.result ?? data; } /** * Build the `response_format.json_schema` payload for native Workers AI models. * * Native Workers AI (`@cf/...`) expects `json_schema` to be a **bare** JSON * Schema, NOT OpenAI's `{ name, schema, strict }` envelope. That envelope is * only required by partner-model routes (e.g. `openai/...`), which never reach * this code — they go through the gateway delegate and the real `@ai-sdk/*` * providers, which build the envelope themselves. Wrapping the schema here would * break native models, so we must keep the bare shape. * * The AI SDK's structured-output `name` / `description` (from * `Output.object({ schema, name, description })` / `generateObject`) would * otherwise be silently dropped on this path. We preserve them as the standard * JSON Schema `title` (from `name`) and `description` keywords, which keeps the * payload a valid bare schema while still passing the LLM guidance through. * * Existing schema-level `title` / `description` are never overwritten, empty * strings are ignored, and the input schema object is never mutated. * * See https://github.com/cloudflare/ai/issues/559. */ function buildJsonSchemaPayload(schema, name, description) { if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return schema; const record = schema; const addTitle = !!name && record.title === void 0; const addDescription = !!description && record.description === void 0; if (!addTitle && !addDescription) return schema; return { ...record, ...addTitle ? { title: name } : {}, ...addDescription ? { description } : {} }; } function prepareToolsAndToolChoice(tools, toolChoice) { if (tools == null) return { tool_choice: void 0, tools: void 0 }; const mappedTools = tools.map((tool) => ({ function: { description: tool.type === "function" ? tool.description : void 0, name: tool.name, parameters: tool.type === "function" ? tool.inputSchema : void 0 }, type: "function" })); if (toolChoice == null) return { tool_choice: void 0, tools: mappedTools }; const type = toolChoice.type; switch (type) { case "auto": return { tool_choice: type, tools: mappedTools }; case "none": return { tool_choice: type, tools: mappedTools }; case "required": return { tool_choice: "required", tools: mappedTools }; case "tool": return { tool_choice: { type: "function", function: { name: toolChoice.toolName } }, tools: mappedTools }; default: throw new Error(`Unsupported tool choice type: ${type}`); } } const TOOL_CALL_ID_MARKER = "::cf-wai-tool-call::"; function createAISDKToolCallId(toolCallId) { return `${toolCallId || generateId()}${TOOL_CALL_ID_MARKER}${generateId()}`; } function toWorkersAIToolCallId(toolCallId) { const markerIndex = toolCallId.lastIndexOf(TOOL_CALL_ID_MARKER); if (markerIndex === -1) return toolCallId; if (markerIndex + 20 >= toolCallId.length) return toolCallId; return toolCallId.slice(0, markerIndex); } function processToolCall(toolCall) { const fn = "function" in toolCall && typeof toolCall.function === "object" && toolCall.function ? toolCall.function : null; if (fn?.name) return { input: typeof fn.arguments === "string" ? fn.arguments : JSON.stringify(fn.arguments || {}), toolCallId: createAISDKToolCallId(toolCall.id), type: "tool-call", toolName: fn.name }; const flat = toolCall; return { input: typeof flat.arguments === "string" ? flat.arguments : JSON.stringify(flat.arguments || {}), toolCallId: createAISDKToolCallId(flat.id), type: "tool-call", toolName: flat.name }; } function processToolCalls(output) { if (output.tool_calls && Array.isArray(output.tool_calls)) return output.tool_calls.map((toolCall) => processToolCall(toolCall)); const choices = output.choices; if (choices?.[0]?.message?.tool_calls && Array.isArray(choices[0].message.tool_calls)) return choices[0].message.tool_calls.map((toolCall) => processToolCall(toolCall)); return []; } /** * Parse tool calls that a model leaked as JSON text instead of structured * `tool_calls`, assigning AI-SDK tool-call ids. * * The recovery logic (which JSON shapes count as a leaked call) lives in * `@cloudflare/gateway-core`; this wrapper only layers the framework id on each * neutral result so the existing `LanguageModelV3ToolCall` shape is preserved. */ function parseLeakedToolCalls(text, knownToolNames) { return parseLeakedToolCalls$1(text, knownToolNames).map((call) => ({ input: call.input, toolCallId: createAISDKToolCallId(void 0), type: "tool-call", toolName: call.toolName })); } /** * Salvage a tool call that a model leaked into text content instead of the * structured `tool_calls` field. * * Workers AI's gpt-oss models (harmony format) sometimes emit a forced tool * call as raw JSON in `message.content` with an empty `tool_calls` array and * `finish_reason: "stop"` — typically when the forced tool is a poor fit for * the conversation. The content looks like one of: * * {"name":"read_skill_resource","path":"feedback.txt"} (flat args) * {"name":"calc","arguments":{"a":1}} (wrapped args) * [{"name":"calc","parameters":{"a":1}}] (array form) * * This reinterprets that text as a structured tool call. It is intentionally * narrow to avoid false positives: * - only runs when a tool was *forced* (required / named-function), so a * tool call was explicitly demanded by the caller; * - only runs when there are no real structured tool calls to override; * - only matches JSON objects whose `name` is one of the requested tools. * * Returns the salvaged tool calls, or `null` when nothing was salvaged. * * See https://github.com/cloudflare/ai/issues/560. */ function salvageToolCallsFromText(output, context) { if (!isForcedToolChoice(context.toolChoice)) return null; if (processToolCalls(output).length > 0) return null; const knownToolNames = getToolNames(context.tools); if (knownToolNames.size === 0) return null; const text = processText(output); if (!text) return null; const salvaged = parseLeakedToolCalls(text, knownToolNames); return salvaged.length > 0 ? salvaged : null; } //#endregion //#region src/convert-to-workersai-chat-messages.ts /** * Normalise any LanguageModelV3DataContent value to a Uint8Array. * * Handles: * - Uint8Array → returned as-is * - string → decoded from base64 (with or without data-URL prefix) * - URL → not supported (Workers AI needs raw bytes, not a reference) */ function toUint8Array$2(data) { if (data instanceof Uint8Array) return data; if (typeof data === "string") { let base64 = data; if (base64.startsWith("data:")) { const commaIndex = base64.indexOf(","); if (commaIndex >= 0) base64 = base64.slice(commaIndex + 1); } const binaryString = atob(base64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) bytes[i] = binaryString.charCodeAt(i); return bytes; } if (data instanceof URL) throw new Error("URL image sources are not supported by Workers AI. Provide image data as a Uint8Array or base64 string instead."); return null; } function assertImageMediaType(mediaType) { if (!mediaType) throw new UnsupportedFunctionalityError({ functionality: "file-part-without-media-type", message: "Workers AI chat only supports image file parts with an image/* mediaType. Received a file part without a mediaType." }); if (!mediaType.toLowerCase().startsWith("image/")) throw new UnsupportedFunctionalityError({ functionality: "non-image-file-part", message: `Workers AI chat only supports image file parts with an image/* mediaType. Received mediaType "${mediaType}".` }); return mediaType; } function uint8ArrayToBase64$1(bytes) { let binary = ""; const chunkSize = 8192; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); binary += String.fromCharCode(...chunk); } return btoa(binary); } function convertToWorkersAIChatMessages(prompt) { const messages = []; for (const { role, content } of prompt) switch (role) { case "system": messages.push({ content, role: "system" }); break; case "user": { const textParts = []; const imageParts = []; for (const part of content) switch (part.type) { case "text": textParts.push(part.text); break; case "file": { const mediaType = assertImageMediaType(part.mediaType); const imageBytes = toUint8Array$2(part.data); if (imageBytes) imageParts.push({ image: imageBytes, mediaType }); break; } } if (imageParts.length > 0) { const contentArray = []; if (textParts.length > 0) contentArray.push({ type: "text", text: textParts.join("\n") }); for (const img of imageParts) { const base64 = uint8ArrayToBase64$1(img.image); contentArray.push({ type: "image_url", image_url: { url: `data:${img.mediaType};base64,${base64}` } }); } messages.push({ content: contentArray, role: "user" }); } else messages.push({ content: textParts.join("\n"), role: "user" }); break; } case "assistant": { let text = ""; let reasoning = ""; const toolCalls = []; for (const part of content) switch (part.type) { case "text": text += part.text; break; case "reasoning": reasoning += part.text; break; case "file": break; case "tool-call": toolCalls.push({ function: { arguments: JSON.stringify(part.input), name: part.toolName }, id: toWorkersAIToolCallId(part.toolCallId), type: "function" }); break; case "tool-result": break; default: throw new Error(`Unsupported part type: ${part.type}`); } messages.push({ content: text, role: "assistant", ...reasoning ? { reasoning } : {}, tool_calls: toolCalls.length > 0 ? toolCalls.map(({ function: { name, arguments: args }, id }) => ({ function: { arguments: args, name }, id, type: "function" })) : void 0 }); break; } case "tool": for (const toolResponse of content) if (toolResponse.type === "tool-result") { const output = toolResponse.output; let content; switch (output.type) { case "text": case "error-text": content = output.value; break; case "json": case "error-json": content = JSON.stringify(output.value); break; case "execution-denied": content = output.reason ? `Tool execution denied: ${output.reason}` : "Tool execution was denied."; break; case "content": content = output.value.filter((p) => p.type === "text").map((p) => p.text).join("\n"); break; default: content = ""; break; } messages.push({ content, name: toolResponse.toolName, tool_call_id: toWorkersAIToolCallId(toolResponse.toolCallId), role: "tool" }); } break; default: throw new Error(`Unsupported role: ${role}`); } return { messages }; } //#endregion //#region src/map-workersai-usage.ts /** * Map Workers AI usage data to the AI SDK V3 usage format. * Accepts any object that may have a `usage` property with token counts. * * Workers AI mirrors the OpenAI usage shape, including * `prompt_tokens_details.cached_tokens` for prompt-cache hits. OpenAI-style * responses don't distinguish cache reads from cache writes, so we treat * `cached_tokens` as `cacheRead` and leave `cacheWrite` undefined. */ function mapWorkersAIUsage(output) { const usage = output.usage ?? { completion_tokens: 0, prompt_tokens: 0 }; const promptTokens = usage.prompt_tokens ?? 0; const completionTokens = usage.completion_tokens ?? 0; const cachedTokens = usage.prompt_tokens_details?.cached_tokens; const noCache = cachedTokens !== void 0 ? Math.max(0, promptTokens - cachedTokens) : void 0; return { outputTokens: { total: completionTokens, text: void 0, reasoning: void 0 }, inputTokens: { total: promptTokens, noCache, cacheRead: cachedTokens, cacheWrite: void 0 }, raw: { total: promptTokens + completionTokens } }; } //#endregion //#region src/map-workersai-finish-reason.ts /** * Map a Workers AI finish reason to the AI SDK unified finish reason. * * Accepts either: * - A raw finish reason string (e.g., "stop", "tool_calls") * - A full response object with finish_reason in various locations */ function mapWorkersAIFinishReason(finishReasonOrResponse) { let finishReason; if (typeof finishReasonOrResponse === "string" || finishReasonOrResponse === null || finishReasonOrResponse === void 0) finishReason = finishReasonOrResponse; else if (typeof finishReasonOrResponse === "object" && finishReasonOrResponse !== null) { const response = finishReasonOrResponse; const choices = response.choices; if (Array.isArray(choices) && choices.length > 0) finishReason = choices[0].finish_reason; else if ("finish_reason" in response) finishReason = response.finish_reason; else finishReason = void 0; } else finishReason = void 0; const raw = finishReason ?? "stop"; switch (finishReason) { case "stop": return { unified: "stop", raw }; case "length": case "model_length": return { unified: "length", raw }; case "tool_calls": return { unified: "tool-calls", raw }; case "error": return { unified: "error", raw }; case "other": case "unknown": return { unified: "other", raw }; default: return { unified: "stop", raw }; } } //#endregion //#region src/streaming.ts /** * Prepend a stream-start event to an existing LanguageModelV3 stream. * Uses pipeThrough for proper backpressure and error propagation. */ function prependStreamStart(source, warnings) { let sentStart = false; return source.pipeThrough(new TransformStream({ transform(chunk, controller) { if (!sentStart) { sentStart = true; controller.enqueue({ type: "stream-start", warnings }); } controller.enqueue(chunk); }, flush(controller) { if (!sentStart) controller.enqueue({ type: "stream-start", warnings }); } })); } /** * Check if a streaming tool call chunk is a null-finalization sentinel. */ function isNullFinalizationChunk(tc) { const fn = tc.function; const name = fn?.name ?? tc.name ?? null; const args = fn?.arguments ?? tc.arguments ?? null; return !(tc.id ?? null) && !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" } }] } */ function getMappedStream(response, salvageContext) { const rawStream = response instanceof ReadableStream ? response : response.body; if (!rawStream) throw new Error("No readable stream available for SSE parsing."); const knownToolNames = getToolNames(salvageContext?.tools); const bufferContentForSalvage = isForcedToolChoice(salvageContext?.toolChoice) && knownToolNames.size > 0; let contentBuffer = ""; let anyToolCallStarted = false; let usage = { outputTokens: { total: 0, text: void 0, reasoning: void 0 }, inputTokens: { total: 0, noCache: void 0, cacheRead: void 0, cacheWrite: void 0 }, raw: { totalTokens: 0 } }; let textId = null; let reasoningId = null; let finishReason = null; let receivedDone = false; let receivedAnyData = false; const activeToolCalls = /* @__PURE__ */ new Map(); const closedToolCalls = /* @__PURE__ */ new Set(); let lastActiveToolIndex = null; return rawStream.pipeThrough(new SSEDecoder()).pipeThrough(new TransformStream({ transform(data, controller) { if (!data || data === "[DONE]") { if (data === "[DONE]") receivedDone = true; return; } receivedAnyData = true; let chunk; try { chunk = JSON.parse(data); } catch { console.warn("[workers-ai-provider] failed to parse SSE event:", data); return; } if (chunk.usage) usage = mapWorkersAIUsage(chunk); const choices = chunk.choices; const choiceFinishReason = choices?.[0]?.finish_reason; const directFinishReason = chunk.finish_reason; if (choiceFinishReason != null) finishReason = mapWorkersAIFinishReason(choiceFinishReason); else if (directFinishReason != null) finishReason = mapWorkersAIFinishReason(directFinishReason); const nativeResponse = chunk.response; if (nativeResponse != null && nativeResponse !== "") { const responseText = String(nativeResponse); if (responseText.length > 0) if (bufferContentForSalvage) contentBuffer += responseText; else { 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 }); } } if (Array.isArray(chunk.tool_calls)) { if (reasoningId) { controller.enqueue({ type: "reasoning-end", id: reasoningId }); reasoningId = null; } emitToolCallDeltas(chunk.tool_calls, controller); } if (choices?.[0]?.delta) { const delta = choices[0].delta; const reasoningDelta = delta.reasoning_content ?? delta.reasoning; 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; if (textDelta && textDelta.length > 0) if (bufferContentForSalvage) contentBuffer += textDelta; else { 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; if (Array.isArray(deltaToolCalls)) { if (reasoningId) { controller.enqueue({ type: "reasoning-end", id: reasoningId }); reasoningId = null; } emitToolCallDeltas(deltaToolCalls, controller); } } }, flush(controller) { for (const [idx] of activeToolCalls) { if (closedToolCalls.has(idx)) continue; closeToolCall(idx, controller); } if (reasoningId) controller.enqueue({ type: "reasoning-end", id: reasoningId }); 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; 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 { 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()) { 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 }); const effectiveFinishReason = salvagedToolCalls ? { unified: "tool-calls", raw: "stop" } : !receivedDone && receivedAnyData && !finishReason ? { unified: "error", raw: "stream-truncated" } : 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, controller) { 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, controller) { for (const tc of toolCalls) { if (isNullFinalizationChunk(tc)) { if (lastActiveToolIndex != null) closeToolCall(lastActiveToolIndex, controller); continue; } const tcIndex = tc.index ?? 0; const fn = tc.function; const tcName = fn?.name ?? tc.name ?? null; const tcArgs = fn?.arguments ?? tc.arguments ?? null; const tcId = tc.id; if (!activeToolCalls.has(tcIndex)) { 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 }); } } } } } //#endregion //#region src/aisearch-chat-language-model.ts var AISearchChatLanguageModel = class { constructor(modelId, settings, config) { _defineProperty(this, "specificationVersion", "v3"); _defineProperty(this, "defaultObjectGenerationMode", "json"); _defineProperty(this, "supportedUrls", {}); _defineProperty(this, "modelId", void 0); _defineProperty(this, "settings", void 0); _defineProperty(this, "config", void 0); this.modelId = modelId; this.settings = settings; this.config = config; } get provider() { return this.config.provider; } getWarnings({ tools, frequencyPenalty, presencePenalty, responseFormat }) { const warnings = []; if (tools != null && tools.length > 0) { console.warn("[workers-ai-provider] Tools are not supported by AI Search. They will be ignored."); warnings.push({ feature: "tools", type: "unsupported" }); } if (frequencyPenalty != null) warnings.push({ feature: "frequencyPenalty", type: "unsupported" }); if (presencePenalty != null) warnings.push({ feature: "presencePenalty", type: "unsupported" }); if (responseFormat?.type === "json") warnings.push({ feature: "responseFormat", type: "unsupported" }); return warnings; } /** * Build the search query from messages. * Flattens the conversation into a single string for aiSearch. */ buildQuery(prompt) { const { messages } = convertToWorkersAIChatMessages(prompt); return messages.map(({ content, role }) => `${role}: ${content}`).join("\n\n"); } async doGenerate(options) { const warnings = this.getWarnings(options); const query = this.buildQuery(options.prompt); const output = await this.config.binding.aiSearch({ query }); return { finishReason: { unified: "stop", raw: "stop" }, content: [ ...output.data.map(({ file_id, filename, score }) => ({ type: "source", sourceType: "url", id: file_id, url: filename, providerMetadata: { attributes: { score } } })), { type: "text", text: output.response }, ...processToolCalls(output) ], usage: mapWorkersAIUsage(output), warnings }; } async doStream(options) { const warnings = this.getWarnings(options); const query = this.buildQuery(options.prompt); return { stream: prependStreamStart(getMappedStream(await this.config.binding.aiSearch({ query, stream: true })), warnings) }; } }; //#endregion //#region src/workersai-embedding-model.ts var WorkersAIEmbeddingModel = class { get provider() { return this.config.provider; } get maxEmbeddingsPerCall() { return this.settings.maxEmbeddingsPerCall ?? 3e3; } get supportsParallelCalls() { return this.settings.supportsParallelCalls ?? true; } constructor(modelId, settings, config) { _defineProperty(this, "specificationVersion", "v3"); _defineProperty(this, "modelId", void 0); _defineProperty(this, "config", void 0); _defineProperty(this, "settings", void 0); this.modelId = modelId; this.settings = settings; this.config = config; } async doEmbed({ values, abortSignal }) { if (values.length > this.maxEmbeddingsPerCall) throw new TooManyEmbeddingValuesForCallError({ maxEmbeddingsPerCall: this.maxEmbeddingsPerCall, modelId: this.modelId, provider: this.provider, values }); const { gateway, maxEmbeddingsPerCall: _maxEmbeddingsPerCall, supportsParallelCalls: _supportsParallelCalls, ...passthroughOptions } = this.settings; let response; try { response = await this.config.binding.run(this.modelId, { text: values }, { gateway: this.config.gateway ?? gateway, signal: abortSignal, ...passthroughOptions }); } catch (error) { throw normalizeBindingError(error, { model: this.modelId, requestBodyValues: { text: values } }); } return { embeddings: response.data, warnings: [] }; } }; //#endregion //#region src/workersai-chat-language-model.ts var WorkersAIChatLanguageModel = class { constructor(modelId, settings, config) { _defineProperty(this, "specificationVersion", "v3"); _defineProperty(this, "defaultObjectGenerationMode", "json"); _defineProperty(this, "supportedUrls", {}); _defineProperty(this, "modelId", void 0); _defineProperty(this, "settings", void 0); _defineProperty(this, "config", void 0); this.modelId = modelId; this.settings = settings; this.config = config; } get provider() { return this.config.provider; } getArgs({ responseFormat, tools, toolChoice, maxOutputTokens, temperature, topP, frequencyPenalty, presencePenalty, seed }) { const type = responseFormat?.type ?? "text"; const warnings = []; if (frequencyPenalty != null) warnings.push({ feature: "frequencyPenalty", type: "unsupported" }); if (presencePenalty != null) warnings.push({ feature: "presencePenalty", type: "unsupported" }); const baseArgs = { max_tokens: maxOutputTokens, model: this.modelId, random_seed: seed, safe_prompt: this.settings.safePrompt, temperature, top_p: topP }; switch (type) { case "text": return { args: { ...baseArgs, response_format: void 0, ...prepareToolsAndToolChoice(tools, toolChoice) }, warnings }; case "json": { const json = responseFormat?.type === "json" ? responseFormat : void 0; return { args: { ...baseArgs, response_format: { type: "json_schema", json_schema: buildJsonSchemaPayload(json?.schema, json?.name, json?.description) }, tools: void 0, tool_choice: void 0 }, warnings }; } default: throw new Error(`Unsupported type: ${type}`); } } /** * Build the inputs object for `binding.run()`, shared by doGenerate and doStream. * * Images are embedded inline in messages as OpenAI-compatible content * arrays with `image_url` parts. Both the REST API and the binding * accept this format at runtime. * * The binding path additionally normalises null content to empty strings. * * Reasoning controls (`reasoning_effort`, `chat_template_kwargs`) are * forwarded here from settings. These belong on the INPUTS object, not on * the 3rd-arg options / REST query string — see * https://github.com/cloudflare/ai/issues/501. Per-call values from * `providerOptions["workers-ai"]` override settings. * * `reasoning_effort: null` is a valid value ("disable reasoning"), so we * check `!== undefined` rather than truthiness. */ buildRunInputs(args, messages, options) { const rawPerCall = options?.providerOptions?.["workers-ai"]; const perCall = rawPerCall !== null && typeof rawPerCall === "object" && !Array.isArray(rawPerCall) ? rawPerCall : {}; const reasoningEffort = "reasoning_effort" in perCall ? perCall.reasoning_effort : this.settings.reasoning_effort; const chatTemplateKwargs = "chat_template_kwargs" in perCall ? perCall.chat_template_kwargs : this.settings.chat_template_kwargs; return { max_tokens: args.max_tokens, messages: this.config.isBinding ? normalizeMessagesForBinding(messages) : messages, temperature: args.temperature, tools: args.tools, ...args.tool_choice ? { tool_choice: args.tool_choice } : {}, top_p: args.top_p, ...args.response_format ? { response_format: args.response_format } : {}, ...options?.stream ? { stream: true } : {}, ...reasoningEffort !== void 0 ? { reasoning_effort: reasoningEffort } : {}, ...chatTemplateKwargs !== void 0 ? { chat_template_kwargs: chatTemplateKwargs } : {} }; } /** * Get passthrough options for binding.run() from settings. * * `reasoning_effort` and `chat_template_kwargs` are explicitly excluded * here — they belong on the `inputs` object (see `buildRunInputs`), not on * the `options` (3rd) arg of binding.run() or the REST query string. */ getRunOptions() { const { gateway, safePrompt: _safePrompt, sessionAffinity, extraHeaders, reasoning_effort: _reasoningEffort, chat_template_kwargs: _chatTemplateKwargs, ...passthroughOptions } = this.settings; const mergedHeaders = { ...extraHeaders && typeof extraHeaders === "object" ? extraHeaders : {}, ...sessionAffinity ? { "x-session-affinity": sessionAffinity } : {} }; return { gateway: this.config.gateway ?? gateway, ...Object.keys(mergedHeaders).length > 0 ? { extraHeaders: mergedHeaders } : {}, ...passthroughOptions }; } /** * Extract reasoning, text, and tool calls from a non-streaming response. * * Shared by `doGenerate` and `doStream`'s graceful-degradation branch (the * path gpt-oss falls through, since it doesn't support `/ai/run/` streaming * and is retried non-streaming). When a forced tool call was leaked into * text content (gpt-oss harmony quirk), it is salvaged into a structured * tool call and the leaked JSON text is suppressed. A warning is appended in * place so callers can observe the reinterpretation. */ extractContent(outputRecord, args, warnings) { const choices = outputRecord.choices; const reasoningContent = choices?.[0]?.message?.reasoning_content ?? choices?.[0]?.message?.reasoning; const toolCalls = processToolCalls(outputRecord); const salvaged = toolCalls.length === 0 ? salvageToolCallsFromText(outputRecord, { tools: args.tools, toolChoice: args.tool_choice }) : null; if (salvaged) warnings.push({ type: "other", message: `Recovered ${salvaged.length} forced tool call(s) that the model emitted as text content instead of structured tool calls (model: ${this.modelId}).` }); return { reasoningContent, text: salvaged ? "" : processText(outputRecord) ?? "", toolCalls: salvaged ?? toolCalls, finishReason: salvaged ? { unified: "tool-calls", raw: "stop" } : mapWorkersAIFinishReason(outputRecord) }; } async doGenerate(options) { const { args, warnings } = this.getArgs(options); const { messages } = convertToWorkersAIChatMessages(options.prompt); const inputs = this.buildRunInputs(args, messages, { providerOptions: options.providerOptions }); const runOptions = this.getRunOptions(); let output; try { output = await this.config.binding.run(args.model, inputs, { ...runOptions, signal: options.abortSignal }); } catch (error) { throw normalizeBindingError(error, { model: args.model, requestBodyValues: inputs }); } if (output instanceof ReadableStream) throw new Error("Unexpected streaming response from non-streaming request. Check that `stream: true` was not passed."); const outputRecord = output; const { reasoningContent, text, toolCalls, finishReason } = this.extractContent(outputRecord, args, warnings); return { finishReason, content: [ ...reasoningContent ? [{ type: "reasoning", text: reasoningContent }] : [], { type: "text", text }, ...toolCalls ], usage: mapWorkersAIUsage(output), warnings }; } async doStream(options) { const { args, warnings } = this.getArgs(options); const { messages } = convertToWorkersAIChatMessages(options.prompt); const inputs = this.buildRunInputs(args, messages, { stream: true, providerOptions: options.providerOptions }); const runOptions = this.getRunOptions(); let response; try { response = await this.config.binding.run(args.model, inputs, { ...runOptions, signal: options.abortSignal }); } catch (error) { throw normalizeBindingError(error, { model: args.model, requestBodyValues: inputs }); } if (response instanceof ReadableStream) return { stream: prependStreamStart(getMappedStream(response, { tools: args.tools, toolChoice: args.tool_choice }), warnings) }; const outputRecord = response; const { reasoningContent, text, toolCalls, finishReason } = this.extractContent(outputRecord, args, warnings); let textId = null; let reasoningId = null; return { stream: new ReadableStream({ start(controller) { controller.enqueue({ type: "stream-start", warnings }); if (reasoningContent) { reasoningId = generateId(); controller.enqueue({ type: "reasoning-start", id: reasoningId }); controller.enqueue({ type: "reasoning-delta", id: reasoningId, delta: reasoningContent }); controller.enqueue({ type: "reasoning-end", id: reasoningId }); } if (text) { textId = generateId(); controller.enqueue({ type: "text-start", id: textId }); controller.enqueue({ type: "text-delta", id: textId, delta: text }); controller.enqueue({ type: "text-end", id: textId }); } for (const toolCall of toolCalls) controller.enqueue(toolCall); controller.enqueue({ type: "finish", finishReason, usage: mapWorkersAIUsage(response) }); controller.close(); } }) }; } }; //#endregion //#region src/workersai-image-model.ts var WorkersAIImageModel = class { get maxImagesPerCall() { return this.settings.maxImagesPerCall ?? 1; } get provider() { return this.config.provider; } constructor(modelId, settings, config) { this.modelId = modelId; this.settings = settings; this.config = config; _defineProperty(this, "specificationVersion", "v3"); } async doGenerate({ prompt, n, size, aspectRatio, seed, abortSignal }) { const { width, height } = getDimensionsFromSizeString(size); const warnings = []; if (aspectRatio != null) warnings.push({ details: "This model does not support aspect ratio. Use `size` instead.", feature: "aspectRatio", type: "unsupported" }); const generateImage = async () => { const inputs = { height, prompt: prompt ?? "", seed, width }; let output; try { output = await this.config.binding.run(this.modelId, inputs, { gateway: this.config.gateway, signal: abortSignal }); } catch (error) { throw normalizeBindingError(error, { model: this.modelId, requestBodyValues: inputs }); } return toUint8Array$1(output); }; return { images: await Promise.all(Array.from({ length: n }, () => generateImage())), response: { headers: {}, modelId: this.modelId, timestamp: /* @__PURE__ */ new Date() }, warnings }; } }; function getDimensionsFromSizeString(size) { const [width, height] = size?.split("x") ?? [void 0, void 0]; return { height: parseInteger(height), width: parseInteger(width) }; } function parseInteger(value) { if (value === "" || !value) return void 0; const number = Number(value); return Number.isInteger(number) ? number : void 0; } /** * Convert various output types from binding.run() to Uint8Array. * Workers AI image models return different types depending on the runtime: * - ReadableStream<Uint8Array> (most common in workerd) * - Uint8Array / ArrayBuffer (direct binary) * - Response (needs .arrayBuffer()) * - { image: string } with base64 data */ async function toUint8Array$1(output) { if (output instanceof Uint8Array) return output; if (output instanceof ArrayBuffer) return new Uint8Array(output); if (output instanceof ReadableStream) { const reader = output.getReader(); const chunks = []; let totalLength = 0; while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(value); totalLength += value.length; } const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } if (output instanceof Response) return new Uint8Array(await output.arrayBuffer()); if (typeof output === "object" && output !== null) { const obj = output; if (typeof obj.image === "string") return Uint8Array.from(atob(obj.image), (c) => c.charCodeAt(0)); if (obj.data instanceof Uint8Array) return obj.data; if (obj.data instanceof ArrayBuffer) return new Uint8Array(obj.data); if (typeof obj.arrayBuffer === "function") return new Uint8Array(await obj.arrayBuffer()); } throw new Error(`Unexpected output type from image model. Got ${Object.prototype.toString.call(output)} with keys: ${typeof output === "object" && output !== null ? JSON.stringify(Object.keys(output)) : "N/A"}`); } //#endregion //#region src/workersai-transcription-model.ts /** * Workers AI transcription model implementing the AI SDK's `TranscriptionModelV3` interface. * * Supports: * - Whisper models (`@cf/openai/whisper`, `whisper-tiny-en`, `whisper-large-v3-turbo`) * - Deepgram Nova-3 (`@cf/deepgram/nova-3`) — uses a different input/output format */ var WorkersAITranscriptionModel = class { get provider() { return this.config.provider; } constructor(modelId, settings, config) { this.modelId = modelId; this.settings = settings; this.config = config; _defineProperty(this, "specificationVersion", "v3"); } async doGenerate(options) { const { audio, mediaType, abortSignal } = options; const warnings = []; const audioBytes = typeof audio === "string" ? Uint8Array.from(atob(audio), (c) => c.charCodeAt(0)) : audio; const isNova3 = this.modelId === "@cf/deepgram/nova-3"; let rawResult; try { if (isNova3) rawResult = await this.runNova3(audioBytes, mediaType, abortSignal); else rawResult = await this.runWhisper(audioBytes, abortSignal); } catch (error) { throw normalizeBindingError(error, { model: this.modelId, requestBodyValues: { mediaType } }); } const result = rawResult; if (isNova3) return this.normalizeNova3Response(result, warnings); return this.normalizeWhisperResponse(result, warnings); } async runWhisper(audioBytes, abortSignal) { const inputs = { audio: this.modelId === "@cf/openai/whisper-large-v3-turbo" ? uint8ArrayToBase64(audioBytes) : Array.from(audioBytes) }; if (this.settings.language) inputs.language = this.settings.language; if (this.settings.prompt) inputs.initial_prompt = this.settings.prompt; return this.config.binding.run(this.modelId, inputs, { gateway: this.config.gateway, signal: abortSignal }); } normalizeWhisperResponse(raw, warnings) { const text = raw.text ?? ""; const segments = []; if (raw.segments && Array.isArray(raw.segments)) for (const seg of raw.segments) segments.push({ text: seg.text ?? "", startSecond: seg.start ?? 0, endSecond: seg.end ?? 0 }); else if (raw.words && Array.isArray(raw.words)) for (const w of raw.words) segments.push({ text: w.word ?? "", startSecond: w.start ?? 0, endSecond: w.end ?? 0 }); const info = raw.transcription_info; return { text, segments, language: info?.language ?? void 0, durationInSeconds: info?.duration ?? void 0, warnings, response: { timestamp: /* @__PURE__ */ new Date(), modelId: this.modelId, headers: {} } }; } async runNova3(audioBytes, mediaType, abortSignal) { if (this.config.isBinding) return this.config.binding.run(this.modelId, { audio: { body: uint8ArrayToBase64(audioBytes), contentType: mediaType } }, { gateway: this.config.gateway, signal: abortSignal }); if (!this.config.credentials) throw new Error("Nova-3 transcription via REST re