workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
1,530 lines • 84.9 kB
JavaScript
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