UNPKG

@ai-sdk/google

Version:
715 lines (659 loc) 22.5 kB
import { InvalidArgumentError, type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions, type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart, type JSONObject, type SharedV4Warning, type TranscriptionModelV4, } from '@ai-sdk/provider'; import { combineHeaders, connectToWebSocket, convertToBase64, createJsonResponseHandler, parseProviderOptions, postJsonToApi, resolve, safeParseJSON, serializeModelOptions, waitForWebSocketBufferDrain, WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE, type FetchFunction, type Resolvable, type WebSocketConnection, type WebSocketConstructor, type WebSocketLike, } from '@ai-sdk/provider-utils'; import { z } from 'zod/v4'; import { getModelPath } from '../get-model-path'; import { getRealtimeWebSocketURL } from '../get-realtime-base-url'; import { googleFailedResponseHandler } from '../google-error'; import { googleTranscriptionModelOptions, type GoogleTranscriptionModelId, type GoogleTranscriptionModelOptions, } from './google-transcription-model-options'; const liveWebSocketPath = 'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent'; /** * After the input audio has ended, finish when no terminal signal * (`turnComplete` / idle `interactionStatus`) arrives within this window. * Trailing transcripts reset the timer. */ const defaultFinishGraceMs = 3000; function getLiveWebSocketURL(baseURL: string, apiKey: string): URL { const url = getRealtimeWebSocketURL(baseURL, liveWebSocketPath); url.searchParams.set('key', apiKey); return url; } /** Live transcription is only supported by `*-live` model variants. */ function isLiveTranscriptionModelId(modelId: string): boolean { return modelId.includes('-live'); } type GoogleLiveWordInfo = { text?: string; word?: string; startOffset?: string; endOffset?: string; }; type GoogleLiveTranscription = { text?: string; finished?: boolean; languageCode?: string; speakerLabel?: string; words?: GoogleLiveWordInfo[]; }; type GoogleLiveServerMessage = { setupComplete?: unknown; serverContent?: { inputTranscription?: GoogleLiveTranscription; interimInputTranscription?: GoogleLiveTranscription; turnComplete?: boolean; generationComplete?: boolean; interactionStatus?: string; }; inputTranscription?: GoogleLiveTranscription; usageMetadata?: JSONObject; error?: { message?: string }; }; interface GoogleTranscriptionModelConfig { provider: string; baseURL: string; headers?: Resolvable<Record<string, string | undefined>>; fetch?: FetchFunction; webSocket?: WebSocketConstructor; _internal?: { currentDate?: () => Date; finishGraceMs?: number; }; } export class GoogleTranscriptionModel implements TranscriptionModelV4 { readonly specificationVersion = 'v4'; static [WORKFLOW_SERIALIZE](model: GoogleTranscriptionModel) { return serializeModelOptions({ modelId: model.modelId, config: model.config, }); } static [WORKFLOW_DESERIALIZE](options: { modelId: GoogleTranscriptionModelId; config: GoogleTranscriptionModelConfig; }) { return new GoogleTranscriptionModel(options.modelId, options.config); } get provider(): string { return this.config.provider; } constructor( readonly modelId: GoogleTranscriptionModelId, private readonly config: GoogleTranscriptionModelConfig, ) {} private async parseOptions( providerOptions: Record<string, unknown> | undefined, ): Promise<GoogleTranscriptionModelOptions | undefined> { return parseProviderOptions({ provider: 'google', providerOptions, schema: googleTranscriptionModelOptions, }); } async doGenerate( options: Parameters<TranscriptionModelV4['doGenerate']>[0], ): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> { if (isLiveTranscriptionModelId(this.modelId)) { throw new InvalidArgumentError({ argument: 'modelId', message: `Model '${this.modelId}' only supports streaming transcription. ` + `Use experimental_streamTranscribe, or a unary model such as 'gemini-3.5-transcribe'.`, }); } const currentDate = this.config._internal?.currentDate?.() ?? new Date(); const warnings: SharedV4Warning[] = []; const googleOptions = await this.parseOptions(options.providerOptions); const transcriptionConfig = buildTranscriptionConfig(googleOptions); // Unary transcription is served by the Interactions API // (https://ai.google.dev/gemini-api/docs/transcribe). const requestBody = { model: this.modelId, input: [ { type: 'audio', data: convertToBase64(options.audio), mime_type: options.mediaType, }, ], ...(transcriptionConfig != null ? { generation_config: { transcription_config: transcriptionConfig } } : {}), }; const { value: response, responseHeaders, rawValue: rawResponse, } = await postJsonToApi({ url: `${this.config.baseURL}/interactions`, headers: combineHeaders( this.config.headers ? await resolve(this.config.headers) : undefined, options.headers, ), body: requestBody, failedResponseHandler: googleFailedResponseHandler, successfulResponseHandler: createJsonResponseHandler( googleInteractionsTranscriptionResponseSchema, ), abortSignal: options.abortSignal, fetch: this.config.fetch, }); let text = ''; const segments: Array<{ text: string; startSecond: number; endSecond: number; }> = []; for (const step of response.steps ?? []) { for (const content of step.content ?? []) { if (content.type !== 'text' || content.text == null) continue; text += content.text; for (const annotation of content.annotations ?? []) { if (annotation.type !== 'word_info') continue; const startSecond = parseOffsetSeconds(annotation.start_offset); const endSecond = parseOffsetSeconds(annotation.end_offset); if ( annotation.text == null || startSecond == null || endSecond == null ) { continue; } segments.push({ text: annotation.text, startSecond, endSecond }); } } } return { text, segments, language: undefined, durationInSeconds: undefined, warnings, response: { timestamp: currentDate, modelId: this.modelId, headers: responseHeaders, body: rawResponse, }, ...(response.usage != null ? { providerMetadata: { google: { usage: response.usage as JSONObject }, }, } : {}), }; } async doStream( options: TranscriptionModelV4StreamOptions, ): Promise< Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>> > { if (!isLiveTranscriptionModelId(this.modelId)) { throw new InvalidArgumentError({ argument: 'modelId', message: `Model '${this.modelId}' does not support streaming transcription. ` + `Use a live model such as 'gemini-3.5-transcribe-live'.`, }); } const currentDate = this.config._internal?.currentDate?.() ?? new Date(); const warnings: SharedV4Warning[] = []; const googleOptions = await this.parseOptions(options.providerOptions); validateLiveInputAudioFormat(options.inputAudioFormat); const headers = combineHeaders( this.config.headers ? await resolve(this.config.headers) : undefined, options.headers, ); // last case-variant wins: combineHeaders keeps case-distinct keys and // spreads per-call headers after configuration headers let apiKey: string | undefined; for (const [key, value] of Object.entries(headers)) { if (key.toLowerCase() === 'x-goog-api-key' && value != null) { apiKey = value; } } if (apiKey == null) { throw new Error( 'Google Generative AI API key is required for streaming transcription.', ); } const webSocketHeaders = Object.fromEntries( Object.entries(headers).filter( ([key]) => key.toLowerCase() !== 'x-goog-api-key', ), ); // NOTE: Google's GA announcement shows the setup with // `generationConfig: { responseModalities: ['TEXT'] }`, but sending it // suppresses the final `inputTranscription` segments on the current // endpoint (only interim partials arrive). Omit generationConfig — the // empirically working shape — until the endpoint honors the documented // form. const setup = { model: getModelPath(this.modelId), inputAudioTranscription: buildAudioTranscriptionConfig(googleOptions) ?? {}, }; return { request: { body: setup }, response: { timestamp: currentDate, modelId: this.modelId, }, stream: createGoogleLiveTranscriptionStream({ webSocket: this.config.webSocket, url: getLiveWebSocketURL(this.config.baseURL, apiKey), headers: webSocketHeaders, setup, inputAudioRate: options.inputAudioFormat.rate ?? 16000, finishGraceMs: this.config._internal?.finishGraceMs ?? defaultFinishGraceMs, warnings, audio: options.audio, abortSignal: options.abortSignal, includeRawChunks: options.includeRawChunks, }), }; } } function createGoogleLiveTranscriptionStream({ webSocket, url, headers, setup, inputAudioRate, finishGraceMs, warnings, audio, abortSignal, includeRawChunks, }: { webSocket: WebSocketConstructor | undefined; url: URL; headers: Record<string, string | undefined>; setup: unknown; inputAudioRate: number; finishGraceMs: number; warnings: SharedV4Warning[]; audio: ReadableStream<Uint8Array | string>; abortSignal: AbortSignal | undefined; includeRawChunks: boolean | undefined; }) { let finished = false; let cleanup: (closeCode?: number) => void = () => {}; return new ReadableStream<TranscriptionModelV4StreamPart>({ start: controller => { let audioReader: | ReadableStreamDefaultReader<Uint8Array | string> | undefined; let connection: WebSocketConnection | undefined; // The Live API contract requires waiting for the `setupComplete` // server message before sending realtime input: the audio send loop // is gated on this promise. let resolveSetupComplete!: () => void; const setupComplete = new Promise<void>(resolve => { resolveSetupComplete = resolve; }); // Google Live messages carry no response/item IDs; a segment counter // generates consistent synthetic IDs. Transcription fragments arrive // incrementally and are accumulated per segment; a `finished: true` // transcription or `turnComplete` finalizes the current segment. let segmentCounter = 0; let segmentBuffer = ''; let fullText = ''; // Latest revisable interim text: the fallback final when the server // never delivers a finished `inputTranscription` segment. let latestInterim = ''; let language: string | undefined; let audioEnded = false; let usageMetadata: JSONObject | undefined; let finishTimer: ReturnType<typeof setTimeout> | undefined; const segmentId = () => `google-segment-${segmentCounter}`; const cancelPendingFinish = () => { if (finishTimer != null) { clearTimeout(finishTimer); finishTimer = undefined; } }; // Trailing transcripts can arrive after audioStreamEnd; without a // terminal signal, finish after a quiet grace window. Transcript // activity reschedules the timer. const schedulePendingFinish = () => { if (finished || !audioEnded) return; cancelPendingFinish(); finishTimer = setTimeout(() => { finishTimer = undefined; finish(); }, finishGraceMs); }; cleanup = (closeCode?: number) => { cancelPendingFinish(); if (audioReader != null) { void audioReader.cancel().catch(() => {}); } else { // pre-open failure or abort: cancel the caller's audio stream so an // upstream producer piping into it does not hang: void audio.cancel().catch(() => {}); } connection?.close(closeCode); }; const finishWithError = (error: unknown) => { if (finished) return; finished = true; cleanup(); controller.error(error); }; const completeSegment = () => { // A finished segment supersedes any interim text it revises. if (segmentBuffer === '') { if (latestInterim === '') return; segmentBuffer = latestInterim; } latestInterim = ''; controller.enqueue({ type: 'transcript-final', id: segmentId(), text: segmentBuffer, }); fullText += fullText === '' ? segmentBuffer : ` ${segmentBuffer}`; segmentBuffer = ''; segmentCounter++; }; const finish = () => { if (finished) return; completeSegment(); finished = true; controller.enqueue({ type: 'finish', text: fullText, segments: [], language, durationInSeconds: undefined, ...(usageMetadata != null ? { providerMetadata: { google: { usageMetadata } } } : {}), }); controller.close(); cleanup(1000); }; const sendAudio = async (socket: WebSocketLike) => { audioReader = audio.getReader(); try { while (true) { const { done, value } = await audioReader.read(); if (done || finished) break; socket.send( JSON.stringify({ realtimeInput: { audio: { data: convertToBase64(value), mimeType: `audio/pcm;rate=${inputAudioRate}`, }, }, }), ); // backpressure: pause reads while the socket buffer is full await waitForWebSocketBufferDrain(socket); } } finally { audioReader.releaseLock(); // unlocked again: cleanup must cancel `audio`, not the reader audioReader = undefined; } if (!finished) { socket.send( JSON.stringify({ realtimeInput: { audioStreamEnd: true } }), ); audioEnded = true; schedulePendingFinish(); } }; connection = connectToWebSocket({ url, headers, webSocket, abortSignal, onAbort: finishWithError, onProcessingError: finishWithError, onOpen: socket => { controller.enqueue({ type: 'stream-start', warnings }); socket.send(JSON.stringify({ setup })); // audio may only be sent after the server acknowledged the setup: void setupComplete .then(() => (finished ? undefined : sendAudio(socket))) .catch(finishWithError); }, onMessageText: async text => { if (finished) return; const parsed = await safeParseJSON({ text }); if (!parsed.success) return; const message = parsed.value as GoogleLiveServerMessage; if (includeRawChunks) { controller.enqueue({ type: 'raw', rawValue: message }); } if (message.setupComplete != null) { resolveSetupComplete(); } if (message.usageMetadata != null) { usageMetadata = message.usageMetadata; } if (message.error != null) { finishWithError( new Error(message.error.message ?? 'Google Live API error'), ); return; } const serverContent = message.serverContent; // Low-latency revisable transcription while the user is speaking. const interim = serverContent?.interimInputTranscription; if (interim?.text) { schedulePendingFinish(); latestInterim = interim.text; controller.enqueue({ type: 'transcript-partial', id: segmentId(), text: interim.text, }); } const transcription = serverContent?.inputTranscription ?? message.inputTranscription; if (transcription != null) { if (transcription.languageCode != null) { language = transcription.languageCode; } if (transcription.text) { schedulePendingFinish(); // A real transcription delta supersedes interim fallback text. latestInterim = ''; segmentBuffer += transcription.text; controller.enqueue({ type: 'transcript-delta', id: segmentId(), delta: transcription.text, }); } if (transcription.finished === true) { completeSegment(); } } if (serverContent?.turnComplete) { completeSegment(); } // `interactionStatus` idle (REQUIRES_ACTION in the EAP builds) is // the definitive all-processing-complete signal: finish as soon as // the input audio has ended. const interactionStatus = serverContent?.interactionStatus; if ( audioEnded && (interactionStatus === 'IDLE' || interactionStatus === 'REQUIRES_ACTION' || (serverContent?.turnComplete === true && interactionStatus == null)) ) { finish(); } }, onSocketError: () => { finishWithError(new Error('Google Live transcription error')); }, onClose: ({ code, reason }) => { if (finished) return; // a close after the input audio ended means the server delivered // everything it will deliver: finish with the accumulated text if (audioEnded) { finish(); return; } finishWithError( new Error( `Google Live transcription WebSocket closed unexpectedly before finishing` + ` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`, ), ); }, }); }, cancel: () => { if (finished) return; finished = true; cleanup(); }, }); } /** * Builds Google's `AudioTranscriptionConfig` from provider options; returns * undefined when no options are set. */ function buildAudioTranscriptionConfig( options: GoogleTranscriptionModelOptions | undefined, ): Record<string, unknown> | undefined { if (options == null) return undefined; const config: Record<string, unknown> = {}; if (options.languageCodes != null) { config.languageCodes = options.languageCodes; } if (options.customVocabulary != null) { config.customVocabulary = options.customVocabulary; } if (options.wordTimestamp != null) { config.wordTimestamp = options.wordTimestamp; } if (options.diarization != null) { config.diarization = options.diarization; } if (options.mode != null) { config.mode = options.mode; } return Object.keys(config).length > 0 ? config : undefined; } /** * Builds the Interactions API `transcription_config` (snake_case wire) from * provider options; returns undefined when no options are set. Diarization * and word timestamps are expressed inside the `mode` object per * https://ai.google.dev/gemini-api/docs/transcribe. */ function buildTranscriptionConfig( options: GoogleTranscriptionModelOptions | undefined, ): Record<string, unknown> | undefined { if (options == null) return undefined; const config: Record<string, unknown> = {}; if (options.languageCodes != null) { config.language_codes = options.languageCodes; } if (options.customVocabulary != null) { config.custom_vocabulary = options.customVocabulary; } if ( options.mode != null || options.diarization === true || options.wordTimestamp === true ) { config.mode = { type: (options.mode ?? 'VERBATIM').toLowerCase(), ...(options.diarization === true ? { diarization_mode: 'speaker' } : {}), ...(options.wordTimestamp === true ? { timestamp_granularities: ['word'] } : {}), }; } return Object.keys(config).length > 0 ? config : undefined; } /** Parses a Google duration offset such as `"1s"` or `"9.400s"` to seconds. */ function parseOffsetSeconds( offset: string | undefined | null, ): number | undefined { if (offset == null) return undefined; const parsed = Number.parseFloat(offset); return Number.isFinite(parsed) ? parsed : undefined; } function validateLiveInputAudioFormat( inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'], ) { if ( inputAudioFormat.type !== 'audio/pcm' || (inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000) ) { throw new InvalidArgumentError({ argument: 'inputAudioFormat', message: 'The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.', }); } } const googleInteractionsWordAnnotationSchema = z.object({ type: z.string().nullish(), text: z.string().nullish(), speaker: z.string().nullish(), start_offset: z.string().nullish(), end_offset: z.string().nullish(), }); const googleInteractionsTranscriptionResponseSchema = z.object({ status: z.string().nullish(), steps: z .array( z.object({ type: z.string().nullish(), content: z .array( z.object({ type: z.string().nullish(), text: z.string().nullish(), annotations: z .array(googleInteractionsWordAnnotationSchema) .nullish(), }), ) .nullish(), }), ) .nullish(), usage: z.record(z.string(), z.unknown()).nullish(), });