UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

263 lines (236 loc) 7.08 kB
import type { LanguageModelV3DataContent, LanguageModelV3Prompt } from "@ai-sdk/provider"; import { UnsupportedFunctionalityError } from "@ai-sdk/provider"; import { toWorkersAIToolCallId } from "./utils"; import type { WorkersAIContentPart, WorkersAIChatPrompt } from "./workersai-chat-prompt"; /** * 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(data: LanguageModelV3DataContent): Uint8Array | null { 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: string | undefined): string { 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.", }); } // Media types are case-insensitive (RFC 2045), so compare against a // lower-cased copy while preserving the caller's original casing on output. 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(bytes: Uint8Array): string { 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); } export function convertToWorkersAIChatMessages(prompt: LanguageModelV3Prompt): { messages: WorkersAIChatPrompt; } { const messages: WorkersAIChatPrompt = []; for (const { role, content } of prompt) { switch (role) { case "system": { messages.push({ content, role: "system" }); break; } case "user": { const textParts: string[] = []; const imageParts: { image: Uint8Array; mediaType: string }[] = []; 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(part.data); if (imageBytes) { imageParts.push({ image: imageBytes, mediaType, }); } break; } } } if (imageParts.length > 0) { const contentArray: WorkersAIContentPart[] = []; if (textParts.length > 0) { contentArray.push({ type: "text", text: textParts.join("\n") }); } for (const img of imageParts) { const base64 = uint8ArrayToBase64(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: Array<{ id: string; type: "function"; function: { name: string; arguments: string }; }> = []; for (const part of content) { switch (part.type) { case "text": { text += part.text; break; } case "reasoning": { // Reasoning is accumulated separately and sent as the `reasoning` // field on the message object. This is the field name vLLM expects // on input for reasoning models (kimi-k2.7-code, glm-4.7-flash). // Concatenating it into `content` corrupts the conversation history // and causes models to produce empty or garbled responses on the // next turn. reasoning += part.text; break; } case "file": { // File parts in assistant messages - no action needed 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": { // Tool results in assistant messages - no action needed break; } default: { const exhaustiveCheck = part satisfies never; throw new Error( `Unsupported part type: ${(exhaustiveCheck as { type: string }).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" as const, })) : undefined, }); break; } case "tool": { for (const toolResponse of content) { if (toolResponse.type === "tool-result") { const output = toolResponse.output; let content: string; 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 is { type: "text"; text: string } => 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", }); } // Skip tool-approval-response parts as they're not supported by Workers AI } break; } default: { const exhaustiveCheck = role satisfies never; throw new Error(`Unsupported role: ${exhaustiveCheck}`); } } } return { messages }; }