workers-ai-provider
Version:
Workers AI Provider for the vercel AI SDK
164 lines (148 loc) • 5.26 kB
text/typescript
import type { SpeechModelV3, SharedV3Warning } from "@ai-sdk/provider";
import { apiCallErrorFromResponse, normalizeBindingError } from "./workersai-error";
import type { WorkersAISpeechSettings } from "./workersai-speech-settings";
import type { SpeechModels } from "./workersai-models";
export type WorkersAISpeechConfig = {
provider: string;
binding: Ai;
gateway?: GatewayOptions;
};
/**
* Workers AI speech (text-to-speech) model implementing the AI SDK's `SpeechModelV3` interface.
*
* Currently supports Deepgram Aura-1 (`@cf/deepgram/aura-1`).
* The model accepts `{ text, voice?, speed? }` and returns raw audio bytes.
*/
export class WorkersAISpeechModel implements SpeechModelV3 {
readonly specificationVersion = "v3";
get provider(): string {
return this.config.provider;
}
constructor(
readonly modelId: SpeechModels,
readonly settings: WorkersAISpeechSettings,
readonly config: WorkersAISpeechConfig,
) {}
async doGenerate(
options: Parameters<SpeechModelV3["doGenerate"]>[0],
): Promise<Awaited<ReturnType<SpeechModelV3["doGenerate"]>>> {
const { text, voice, speed, abortSignal } = options;
const warnings: Array<SharedV3Warning> = [];
if (options.instructions) {
warnings.push({
details: "Workers AI TTS models do not support instructions.",
feature: "instructions",
type: "unsupported",
});
}
if (options.outputFormat) {
warnings.push({
details:
"Workers AI TTS models do not support output format selection. Audio is returned as MP3.",
feature: "outputFormat",
type: "unsupported",
});
}
// Build inputs for Workers AI TTS
const inputs: Record<string, unknown> = { text };
if (voice) inputs.voice = voice;
if (speed != null) inputs.speed = speed;
let result: unknown;
try {
result = await this.config.binding.run(
this.modelId as Parameters<Ai["run"]>[0],
inputs as Parameters<Ai["run"]>[1],
{
gateway: this.config.gateway,
signal: abortSignal,
// returnRawResponse prevents the createRun REST shim from trying
// to JSON.parse binary audio. Real env.AI bindings don't recognize
// this option — it has no effect, and the binding returns the normal
// binary result (Uint8Array/ReadableStream) which toUint8Array handles.
returnRawResponse: true,
} 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: this.modelId,
requestBodyValues: inputs,
});
}
// The REST shim uses `returnRawResponse`, so it does NOT throw on a non-OK
// status — it hands back the raw Response. Without this guard the error body
// would be decoded as "audio". Surface it as a (retryable-aware) APICallError.
if (result instanceof Response && !result.ok) {
const errorBody = await result.text().catch(() => "<unable to read response body>");
throw apiCallErrorFromResponse(result, errorBody, {
url: `workers-ai:run/${this.modelId}`,
requestBodyValues: inputs,
});
}
// Workers AI TTS returns binary audio in various formats:
// - Binding: Uint8Array, ArrayBuffer, ReadableStream, or { audio: base64 }
// - REST (returnRawResponse): Response object
const audio = await toUint8Array(result);
return {
audio,
warnings,
response: {
timestamp: new Date(),
modelId: this.modelId,
headers: {},
},
};
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Convert various output types from binding.run() to Uint8Array.
* Workers AI TTS models return different types depending on the runtime:
* - Response (from REST shim with returnRawResponse)
* - ReadableStream<Uint8Array> (most common in workerd)
* - Uint8Array / ArrayBuffer (direct binary)
* - { audio: string } with base64 data
*/
async function toUint8Array(output: unknown): Promise<Uint8Array> {
// Response object (from REST shim with returnRawResponse: true)
if (output instanceof Response) {
return new Uint8Array(await output.arrayBuffer());
}
if (output instanceof Uint8Array) {
return output;
}
if (output instanceof ArrayBuffer) {
return new Uint8Array(output);
}
if (output instanceof ReadableStream) {
const reader = (output as ReadableStream<Uint8Array>).getReader();
const chunks: Uint8Array[] = [];
let totalLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
totalLength += value.length;
}
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
// Object with audio property (e.g. { audio: base64string })
if (typeof output === "object" && output !== null) {
const obj = output as Record<string, unknown>;
if (typeof obj.audio === "string") {
return Uint8Array.from(atob(obj.audio), (c) => c.charCodeAt(0));
}
}
throw new Error(
`Unexpected output type from TTS model. Got ${Object.prototype.toString.call(output)}`,
);
}