UNPKG

workers-ai-provider

Version:

Workers AI Provider for the vercel AI SDK

424 lines (382 loc) 13.1 kB
import type { LanguageModelV3, SharedV3Warning, LanguageModelV3StreamPart } from "@ai-sdk/provider"; import { generateId } from "ai"; import { convertToWorkersAIChatMessages } from "./convert-to-workersai-chat-messages"; import { mapWorkersAIFinishReason } from "./map-workersai-finish-reason"; import { mapWorkersAIUsage } from "./map-workersai-usage"; import { getMappedStream, prependStreamStart } from "./streaming"; import { buildJsonSchemaPayload, normalizeMessagesForBinding, prepareToolsAndToolChoice, processText, processToolCalls, salvageToolCallsFromText, } from "./utils"; import type { WorkersAIChatSettings } from "./workersai-chat-settings"; import { normalizeBindingError } from "./workersai-error"; import type { TextGenerationModels } from "./workersai-models"; type WorkersAIChatConfig = { provider: string; binding: Ai; gateway?: GatewayOptions; /** True when using a real Workers AI binding (not the REST shim). */ isBinding: boolean; }; export class WorkersAIChatLanguageModel implements LanguageModelV3 { readonly specificationVersion = "v3"; readonly defaultObjectGenerationMode = "json"; readonly supportedUrls: Record<string, RegExp[]> | PromiseLike<Record<string, RegExp[]>> = {}; readonly modelId: TextGenerationModels; readonly settings: WorkersAIChatSettings; private readonly config: WorkersAIChatConfig; constructor( modelId: TextGenerationModels, settings: WorkersAIChatSettings, config: WorkersAIChatConfig, ) { this.modelId = modelId; this.settings = settings; this.config = config; } get provider(): string { return this.config.provider; } private getArgs({ responseFormat, tools, toolChoice, maxOutputTokens, temperature, topP, frequencyPenalty, presencePenalty, seed, }: Parameters<LanguageModelV3["doGenerate"]>[0]) { const type = responseFormat?.type ?? "text"; const warnings: SharedV3Warning[] = []; 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: undefined as | { type: string; json_schema?: unknown } | undefined, ...prepareToolsAndToolChoice(tools, toolChoice), }, warnings, }; } case "json": { // Native Workers AI expects a BARE JSON Schema under `json_schema` // (not OpenAI's `{ name, schema, strict }` envelope — partner models // that need that go through the gateway delegate, not this path). We // fold the AI SDK's `name`/`description` into the schema as `title`/ // `description` so they aren't lost. See // https://github.com/cloudflare/ai/issues/559. const json = responseFormat?.type === "json" ? responseFormat : undefined; return { args: { ...baseArgs, response_format: { type: "json_schema", json_schema: buildJsonSchemaPayload( json?.schema, json?.name, json?.description, ), }, tools: undefined, tool_choice: undefined, }, warnings, }; } default: { const exhaustiveCheck = type satisfies never; throw new Error(`Unsupported type: ${exhaustiveCheck}`); } } } /** * 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. */ private buildRunInputs( args: ReturnType<typeof this.getArgs>["args"], messages: ReturnType<typeof convertToWorkersAIChatMessages>["messages"], options?: { stream?: boolean; providerOptions?: Record<string, unknown> }, ) { // The AI SDK types this as `Record<string, JSONObject>` but we defensively // accept anything and only treat it as a lookup if it's a plain object. // `"key" in x` throws for primitives, so we can't skip the typeof guard. const rawPerCall = options?.providerOptions?.["workers-ai"]; const perCall: Record<string, unknown> = rawPerCall !== null && typeof rawPerCall === "object" && !Array.isArray(rawPerCall) ? (rawPerCall as Record<string, unknown>) : {}; 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 !== undefined ? { reasoning_effort: reasoningEffort } : {}), ...(chatTemplateKwargs !== undefined ? { 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. */ private getRunOptions() { const { gateway, safePrompt: _safePrompt, sessionAffinity, extraHeaders, reasoning_effort: _reasoningEffort, chat_template_kwargs: _chatTemplateKwargs, ...passthroughOptions } = this.settings; const mergedHeaders = { ...(extraHeaders && typeof extraHeaders === "object" ? (extraHeaders as Record<string, string>) : {}), ...(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. */ private extractContent( outputRecord: Record<string, unknown>, args: ReturnType<typeof this.getArgs>["args"], warnings: SharedV3Warning[], ) { const choices = outputRecord.choices as | Array<{ message?: { reasoning_content?: string; reasoning?: string } }> | undefined; 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, // Suppress the leaked JSON text when we salvaged a tool call from it. text: salvaged ? "" : (processText(outputRecord) ?? ""), toolCalls: salvaged ?? toolCalls, // When salvaged, the upstream finish_reason is "stop"; report // "tool-calls" so the response is indistinguishable from a native // tool call and the agentic loop continues correctly. finishReason: salvaged ? ({ unified: "tool-calls", raw: "stop" } as const) : mapWorkersAIFinishReason(outputRecord), }; } async doGenerate( options: Parameters<LanguageModelV3["doGenerate"]>[0], ): Promise<Awaited<ReturnType<LanguageModelV3["doGenerate"]>>> { 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: unknown; try { output = await this.config.binding.run( args.model as keyof AiModels, inputs as AiModels[keyof AiModels]["inputs"], { ...runOptions, signal: options.abortSignal, } as AiOptions, ); } catch (error) { // Normalize binding failures (e.g. 3040 "out of capacity" → 429) into a // retryable APICallError so the AI SDK's maxRetries can engage. 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 as Record<string, unknown>; const { reasoningContent, text, toolCalls, finishReason } = this.extractContent( outputRecord, args, warnings, ); return { finishReason, content: [ ...(reasoningContent ? [{ type: "reasoning" as const, text: reasoningContent }] : []), { type: "text" as const, text }, ...toolCalls, ], usage: mapWorkersAIUsage(output as Record<string, unknown>), warnings, }; } async doStream( options: Parameters<LanguageModelV3["doStream"]>[0], ): Promise<Awaited<ReturnType<LanguageModelV3["doStream"]>>> { 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: unknown; try { response = await this.config.binding.run( args.model as keyof AiModels, inputs as AiModels[keyof AiModels]["inputs"], { ...runOptions, signal: options.abortSignal, } as AiOptions, ); } catch (error) { // Normalize binding failures (e.g. 3040 "out of capacity" → 429) into a // retryable APICallError so the AI SDK's maxRetries can engage. throw normalizeBindingError(error, { model: args.model, requestBodyValues: inputs, }); } // If the binding returned a stream, pipe it through the SSE mapper if (response instanceof ReadableStream) { return { stream: prependStreamStart( getMappedStream(response, { tools: args.tools, toolChoice: args.tool_choice, }), warnings, ), }; } // Graceful degradation: some models return a non-streaming response even // when stream:true is requested. Wrap the complete response as a stream. const outputRecord = response as Record<string, unknown>; const { reasoningContent, text, toolCalls, finishReason } = this.extractContent( outputRecord, args, warnings, ); let textId: string | null = null; let reasoningId: string | null = null; return { stream: new ReadableStream<LanguageModelV3StreamPart>({ start(controller) { controller.enqueue({ type: "stream-start", warnings: warnings as SharedV3Warning[], }); 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 as Record<string, unknown>), }); controller.close(); }, }), }; } }