workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
272 lines (235 loc) • 8.1 kB
text/typescript
import type { LanguageModelV1, LanguageModelV1FunctionToolCall } from "@ai-sdk/provider";
/**
* General AI run interface with overloads to handle distinct return types.
*
* The behaviour depends on the combination of parameters:
* 1. `returnRawResponse: true` => returns the raw Response object.
* 2. `stream: true` => returns a ReadableStream (if available).
* 3. Otherwise => returns post-processed AI results.
*/
export interface AiRun {
// (1) Return raw Response if `options.returnRawResponse` is `true`.
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options: AiOptions & { returnRawResponse: true },
): Promise<Response>;
// (2) Return a stream if the input has `stream: true`.
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"] & { stream: true },
options?: AiOptions,
): Promise<ReadableStream<Uint8Array>>;
// (3) Return post-processed outputs by default.
<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options?: AiOptions,
): Promise<AiModels[Name]["postProcessedOutputs"]>;
}
export type StringLike = string | { toString(): string };
/**
* 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;
}
/**
* Creates a run method that emulates the Cloudflare Workers AI binding,
* but uses the Cloudflare REST API under the hood. Headers and abort
* signals are configured at creation time, rather than per-request.
*
* @param config An object containing:
* - `accountId`: Cloudflare account identifier.
* - `apiKey`: Cloudflare API token/key with suitable permissions.
* - `headers`: Optional custom headers to merge with defaults.
* - `signal`: Optional AbortSignal for request cancellation.
*
* @returns A function matching the AiRun interface.
*/
export function createRun(config: CreateRunConfig): AiRun {
const { accountId, apiKey } = config;
// Return the AiRun-compatible function.
return async function run<Name extends keyof AiModels>(
model: Name,
inputs: AiModels[Name]["inputs"],
options?: AiOptions & Record<string, StringLike>,
): Promise<Response | ReadableStream<Uint8Array> | AiModels[Name]["postProcessedOutputs"]> {
// biome-ignore lint/correctness/noUnusedVariables: they need to be destructured
const { gateway, prefix, extraHeaders, returnRawResponse, ...passthroughOptions } =
options || {};
const urlParams = new URLSearchParams();
for (const [key, value] of Object.entries(passthroughOptions)) {
// throw a useful error if the value is not to-stringable
try {
const valueStr = value.toString();
if (!valueStr) {
continue;
}
urlParams.append(key, valueStr);
} catch (_error) {
throw new Error(
`Value for option '${key}' is not able to be coerced into a string.`,
);
}
}
const url = `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/run/${model}${
urlParams ? `?${urlParams}` : ""
}`;
// Merge default and custom headers.
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
};
const body = JSON.stringify(inputs);
// Execute the POST request. The optional AbortSignal is applied here.
const response = await fetch(url, {
body,
headers,
method: "POST",
});
// (1) If the user explicitly requests the raw Response, return it as-is.
if (returnRawResponse) {
return response;
}
// (2) If the AI input requests streaming, return the ReadableStream if available.
if ((inputs as AiTextGenerationInput).stream === true) {
if (response.body) {
return response.body;
}
throw new Error("No readable body available for streaming.");
}
// (3) In all other cases, parse JSON and return the result field.
const data = await response.json<{
result: AiModels[Name]["postProcessedOutputs"];
}>();
return data.result;
};
}
export function prepareToolsAndToolChoice(
mode: Parameters<LanguageModelV1["doGenerate"]>[0]["mode"] & {
type: "regular";
},
) {
// when the tools array is empty, change it to undefined to prevent errors:
const tools = mode.tools?.length ? mode.tools : undefined;
if (tools == null) {
return { tool_choice: undefined, tools: undefined };
}
const mappedTools = tools.map((tool) => ({
function: {
// @ts-expect-error - description is not a property of tool
description: tool.description,
name: tool.name,
// @ts-expect-error - parameters is not a property of tool
parameters: tool.parameters,
},
type: "function",
}));
const toolChoice = mode.toolChoice;
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: "any", tools: mappedTools };
// workersAI does not support tool mode directly,
// so we filter the tools and force the tool choice through 'any'
case "tool":
return {
tool_choice: "any",
tools: mappedTools.filter((tool) => tool.function.name === toolChoice.toolName),
};
default: {
const exhaustiveCheck = type satisfies never;
throw new Error(`Unsupported tool choice type: ${exhaustiveCheck}`);
}
}
}
export function lastMessageWasUser<T extends { role: string }>(messages: T[]) {
return messages.length > 0 && messages[messages.length - 1]!.role === "user";
}
function mergePartialToolCalls(partialCalls: any[]) {
const mergedCallsByIndex: any = {};
for (const partialCall of partialCalls) {
const index = partialCall.index;
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, this assumes arguments come in the right order
if (partialCall.function?.arguments) {
mergedCallsByIndex[index].function.arguments += partialCall.function.arguments;
}
}
return Object.values(mergedCallsByIndex);
}
function processToolCall(toolCall: any): LanguageModelV1FunctionToolCall {
// Check for OpenAI format tool calls first
if (toolCall.function && toolCall.id) {
return {
args:
typeof toolCall.function.arguments === "string"
? toolCall.function.arguments
: JSON.stringify(toolCall.function.arguments || {}),
toolCallId: toolCall.id,
toolCallType: "function",
toolName: toolCall.function.name,
};
}
return {
args:
typeof toolCall.arguments === "string"
? toolCall.arguments
: JSON.stringify(toolCall.arguments || {}),
toolCallId: toolCall.name,
toolCallType: "function",
toolName: toolCall.name,
};
}
export function processToolCalls(output: any): LanguageModelV1FunctionToolCall[] {
if (output.tool_calls && Array.isArray(output.tool_calls)) {
return output.tool_calls.map((toolCall: any) => {
const processedToolCall = processToolCall(toolCall);
return processedToolCall;
});
}
if (
output?.choices?.[0]?.message?.tool_calls &&
Array.isArray(output.choices[0].message.tool_calls)
) {
return output.choices[0].message.tool_calls.map((toolCall: any) => {
const processedToolCall = processToolCall(toolCall);
return processedToolCall;
});
}
return [];
}
export function processPartialToolCalls(partialToolCalls: any[]) {
const mergedToolCalls = mergePartialToolCalls(partialToolCalls);
return processToolCalls({ tool_calls: mergedToolCalls });
}