UNPKG

ai

Version:

AI SDK by Vercel - build apps like ChatGPT, Claude, Gemini, and more with a single interface for any model using the Vercel AI Gateway or go direct to OpenAI, Anthropic, Google, or any other model provider.

500 lines (455 loc) • 15.5 kB
import { UnsupportedFunctionalityError, type Experimental_TranscriptionModelV4StreamPart, type JSONObject, type SharedV4AudioFormat, } from '@ai-sdk/provider'; import { createIdGenerator, DelayedPromise, withUserAgentSuffix, type ProviderOptions, } from '@ai-sdk/provider-utils'; import { NoTranscriptGeneratedError } from '../error/no-transcript-generated-error'; import { logWarnings } from '../logger/log-warnings'; import { resolveTranscriptionModel } from '../model/resolve-model'; import { createTelemetryDispatcher } from '../telemetry/create-telemetry-dispatcher'; import type { TelemetryOptions } from '../telemetry/telemetry-options'; import type { TranscriptionModel } from '../types/transcription-model'; import type { TranscriptionModelResponseMetadata } from '../types/transcription-model-response-metadata'; import type { Warning } from '../types/warning'; import { asAsyncIterableStream } from '../util/async-iterable-stream'; import { mergeAbortSignals } from '../util/merge-abort-signals'; import { notify } from '../util/notify'; import { VERSION } from '../version'; import type { StreamTranscriptionResult, TranscriptionStreamPart, } from './stream-transcribe-result'; import type { StreamTranscriptionEndEvent, StreamTranscriptionStartEvent, } from './transcription-events'; type TranscriptSegment = { text: string; startSecond: number; endSecond: number; }; const originalGenerateCallId = createIdGenerator({ prefix: 'call', size: 24, }); /** * Streams transcripts using a transcription model. * * @param model - The transcription model to use. * @param audio - Raw audio chunks to transcribe. * @param inputAudioFormat - The input audio format for the raw audio chunks. * @param providerOptions - Additional provider-specific options. * @param abortSignal - An optional abort signal that can be used to cancel the call. * @param headers - Additional HTTP/WebSocket headers to send when supported by the provider. * @param telemetry - Optional telemetry configuration. * * @returns A result object that contains the streaming transcript and final transcript metadata. */ export function streamTranscribe({ model, audio, inputAudioFormat, providerOptions = {}, abortSignal, headers, includeRawChunks, telemetry, _internal: { currentDate = () => new Date(), generateCallId = originalGenerateCallId, } = {}, }: { /** * The transcription model to use. */ model: TranscriptionModel; /** * Raw audio chunks to transcribe. */ audio: ReadableStream<Uint8Array | string>; /** * The input audio format for the raw audio chunks. */ inputAudioFormat: SharedV4AudioFormat; /** * Additional provider-specific options. */ providerOptions?: ProviderOptions; /** * Abort signal. */ abortSignal?: AbortSignal; /** * Additional headers to include in the request, if supported by the provider. */ headers?: Record<string, string>; /** * When true, providers should include raw provider chunks in the stream. */ includeRawChunks?: boolean; /** * Optional telemetry configuration. */ telemetry?: TelemetryOptions; /** * Internal test hooks. */ _internal?: { currentDate?: () => Date; generateCallId?: () => string; }; }): StreamTranscriptionResult { const resolvedModel = resolveTranscriptionModel(model); if (!resolvedModel) { throw new Error('Model could not be resolved'); } const doStream = resolvedModel.doStream?.bind(resolvedModel); if (doStream == null) { throw new UnsupportedFunctionalityError({ functionality: 'streaming transcription', message: `The ${resolvedModel.provider} model "${resolvedModel.modelId}" does not support streaming transcription.` + (typeof model === 'string' ? ' String model IDs resolve through the global provider (AI Gateway by default).' + ' If that provider does not support streaming transcription, pass a provider model' + " instance instead (e.g. openai.transcription('gpt-realtime-whisper'))" + ' or upgrade @ai-sdk/gateway to a version with streaming transcription support.' : ''), }); } const headersWithUserAgent = withUserAgentSuffix( headers ?? {}, `ai/${VERSION}`, ); const callId = generateCallId(); const telemetryDispatcher = createTelemetryDispatcher({ telemetry }); const runInTracingChannelSpan = telemetryDispatcher.runInTracingChannelSpan ?? (async <T>({ execute }: { execute: () => PromiseLike<T> }) => await execute()); let audioByteLength = 0; const countedAudio = telemetryDispatcher.experimental_onStreamTranscriptionEnd != null ? createByteCountingStream(audio, byteLength => { audioByteLength += byteLength; }) : audio; const startEvent: StreamTranscriptionStartEvent = { callId, operationId: 'ai.streamTranscribe', provider: resolvedModel.provider, modelId: resolvedModel.modelId, audio: { byteLength: undefined, mediaType: inputAudioFormat.type, }, inputAudioFormat, maxRetries: undefined, headers, providerOptions, }; const textPromise = new DelayedPromise<string>(); const segmentsPromise = new DelayedPromise<Array<TranscriptSegment>>(); const languagePromise = new DelayedPromise<string | undefined>(); const durationInSecondsPromise = new DelayedPromise<number | undefined>(); const warningsPromise = new DelayedPromise<Array<Warning>>(); const responsesPromise = new DelayedPromise< Array<TranscriptionModelResponseMetadata> >(); const providerMetadataPromise = new DelayedPromise< Record<string, JSONObject> >(); let warnings: Array<Warning> = []; let telemetrySettled = false; const rejectPendingPromises = (error: unknown) => { for (const promise of [ textPromise, segmentsPromise, languagePromise, durationInSecondsPromise, warningsPromise, responsesPromise, providerMetadataPromise, ]) { if (promise.isPending()) { promise.reject(error); } } }; const startedAt = currentDate(); let response: TranscriptionModelResponseMetadata | undefined; const currentResponseMetadata = () => response ?? { timestamp: startedAt, modelId: resolvedModel.modelId }; const resolveWarnings = (resolvedWarnings: Array<Warning>) => { warnings = resolvedWarnings; warningsPromise.resolve(resolvedWarnings); logWarnings({ warnings: resolvedWarnings, provider: resolvedModel.provider, model: resolvedModel.modelId, }); }; // When the consumer cancels `fullStream` early, we abort the model pipe below // with a defined reason via this controller. Relying on the default cancel // cascade aborts the pipe with an `undefined` reason, which surfaces as a // spurious unhandled rejection on Node.js 26 when the transform drops chunks. const pipeAbortController = new AbortController(); // `Transformer.cancel` is part of the Streams spec (and supported at runtime), // but not yet reflected in the ambient `Transformer` type, so widen it here. const transformer: Transformer< Experimental_TranscriptionModelV4StreamPart, TranscriptionStreamPart > & { cancel?: (reason?: unknown) => void } = { async transform(value, controller) { switch (value.type) { case 'stream-start': { resolveWarnings(value.warnings); break; } case 'response-metadata': { response = { timestamp: value.timestamp ?? currentResponseMetadata().timestamp, modelId: value.modelId ?? currentResponseMetadata().modelId, headers: value.headers ?? response?.headers, }; break; } case 'transcript-delta': case 'transcript-partial': case 'transcript-final': case 'raw': case 'error': { controller.enqueue(value); break; } case 'finish': { if (!warningsPromise.isResolved()) { resolveWarnings([]); } if (!value.text) { throw new NoTranscriptGeneratedError({ responses: [currentResponseMetadata()], }); } textPromise.resolve(value.text); segmentsPromise.resolve(value.segments); languagePromise.resolve(value.language); durationInSecondsPromise.resolve(value.durationInSeconds); responsesPromise.resolve([currentResponseMetadata()]); providerMetadataPromise.resolve(value.providerMetadata ?? {}); telemetrySettled = true; if ( telemetryDispatcher.experimental_onStreamTranscriptionEnd != null ) { const endEvent: StreamTranscriptionEndEvent = { callId, operationId: 'ai.streamTranscribe', provider: resolvedModel.provider, modelId: resolvedModel.modelId, audio: { byteLength: audioByteLength, mediaType: inputAudioFormat.type, }, text: value.text, segments: value.segments, language: value.language, durationInSeconds: value.durationInSeconds, usage: value.usage, warnings, providerMetadata: value.providerMetadata, response: currentResponseMetadata(), }; await notify({ event: endEvent, callbacks: [ telemetryDispatcher.experimental_onStreamTranscriptionEnd, ], }); } break; } } }, flush() { if (textPromise.isPending()) { throw new NoTranscriptGeneratedError({ responses: [currentResponseMetadata()], }); } }, cancel(reason) { pipeAbortController.abort( reason ?? new Error('Transcription stream was cancelled.'), ); }, }; const transform = new TransformStream< Experimental_TranscriptionModelV4StreamPart, TranscriptionStreamPart >(transformer); // Piping (instead of an eager read loop) preserves consumer backpressure // and propagates cancellation of `fullStream` to the model stream. void runInTracingChannelSpan({ type: 'streamTranscribe', event: startEvent, execute: async () => { await notify({ event: startEvent, callbacks: [ telemetryDispatcher.experimental_onStreamTranscriptionStart, ], }); const result = await doStream({ audio: countedAudio, inputAudioFormat, providerOptions, // merged so cancelling fullStream also aborts a still-pending doStream abortSignal: mergeAbortSignals(abortSignal, pipeAbortController.signal), headers: headersWithUserAgent, includeRawChunks, }); response = { timestamp: result.response?.timestamp ?? startedAt, modelId: result.response?.modelId ?? resolvedModel.modelId, headers: result.response?.headers, }; await result.stream.pipeTo(transform.writable, { signal: pipeAbortController.signal, }); }, }).catch(async error => { const reason = error ?? new Error('Transcription stream was cancelled or errored.'); rejectPendingPromises(reason); if (!telemetrySettled) { telemetrySettled = true; await telemetryDispatcher.onError?.({ callId, error: reason }); } // When `doStream` rejects before the model stream exists (e.g. auth or // header resolution failure), nothing has taken ownership of `countedAudio` // yet, so cancel it directly. When the model did take a reader, the cancel // rejects and the model's cleanup owns it. countedAudio.cancel(reason).catch(() => {}); transform.writable.abort(reason).catch(() => { // the writable is already errored when the model stream failed mid-pipe }); }); // Transcription streams can be unbounded (live microphone + raw chunks), so // unlike streamText we cannot retain an unread tee branch for replay. The // output stream has one owner: either fullStream, or the first result promise // getter which claims and drains it internally. let streamOwner: 'unclaimed' | 'full-stream' | 'result-promises' = 'unclaimed'; function consumeStream() { if (streamOwner === 'full-stream' || streamOwner === 'result-promises') { return; } streamOwner = 'result-promises'; const reader = transform.readable.getReader(); void (async () => { while (!(await reader.read()).done) { // drain; results surface via the promises } })().catch(() => { // stream errors reject the promises via the pipe handler above }); } function getFullStream() { if (streamOwner !== 'unclaimed') { throw new Error( streamOwner === 'full-stream' ? 'fullStream can only be accessed once.' : 'fullStream cannot be accessed after a result promise.', ); } streamOwner = 'full-stream'; // Direct ownership preserves backpressure and cancellation: cancelling // this stream reaches Transformer.cancel and aborts the model pipe. return asAsyncIterableStream(transform.readable); } return { get text() { consumeStream(); return textPromise.promise; }, get segments() { consumeStream(); return segmentsPromise.promise; }, get language() { consumeStream(); return languagePromise.promise; }, get durationInSeconds() { consumeStream(); return durationInSecondsPromise.promise; }, get warnings() { consumeStream(); return warningsPromise.promise; }, get responses() { consumeStream(); return responsesPromise.promise; }, get providerMetadata() { consumeStream(); return providerMetadataPromise.promise; }, get fullStream() { return getFullStream(); }, }; } function createByteCountingStream( audio: ReadableStream<Uint8Array | string>, onChunk: (byteLength: number) => void, ): ReadableStream<Uint8Array | string> { let audioReader: ReadableStreamDefaultReader<Uint8Array | string> | undefined; return new ReadableStream<Uint8Array | string>({ async pull(controller) { audioReader ??= audio.getReader(); const { done, value } = await audioReader.read(); if (done) { controller.close(); return; } onChunk( typeof value === 'string' ? getBase64ByteLength(value) : value.byteLength, ); controller.enqueue(value); }, async cancel(reason) { if (audioReader != null) { await audioReader.cancel(reason); } else { await audio.cancel(reason); } }, }); } function getBase64ByteLength(value: string): number { let characterCount = 0; let lastCharacter = ''; let secondLastCharacter = ''; for (const character of value) { if (/\s/.test(character)) { continue; } characterCount++; secondLastCharacter = lastCharacter; lastCharacter = character; } const padding = lastCharacter === '=' ? (secondLastCharacter === '=' ? 2 : 1) : 0; return Math.floor((characterCount * 3) / 4) - padding; }