UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

272 lines (235 loc) 8.1 kB
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 }); }