workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
602 lines (531 loc) • 19.3 kB
text/typescript
import type { LanguageModelV3, LanguageModelV3ToolCall } from "@ai-sdk/provider";
import {
getToolNames,
isForcedToolChoice,
normalizeMessagesForBinding as coreNormalizeMessagesForBinding,
parseLeakedToolCalls as coreParseLeakedToolCalls,
processText,
} from "@cloudflare/gateway-core";
import { generateId } from "ai";
import type { WorkersAIChatPrompt } from "./workersai-chat-prompt";
import { apiCallErrorFromResponse } from "./workersai-error";
// Re-exported from `@cloudflare/gateway-core` (single source of truth) so the
// existing `workers-ai-provider/src/utils` import paths keep working unchanged.
export { getToolNames, isForcedToolChoice, processText } from "@cloudflare/gateway-core";
// ---------------------------------------------------------------------------
// Workers AI quirk workarounds
// ---------------------------------------------------------------------------
/**
* 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
*/
export function normalizeMessagesForBinding(messages: WorkersAIChatPrompt): WorkersAIChatPrompt {
return coreNormalizeMessagesForBinding(
messages as unknown as Record<string, unknown>[],
) as unknown as WorkersAIChatPrompt;
}
// ---------------------------------------------------------------------------
// REST API client
// ---------------------------------------------------------------------------
/**
* General AI run interface with overloads to handle distinct return types.
*/
export interface AiRun {
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options: AiOptions & { returnRawResponse: true },
): Promise<Response>;
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"] & { stream: true },
options?: AiOptions,
): Promise<ReadableStream<Uint8Array>>;
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options?: AiOptions,
): Promise<AiModels[Name]["postProcessedOutputs"]>;
}
/**
* Parameters for configuring the Cloudflare-based AI runner.
*/
export interface CreateRunConfig {
/** Your Cloudflare account identifier. */
accountId: string;
/** Cloudflare API token/key with appropriate permissions. */
apiKey: string;
/** Custom fetch implementation for intercepting requests. */
fetch?: typeof globalThis.fetch;
}
/**
* Creates a run method that emulates the Cloudflare Workers AI binding,
* but uses the Cloudflare REST API under the hood.
*/
export function createRun(config: CreateRunConfig): AiRun {
const { accountId, apiKey } = config;
const fetchFn = config.fetch ?? globalThis.fetch;
return async function run<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options?: AiOptions & Record<string, unknown>,
): Promise<Response | ReadableStream<Uint8Array> | AiModels[Name]["postProcessedOutputs"]> {
const {
gateway,
prefix: _prefix,
extraHeaders,
returnRawResponse,
signal, // AbortSignal — not serializable as a query parameter
...passthroughOptions
} = options || {};
const urlParams = new URLSearchParams();
for (const [key, value] of Object.entries(passthroughOptions)) {
if (value === undefined || 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}`;
// Build URL: use AI Gateway if gateway option is provided, otherwise direct API
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: Record<string, string> = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
...(extraHeaders && typeof extraHeaders === "object"
? (extraHeaders as Record<string, string>)
: {}),
};
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 body = JSON.stringify(inputs);
const response = await fetchFn(url, {
body,
headers,
method: "POST",
signal: signal as AbortSignal | undefined,
});
// Check for HTTP errors before processing. Surface as an APICallError so
// the AI SDK can classify retryability from the status (429 / 5xx → retry)
// and honor any Retry-After header.
if (!response.ok && !returnRawResponse) {
let errorBody: string;
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 as AiTextGenerationInput).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")) {
// Unknown content type — assume it's a stream
return response.body;
}
// Some models (e.g. GPT-OSS) don't support streaming via the /ai/run/
// endpoint and return a JSON response with empty result instead of SSE.
// Retry without streaming so doStream's graceful degradation path can
// wrap the complete response as a synthetic stream.
// Use the same URL (gateway or direct) as the original request.
const retryResponse = await fetchFn(url, {
body: JSON.stringify({
...(inputs as Record<string, unknown>),
stream: false,
}),
headers,
method: "POST",
signal: signal as AbortSignal | undefined,
});
if (!retryResponse.ok) {
let errorBody: string;
try {
errorBody = await retryResponse.text();
} catch {
errorBody = "<unable to read response body>";
}
throw apiCallErrorFromResponse(retryResponse, errorBody, {
url,
requestBodyValues: inputs,
});
}
const retryData = await retryResponse.json<{
result: AiModels[Name]["postProcessedOutputs"];
}>();
return retryData.result;
}
const data = await response.json<{
result: AiModels[Name]["postProcessedOutputs"];
}>();
return data.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
*/
export async function createRunBinary(
config: CreateRunConfig,
model: string,
audioBytes: Uint8Array,
contentType: string,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
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: string;
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<{ result?: Record<string, unknown> }>();
return (data.result ?? data) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Structured output (JSON mode)
// ---------------------------------------------------------------------------
/**
* 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.
*/
export function buildJsonSchemaPayload(
schema: unknown,
name?: string,
description?: string,
): unknown {
// Only objects can carry JSON Schema keywords. Anything else (incl.
// `undefined` when no schema was supplied) passes through untouched.
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
return schema;
}
const record = schema as Record<string, unknown>;
const addTitle = !!name && record.title === undefined;
const addDescription = !!description && record.description === undefined;
if (!addTitle && !addDescription) {
return schema;
}
return {
...record,
...(addTitle ? { title: name } : {}),
...(addDescription ? { description } : {}),
};
}
// ---------------------------------------------------------------------------
// Tool preparation
// ---------------------------------------------------------------------------
export function prepareToolsAndToolChoice(
tools: Parameters<LanguageModelV3["doGenerate"]>[0]["tools"],
toolChoice: Parameters<LanguageModelV3["doGenerate"]>[0]["toolChoice"],
) {
if (tools == null) {
return { tool_choice: undefined, tools: undefined };
}
const mappedTools = tools.map((tool) => ({
function: {
description: tool.type === "function" ? tool.description : undefined,
name: tool.name,
parameters: tool.type === "function" ? tool.inputSchema : undefined,
},
type: "function",
}));
if (toolChoice == null) {
return { tool_choice: undefined, 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 };
// Force a specific tool via the OpenAI-style named-function form.
// Workers AI enforces this server-side, unlike "required" which is
// advisory and "fails open" on long contexts / reasoning models (the
// model can answer in prose instead of calling the tool). The full tool
// list is kept (not filtered to the single function) to match OpenAI
// semantics and preserve tool-result context fidelity.
// See https://github.com/cloudflare/ai/issues/560.
case "tool":
return {
tool_choice: { type: "function", function: { name: toolChoice.toolName } },
tools: mappedTools,
};
default: {
const exhaustiveCheck = type satisfies never;
throw new Error(`Unsupported tool choice type: ${exhaustiveCheck}`);
}
}
}
// ---------------------------------------------------------------------------
// Message helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Tool call processing
// ---------------------------------------------------------------------------
const TOOL_CALL_ID_MARKER = "::cf-wai-tool-call::";
export function createAISDKToolCallId(toolCallId: string | null | undefined): string {
const originalId = toolCallId || generateId();
return `${originalId}${TOOL_CALL_ID_MARKER}${generateId()}`;
}
export function toWorkersAIToolCallId(toolCallId: string): string {
const markerIndex = toolCallId.lastIndexOf(TOOL_CALL_ID_MARKER);
if (markerIndex === -1) return toolCallId;
const suffixIndex = markerIndex + TOOL_CALL_ID_MARKER.length;
if (suffixIndex >= toolCallId.length) return toolCallId;
return toolCallId.slice(0, markerIndex);
}
/** Workers AI flat tool call format (non-streaming, native) */
interface FlatToolCall {
name: string;
arguments: unknown;
id?: string;
}
/** Workers AI OpenAI-compatible tool call format */
interface OpenAIToolCall {
id: string;
type: "function";
function: {
name: string;
arguments: unknown;
};
}
/** Partial tool call from streaming (has index for merging) */
interface PartialToolCall {
index?: number;
id?: string;
type?: string;
function?: {
name?: string;
arguments?: string;
};
// Flat format fields
name?: string;
arguments?: string;
}
function mergePartialToolCalls(partialCalls: PartialToolCall[]) {
const mergedCallsByIndex: Record<
number,
{ function: { arguments: string; name: string }; id: string; type: string }
> = {};
for (const partialCall of partialCalls) {
const index = partialCall.index ?? 0;
if (!mergedCallsByIndex[index]) {
mergedCallsByIndex[index] = {
function: {
arguments: "",
name: partialCall.function?.name || "",
},
id: partialCall.id || "",
type: partialCall.type || "",
};
} else {
if (partialCall.id) {
mergedCallsByIndex[index].id = partialCall.id;
}
if (partialCall.type) {
mergedCallsByIndex[index].type = partialCall.type;
}
if (partialCall.function?.name) {
mergedCallsByIndex[index].function.name = partialCall.function.name;
}
}
// Append arguments if available (they arrive in order during streaming)
if (partialCall.function?.arguments) {
mergedCallsByIndex[index].function.arguments += partialCall.function.arguments;
}
}
return Object.values(mergedCallsByIndex);
}
function processToolCall(toolCall: FlatToolCall | OpenAIToolCall): LanguageModelV3ToolCall {
// OpenAI format: has function.name (the key discriminator)
const fn =
"function" in toolCall && typeof toolCall.function === "object" && toolCall.function
? (toolCall.function as { name?: string; arguments?: unknown })
: 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,
};
}
// Flat format (native Workers AI non-streaming): has top-level name
const flat = toolCall as FlatToolCall;
return {
input:
typeof flat.arguments === "string"
? flat.arguments
: JSON.stringify(flat.arguments || {}),
toolCallId: createAISDKToolCallId(flat.id),
type: "tool-call",
toolName: flat.name,
};
}
export function processToolCalls(output: Record<string, unknown>): LanguageModelV3ToolCall[] {
if (output.tool_calls && Array.isArray(output.tool_calls)) {
return output.tool_calls.map((toolCall: FlatToolCall | OpenAIToolCall) =>
processToolCall(toolCall),
);
}
const choices = output.choices as
| Array<{ message?: { tool_calls?: Array<FlatToolCall | OpenAIToolCall> } }>
| undefined;
if (choices?.[0]?.message?.tool_calls && Array.isArray(choices[0].message.tool_calls)) {
return choices[0].message.tool_calls.map((toolCall) => processToolCall(toolCall));
}
return [];
}
export function processPartialToolCalls(partialToolCalls: PartialToolCall[]) {
const mergedToolCalls = mergePartialToolCalls(partialToolCalls);
return processToolCalls({ tool_calls: mergedToolCalls });
}
// ---------------------------------------------------------------------------
// Forced tool-call salvage (gpt-oss harmony quirk)
// ---------------------------------------------------------------------------
/**
* 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.
*/
export function parseLeakedToolCalls(
text: string,
knownToolNames: Set<string>,
): LanguageModelV3ToolCall[] {
return coreParseLeakedToolCalls(text, knownToolNames).map((call) => ({
input: call.input,
toolCallId: createAISDKToolCallId(undefined),
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.
*/
export function salvageToolCallsFromText(
output: Record<string, unknown>,
context: {
tools: Array<{ function: { name?: string } }> | undefined;
toolChoice: unknown;
},
): LanguageModelV3ToolCall[] | null {
if (!isForcedToolChoice(context.toolChoice)) return null;
// Never override real tool calls.
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;
}