workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
256 lines (224 loc) • 8.06 kB
text/typescript
import type { TranscriptionModelV3, SharedV3Warning } from "@ai-sdk/provider";
import type { WorkersAITranscriptionSettings } from "./workersai-transcription-settings";
import type { TranscriptionModels } from "./workersai-models";
import { createRunBinary, type CreateRunConfig } from "./utils";
import { normalizeBindingError } from "./workersai-error";
export type WorkersAITranscriptionConfig = {
provider: string;
binding: Ai;
gateway?: GatewayOptions;
/**
* Whether the binding is a real `env.AI` binding (true) or a REST shim (false).
* Nova-3 uses different upload paths depending on this.
*/
isBinding: boolean;
/**
* REST credentials, only set when `isBinding` is false.
* Needed for Nova-3 which requires binary upload, bypassing the JSON-based REST shim.
*/
credentials?: CreateRunConfig;
};
/**
* Workers AI transcription model implementing the AI SDK's `TranscriptionModelV3` interface.
*
* Supports:
* - Whisper models (`@cf/openai/whisper`, `whisper-tiny-en`, `whisper-large-v3-turbo`)
* - Deepgram Nova-3 (`@cf/deepgram/nova-3`) — uses a different input/output format
*/
export class WorkersAITranscriptionModel implements TranscriptionModelV3 {
readonly specificationVersion = "v3";
get provider(): string {
return this.config.provider;
}
constructor(
readonly modelId: TranscriptionModels,
readonly settings: WorkersAITranscriptionSettings,
readonly config: WorkersAITranscriptionConfig,
) {}
async doGenerate(
options: Parameters<TranscriptionModelV3["doGenerate"]>[0],
): Promise<Awaited<ReturnType<TranscriptionModelV3["doGenerate"]>>> {
const { audio, mediaType, abortSignal } = options;
const warnings: Array<SharedV3Warning> = [];
// The AI SDK always converts audio to Uint8Array via
// convertDataContentToUint8Array before calling doGenerate.
const audioBytes =
typeof audio === "string"
? Uint8Array.from(atob(audio), (c) => c.charCodeAt(0))
: audio;
const isNova3 = this.modelId === "@cf/deepgram/nova-3";
let rawResult: unknown;
try {
if (isNova3) {
rawResult = await this.runNova3(audioBytes, mediaType, abortSignal);
} else {
rawResult = await this.runWhisper(audioBytes, abortSignal);
}
} catch (error) {
// Normalize binding failures (e.g. 3040 "out of capacity" → 429) into a
// retryable APICallError so the AI SDK's maxRetries can engage. The
// REST binary path already throws an APICallError, which passes through.
throw normalizeBindingError(error, {
model: this.modelId,
requestBodyValues: { mediaType },
});
}
const result = rawResult as Record<string, unknown>;
// Normalize response into AI SDK format
if (isNova3) {
return this.normalizeNova3Response(result, warnings);
}
return this.normalizeWhisperResponse(result, warnings);
}
// ---------------------------------------------------------------------------
// Whisper models
// ---------------------------------------------------------------------------
private async runWhisper(audioBytes: Uint8Array, abortSignal?: AbortSignal): Promise<unknown> {
// whisper-large-v3-turbo requires base64 audio (both binding and REST).
// Other Whisper models accept number[].
const modelStr = this.modelId as string;
const audio =
modelStr === "@cf/openai/whisper-large-v3-turbo"
? uint8ArrayToBase64(audioBytes)
: Array.from(audioBytes);
const inputs: Record<string, unknown> = { audio };
if (this.settings.language) {
inputs.language = this.settings.language;
}
if (this.settings.prompt) {
inputs.initial_prompt = this.settings.prompt;
}
return this.config.binding.run(
this.modelId as Parameters<Ai["run"]>[0],
inputs as Parameters<Ai["run"]>[1],
{ gateway: this.config.gateway, signal: abortSignal } as AiOptions,
);
}
private normalizeWhisperResponse(
raw: Record<string, unknown>,
warnings: Array<SharedV3Warning>,
): Awaited<ReturnType<TranscriptionModelV3["doGenerate"]>> {
const text = (raw.text as string) ?? "";
// Build segments from Whisper's various formats
const segments: Array<{ text: string; startSecond: number; endSecond: number }> = [];
// whisper-large-v3-turbo returns segments[]
if (raw.segments && Array.isArray(raw.segments)) {
for (const seg of raw.segments) {
segments.push({
text: ((seg as Record<string, unknown>).text as string) ?? "",
startSecond: ((seg as Record<string, unknown>).start as number) ?? 0,
endSecond: ((seg as Record<string, unknown>).end as number) ?? 0,
});
}
}
// basic whisper returns words[]
else if (raw.words && Array.isArray(raw.words)) {
for (const w of raw.words) {
segments.push({
text: ((w as Record<string, unknown>).word as string) ?? "",
startSecond: ((w as Record<string, unknown>).start as number) ?? 0,
endSecond: ((w as Record<string, unknown>).end as number) ?? 0,
});
}
}
// Language and duration from transcription_info (v3-turbo)
const info = raw.transcription_info as Record<string, unknown> | undefined;
return {
text,
segments,
language: (info?.language as string) ?? undefined,
durationInSeconds: (info?.duration as number) ?? undefined,
warnings,
response: {
timestamp: new Date(),
modelId: this.modelId,
headers: {},
},
};
}
// ---------------------------------------------------------------------------
// Deepgram Nova-3
// ---------------------------------------------------------------------------
private async runNova3(
audioBytes: Uint8Array,
mediaType: string,
abortSignal?: AbortSignal,
): Promise<unknown> {
if (this.config.isBinding) {
// Binding path: Nova-3 accepts { audio: { body: base64, contentType } }
return this.config.binding.run(
this.modelId as Parameters<Ai["run"]>[0],
{
audio: { body: uint8ArrayToBase64(audioBytes), contentType: mediaType },
} as Parameters<Ai["run"]>[1],
{ gateway: this.config.gateway, signal: abortSignal } as AiOptions,
);
}
// REST path: Nova-3 requires raw binary with a Content-Type header,
// not JSON. The createRun shim always sends JSON, so we bypass it
// and use createRunBinary which sends the audio bytes directly.
if (!this.config.credentials) {
throw new Error(
"Nova-3 transcription via REST requires credentials in the config. " +
"This is a bug — credentials should have been set by createWorkersAI.",
);
}
return createRunBinary(
this.config.credentials,
this.modelId,
audioBytes,
mediaType,
abortSignal,
);
}
private normalizeNova3Response(
raw: Record<string, unknown>,
warnings: Array<SharedV3Warning>,
): Awaited<ReturnType<TranscriptionModelV3["doGenerate"]>> {
// Nova-3 format: { results: { channels: [{ alternatives: [{ transcript, words }] }] } }
const results = raw.results as Record<string, unknown> | undefined;
const channels = results?.channels as
| Array<{
alternatives?: Array<{
transcript?: string;
confidence?: number;
words?: Array<{ word: string; start: number; end: number }>;
}>;
}>
| undefined;
const alt = channels?.[0]?.alternatives?.[0];
const text = alt?.transcript ?? "";
const segments: Array<{ text: string; startSecond: number; endSecond: number }> = [];
if (alt?.words && Array.isArray(alt.words)) {
for (const w of alt.words) {
segments.push({
text: w.word ?? "",
startSecond: w.start ?? 0,
endSecond: w.end ?? 0,
});
}
}
return {
text,
segments,
language: undefined,
durationInSeconds: undefined,
warnings,
response: {
timestamp: new Date(),
modelId: this.modelId,
headers: {},
},
};
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function uint8ArrayToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]!);
}
return btoa(binary);
}