@ai-sdk/google-vertex 5.0.65 → 5.0.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,709 @@
1
+ import {
2
+ InvalidArgumentError,
3
+ type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
4
+ type Experimental_TranscriptionModelV4StreamPart as TranscriptionModelV4StreamPart,
5
+ type JSONObject,
6
+ type SharedV4Warning,
7
+ type TranscriptionModelV4,
8
+ } from '@ai-sdk/provider';
9
+ import {
10
+ combineHeaders,
11
+ connectToWebSocket,
12
+ convertToBase64,
13
+ createJsonResponseHandler,
14
+ parseProviderOptions,
15
+ postJsonToApi,
16
+ resolve,
17
+ safeParseJSON,
18
+ serializeModelOptions,
19
+ waitForWebSocketBufferDrain,
20
+ WORKFLOW_DESERIALIZE,
21
+ WORKFLOW_SERIALIZE,
22
+ type FetchFunction,
23
+ type Resolvable,
24
+ type WebSocketConnection,
25
+ type WebSocketConstructor,
26
+ type WebSocketLike,
27
+ } from '@ai-sdk/provider-utils';
28
+ import { z } from 'zod/v4';
29
+ import { googleVertexFailedResponseHandler } from '../google-vertex-error';
30
+ import {
31
+ googleVertexGeminiTranscriptionModelOptions,
32
+ type GoogleVertexGeminiTranscriptionModelId,
33
+ type GoogleVertexTranscriptionModelGeminiOptions,
34
+ } from './google-vertex-gemini-transcription-model-options';
35
+
36
+ const liveWebSocketPath =
37
+ 'google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent';
38
+
39
+ /**
40
+ * After the input audio has ended, finish when no terminal signal
41
+ * (`turnComplete` / idle `interactionStatus`) arrives within this window.
42
+ * Trailing transcripts reset the timer.
43
+ */
44
+ const defaultFinishGraceMs = 3000;
45
+
46
+ /** Live transcription is only supported by `*-live` model variants. */
47
+ function isLiveTranscriptionModelId(modelId: string): boolean {
48
+ return modelId.includes('-live');
49
+ }
50
+
51
+ /** Regional Vertex hostname (mirrors the provider's base-URL host rules). */
52
+ function vertexHost(location: string): string {
53
+ if (location === 'global') return 'aiplatform.googleapis.com';
54
+ if (location === 'eu' || location === 'us') {
55
+ return `aiplatform.${location}.rep.googleapis.com`;
56
+ }
57
+ return `${location}-aiplatform.googleapis.com`;
58
+ }
59
+
60
+ type GoogleLiveWordInfo = {
61
+ text?: string;
62
+ word?: string;
63
+ startOffset?: string;
64
+ endOffset?: string;
65
+ };
66
+
67
+ type GoogleLiveTranscription = {
68
+ text?: string;
69
+ finished?: boolean;
70
+ languageCode?: string;
71
+ speakerLabel?: string;
72
+ words?: GoogleLiveWordInfo[];
73
+ };
74
+
75
+ type GoogleLiveServerMessage = {
76
+ setupComplete?: unknown;
77
+ serverContent?: {
78
+ inputTranscription?: GoogleLiveTranscription;
79
+ interimInputTranscription?: GoogleLiveTranscription;
80
+ turnComplete?: boolean;
81
+ generationComplete?: boolean;
82
+ interactionStatus?: string;
83
+ };
84
+ inputTranscription?: GoogleLiveTranscription;
85
+ usageMetadata?: JSONObject;
86
+ error?: { message?: string };
87
+ };
88
+
89
+ interface GoogleVertexGeminiTranscriptionModelConfig {
90
+ provider: string;
91
+ /** Regional base URL ending in `/publishers/google`. */
92
+ baseURL: string;
93
+ headers?: Resolvable<Record<string, string | undefined>>;
94
+ fetch?: FetchFunction;
95
+ webSocket?: WebSocketConstructor;
96
+ project: string;
97
+ location: string;
98
+ _internal?: {
99
+ currentDate?: () => Date;
100
+ finishGraceMs?: number;
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Gemini transcription on Vertex AI. Unary variants transcribe via
106
+ * `generateContent`; live variants stream over the Vertex Live API WebSocket
107
+ * (`LlmBidiService/BidiGenerateContent`) with OAuth Bearer authentication
108
+ * from the provider's resolved headers.
109
+ */
110
+ export class GoogleVertexGeminiTranscriptionModel implements TranscriptionModelV4 {
111
+ readonly specificationVersion = 'v4';
112
+
113
+ static [WORKFLOW_SERIALIZE](model: GoogleVertexGeminiTranscriptionModel) {
114
+ return serializeModelOptions({
115
+ modelId: model.modelId,
116
+ config: model.config,
117
+ });
118
+ }
119
+
120
+ static [WORKFLOW_DESERIALIZE](options: {
121
+ modelId: GoogleVertexGeminiTranscriptionModelId;
122
+ config: GoogleVertexGeminiTranscriptionModelConfig;
123
+ }) {
124
+ return new GoogleVertexGeminiTranscriptionModel(
125
+ options.modelId,
126
+ options.config,
127
+ );
128
+ }
129
+
130
+ get provider(): string {
131
+ return this.config.provider;
132
+ }
133
+
134
+ constructor(
135
+ readonly modelId: GoogleVertexGeminiTranscriptionModelId,
136
+ private readonly config: GoogleVertexGeminiTranscriptionModelConfig,
137
+ ) {}
138
+
139
+ private async parseOptions(
140
+ providerOptions: Record<string, unknown> | undefined,
141
+ ): Promise<GoogleVertexTranscriptionModelGeminiOptions | undefined> {
142
+ // The Vertex provider exposes options under `googleVertex`/`vertex`;
143
+ // accept `google` as a cross-namespace fallback (e.g. via the AI Gateway).
144
+ for (const provider of ['googleVertex', 'vertex', 'google'] as const) {
145
+ const parsed = await parseProviderOptions({
146
+ provider,
147
+ providerOptions,
148
+ schema: googleVertexGeminiTranscriptionModelOptions,
149
+ });
150
+ if (parsed != null) return parsed;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ async doGenerate(
156
+ options: Parameters<TranscriptionModelV4['doGenerate']>[0],
157
+ ): Promise<Awaited<ReturnType<TranscriptionModelV4['doGenerate']>>> {
158
+ if (isLiveTranscriptionModelId(this.modelId)) {
159
+ throw new InvalidArgumentError({
160
+ argument: 'modelId',
161
+ message:
162
+ `Model '${this.modelId}' only supports streaming transcription. ` +
163
+ `Use experimental_streamTranscribe, or a unary model such as 'gemini-3.5-transcribe'.`,
164
+ });
165
+ }
166
+
167
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
168
+ const warnings: SharedV4Warning[] = [];
169
+ const googleOptions = await this.parseOptions(options.providerOptions);
170
+ const audioTranscriptionConfig =
171
+ buildAudioTranscriptionConfig(googleOptions);
172
+
173
+ const requestBody = {
174
+ contents: [
175
+ {
176
+ role: 'user',
177
+ parts: [
178
+ {
179
+ inlineData: {
180
+ mimeType: options.mediaType,
181
+ data: convertToBase64(options.audio),
182
+ },
183
+ },
184
+ ],
185
+ },
186
+ ],
187
+ ...(audioTranscriptionConfig != null
188
+ ? { generationConfig: { audioTranscriptionConfig } }
189
+ : {}),
190
+ };
191
+
192
+ const {
193
+ value: response,
194
+ responseHeaders,
195
+ rawValue: rawResponse,
196
+ } = await postJsonToApi({
197
+ url: `${this.config.baseURL}/models/${this.modelId}:generateContent`,
198
+ headers: combineHeaders(
199
+ this.config.headers ? await resolve(this.config.headers) : undefined,
200
+ options.headers,
201
+ ),
202
+ body: requestBody,
203
+ failedResponseHandler: googleVertexFailedResponseHandler,
204
+ successfulResponseHandler: createJsonResponseHandler(
205
+ googleVertexGeminiTranscriptionResponseSchema,
206
+ ),
207
+ abortSignal: options.abortSignal,
208
+ fetch: this.config.fetch,
209
+ });
210
+
211
+ const parts = response.candidates?.[0]?.content?.parts ?? [];
212
+ const plainText = parts.map(part => part.text ?? '').join('');
213
+ const transcriptionText = parts
214
+ .map(part => part.audioTranscription?.text ?? '')
215
+ .join('');
216
+ const text = plainText !== '' ? plainText : transcriptionText;
217
+
218
+ let language: string | undefined;
219
+ const segments: Array<{
220
+ text: string;
221
+ startSecond: number;
222
+ endSecond: number;
223
+ }> = [];
224
+ for (const part of parts) {
225
+ const transcription = part.audioTranscription;
226
+ if (transcription == null) continue;
227
+ language ??= transcription.languageCode ?? undefined;
228
+ for (const word of transcription.words ?? []) {
229
+ const startSecond = parseOffsetSeconds(word.startOffset);
230
+ const endSecond = parseOffsetSeconds(word.endOffset);
231
+ if (word.word == null || startSecond == null || endSecond == null) {
232
+ continue;
233
+ }
234
+ segments.push({ text: word.word, startSecond, endSecond });
235
+ }
236
+ }
237
+
238
+ return {
239
+ text,
240
+ segments,
241
+ language,
242
+ durationInSeconds: undefined,
243
+ warnings,
244
+ response: {
245
+ timestamp: currentDate,
246
+ modelId: this.modelId,
247
+ headers: responseHeaders,
248
+ body: rawResponse,
249
+ },
250
+ ...(response.usageMetadata != null
251
+ ? {
252
+ providerMetadata: {
253
+ google: { usageMetadata: response.usageMetadata as JSONObject },
254
+ },
255
+ }
256
+ : {}),
257
+ };
258
+ }
259
+
260
+ async doStream(
261
+ options: TranscriptionModelV4StreamOptions,
262
+ ): Promise<
263
+ Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>>
264
+ > {
265
+ if (!isLiveTranscriptionModelId(this.modelId)) {
266
+ throw new InvalidArgumentError({
267
+ argument: 'modelId',
268
+ message:
269
+ `Model '${this.modelId}' does not support streaming transcription. ` +
270
+ `Use a live model such as 'gemini-3.5-transcribe-live'.`,
271
+ });
272
+ }
273
+
274
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
275
+ const warnings: SharedV4Warning[] = [];
276
+ const googleOptions = await this.parseOptions(options.providerOptions);
277
+
278
+ validateLiveInputAudioFormat(options.inputAudioFormat);
279
+
280
+ // Vertex Live authenticates with the same OAuth Bearer header the HTTP
281
+ // surface uses; a header-capable WebSocket implementation (e.g. `ws`) is
282
+ // required to send it.
283
+ const headers = combineHeaders(
284
+ this.config.headers ? await resolve(this.config.headers) : undefined,
285
+ options.headers,
286
+ );
287
+
288
+ const { project, location } = this.config;
289
+ const modelResource = `projects/${project}/locations/${location}/publishers/google/models/${this.modelId}`;
290
+ const url = new URL(
291
+ `wss://${vertexHost(location)}/ws/${liveWebSocketPath}`,
292
+ );
293
+
294
+ // NOTE: mirrors the Developer API model — Google's documented setup shape
295
+ // includes `generationConfig.responseModalities: ['TEXT']`, but sending it
296
+ // suppresses the final `inputTranscription` segments; omit generationConfig.
297
+ const setup = {
298
+ model: modelResource,
299
+ inputAudioTranscription:
300
+ buildAudioTranscriptionConfig(googleOptions) ?? {},
301
+ };
302
+
303
+ return {
304
+ request: { body: setup },
305
+ response: {
306
+ timestamp: currentDate,
307
+ modelId: this.modelId,
308
+ },
309
+ stream: createVertexLiveTranscriptionStream({
310
+ webSocket: this.config.webSocket,
311
+ url,
312
+ headers,
313
+ setup,
314
+ inputAudioRate: options.inputAudioFormat.rate ?? 16000,
315
+ finishGraceMs:
316
+ this.config._internal?.finishGraceMs ?? defaultFinishGraceMs,
317
+ warnings,
318
+ audio: options.audio,
319
+ abortSignal: options.abortSignal,
320
+ includeRawChunks: options.includeRawChunks,
321
+ }),
322
+ };
323
+ }
324
+ }
325
+
326
+ function createVertexLiveTranscriptionStream({
327
+ webSocket,
328
+ url,
329
+ headers,
330
+ setup,
331
+ inputAudioRate,
332
+ finishGraceMs,
333
+ warnings,
334
+ audio,
335
+ abortSignal,
336
+ includeRawChunks,
337
+ }: {
338
+ webSocket: WebSocketConstructor | undefined;
339
+ url: URL;
340
+ headers: Record<string, string | undefined>;
341
+ setup: unknown;
342
+ inputAudioRate: number;
343
+ finishGraceMs: number;
344
+ warnings: SharedV4Warning[];
345
+ audio: ReadableStream<Uint8Array | string>;
346
+ abortSignal: AbortSignal | undefined;
347
+ includeRawChunks: boolean | undefined;
348
+ }) {
349
+ let finished = false;
350
+ let cleanup: (closeCode?: number) => void = () => {};
351
+
352
+ return new ReadableStream<TranscriptionModelV4StreamPart>({
353
+ start: controller => {
354
+ let audioReader:
355
+ | ReadableStreamDefaultReader<Uint8Array | string>
356
+ | undefined;
357
+ let connection: WebSocketConnection | undefined;
358
+
359
+ // The Live API contract requires waiting for the `setupComplete`
360
+ // server message before sending realtime input: the audio send loop
361
+ // is gated on this promise.
362
+ let resolveSetupComplete!: () => void;
363
+ const setupComplete = new Promise<void>(resolvePromise => {
364
+ resolveSetupComplete = resolvePromise;
365
+ });
366
+
367
+ // Google Live messages carry no response/item IDs; a segment counter
368
+ // generates consistent synthetic IDs. Transcription fragments arrive
369
+ // incrementally and are accumulated per segment; a `finished: true`
370
+ // transcription or `turnComplete` finalizes the current segment.
371
+ let segmentCounter = 0;
372
+ let segmentBuffer = '';
373
+ let fullText = '';
374
+ // Latest revisable interim text: the fallback final when the server
375
+ // never delivers a finished `inputTranscription` segment.
376
+ let latestInterim = '';
377
+ let language: string | undefined;
378
+ let audioEnded = false;
379
+ let usageMetadata: JSONObject | undefined;
380
+ let finishTimer: ReturnType<typeof setTimeout> | undefined;
381
+
382
+ const segmentId = () => `google-segment-${segmentCounter}`;
383
+
384
+ const cancelPendingFinish = () => {
385
+ if (finishTimer != null) {
386
+ clearTimeout(finishTimer);
387
+ finishTimer = undefined;
388
+ }
389
+ };
390
+
391
+ // Trailing transcripts can arrive after audioStreamEnd; without a
392
+ // terminal signal, finish after a quiet grace window. Transcript
393
+ // activity reschedules the timer.
394
+ const schedulePendingFinish = () => {
395
+ if (finished || !audioEnded) return;
396
+ cancelPendingFinish();
397
+ finishTimer = setTimeout(() => {
398
+ finishTimer = undefined;
399
+ finish();
400
+ }, finishGraceMs);
401
+ };
402
+
403
+ cleanup = (closeCode?: number) => {
404
+ cancelPendingFinish();
405
+ if (audioReader != null) {
406
+ void audioReader.cancel().catch(() => {});
407
+ } else {
408
+ // pre-open failure or abort: cancel the caller's audio stream so an
409
+ // upstream producer piping into it does not hang:
410
+ void audio.cancel().catch(() => {});
411
+ }
412
+ connection?.close(closeCode);
413
+ };
414
+
415
+ const finishWithError = (error: unknown) => {
416
+ if (finished) return;
417
+ finished = true;
418
+ cleanup();
419
+ controller.error(error);
420
+ };
421
+
422
+ const completeSegment = () => {
423
+ // A finished segment supersedes any interim text it revises.
424
+ if (segmentBuffer === '') {
425
+ if (latestInterim === '') return;
426
+ segmentBuffer = latestInterim;
427
+ }
428
+ latestInterim = '';
429
+ controller.enqueue({
430
+ type: 'transcript-final',
431
+ id: segmentId(),
432
+ text: segmentBuffer,
433
+ });
434
+ fullText += fullText === '' ? segmentBuffer : ` ${segmentBuffer}`;
435
+ segmentBuffer = '';
436
+ segmentCounter++;
437
+ };
438
+
439
+ const finish = () => {
440
+ if (finished) return;
441
+ completeSegment();
442
+ finished = true;
443
+ controller.enqueue({
444
+ type: 'finish',
445
+ text: fullText,
446
+ segments: [],
447
+ language,
448
+ durationInSeconds: undefined,
449
+ ...(usageMetadata != null
450
+ ? { providerMetadata: { google: { usageMetadata } } }
451
+ : {}),
452
+ });
453
+ controller.close();
454
+ cleanup(1000);
455
+ };
456
+
457
+ const sendAudio = async (socket: WebSocketLike) => {
458
+ audioReader = audio.getReader();
459
+ try {
460
+ while (true) {
461
+ const { done, value } = await audioReader.read();
462
+ if (done || finished) break;
463
+ socket.send(
464
+ JSON.stringify({
465
+ realtimeInput: {
466
+ audio: {
467
+ data: convertToBase64(value),
468
+ mimeType: `audio/pcm;rate=${inputAudioRate}`,
469
+ },
470
+ },
471
+ }),
472
+ );
473
+ // backpressure: pause reads while the socket buffer is full
474
+ await waitForWebSocketBufferDrain(socket);
475
+ }
476
+ } finally {
477
+ audioReader.releaseLock();
478
+ // unlocked again: cleanup must cancel `audio`, not the reader
479
+ audioReader = undefined;
480
+ }
481
+ if (!finished) {
482
+ socket.send(
483
+ JSON.stringify({ realtimeInput: { audioStreamEnd: true } }),
484
+ );
485
+ audioEnded = true;
486
+ schedulePendingFinish();
487
+ }
488
+ };
489
+
490
+ connection = connectToWebSocket({
491
+ url,
492
+ headers,
493
+ webSocket,
494
+ abortSignal,
495
+ onAbort: finishWithError,
496
+ onProcessingError: finishWithError,
497
+ onOpen: socket => {
498
+ controller.enqueue({ type: 'stream-start', warnings });
499
+ socket.send(JSON.stringify({ setup }));
500
+ // audio may only be sent after the server acknowledged the setup:
501
+ void setupComplete
502
+ .then(() => (finished ? undefined : sendAudio(socket)))
503
+ .catch(finishWithError);
504
+ },
505
+ onMessageText: async text => {
506
+ if (finished) return;
507
+ const parsed = await safeParseJSON({ text });
508
+ if (!parsed.success) return;
509
+ const message = parsed.value as GoogleLiveServerMessage;
510
+
511
+ if (includeRawChunks) {
512
+ controller.enqueue({ type: 'raw', rawValue: message });
513
+ }
514
+
515
+ if (message.setupComplete != null) {
516
+ resolveSetupComplete();
517
+ }
518
+
519
+ if (message.usageMetadata != null) {
520
+ usageMetadata = message.usageMetadata;
521
+ }
522
+
523
+ if (message.error != null) {
524
+ finishWithError(
525
+ new Error(message.error.message ?? 'Vertex Live API error'),
526
+ );
527
+ return;
528
+ }
529
+
530
+ const serverContent = message.serverContent;
531
+
532
+ // Low-latency revisable transcription while the user is speaking.
533
+ const interim = serverContent?.interimInputTranscription;
534
+ if (interim?.text) {
535
+ schedulePendingFinish();
536
+ latestInterim = interim.text;
537
+ controller.enqueue({
538
+ type: 'transcript-partial',
539
+ id: segmentId(),
540
+ text: interim.text,
541
+ });
542
+ }
543
+
544
+ const transcription =
545
+ serverContent?.inputTranscription ?? message.inputTranscription;
546
+ if (transcription != null) {
547
+ if (transcription.languageCode != null) {
548
+ language = transcription.languageCode;
549
+ }
550
+ if (transcription.text) {
551
+ schedulePendingFinish();
552
+ // A real transcription delta supersedes interim fallback text.
553
+ latestInterim = '';
554
+ segmentBuffer += transcription.text;
555
+ controller.enqueue({
556
+ type: 'transcript-delta',
557
+ id: segmentId(),
558
+ delta: transcription.text,
559
+ });
560
+ }
561
+ if (transcription.finished === true) {
562
+ completeSegment();
563
+ }
564
+ }
565
+
566
+ if (serverContent?.turnComplete) {
567
+ completeSegment();
568
+ }
569
+
570
+ // `interactionStatus` idle (REQUIRES_ACTION in the EAP builds) is
571
+ // the definitive all-processing-complete signal: finish as soon as
572
+ // the input audio has ended.
573
+ const interactionStatus = serverContent?.interactionStatus;
574
+ if (
575
+ audioEnded &&
576
+ (interactionStatus === 'IDLE' ||
577
+ interactionStatus === 'REQUIRES_ACTION' ||
578
+ (serverContent?.turnComplete === true &&
579
+ interactionStatus == null))
580
+ ) {
581
+ finish();
582
+ }
583
+ },
584
+ onSocketError: () => {
585
+ finishWithError(
586
+ new Error(
587
+ 'Vertex Live transcription error.' +
588
+ (webSocket == null
589
+ ? ' Note: the native WebSocket implementation cannot send' +
590
+ ' the Authorization header required by Vertex. Pass a' +
591
+ " header-capable WebSocket implementation (e.g. the 'ws'" +
592
+ ' package) via createVertex({ webSocket }).'
593
+ : ''),
594
+ ),
595
+ );
596
+ },
597
+ onClose: ({ code, reason }) => {
598
+ if (finished) return;
599
+ // a close after the input audio ended means the server delivered
600
+ // everything it will deliver: finish with the accumulated text
601
+ if (audioEnded) {
602
+ finish();
603
+ return;
604
+ }
605
+ finishWithError(
606
+ new Error(
607
+ `Vertex Live transcription WebSocket closed unexpectedly before finishing` +
608
+ ` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
609
+ ),
610
+ );
611
+ },
612
+ });
613
+ },
614
+
615
+ cancel: () => {
616
+ if (finished) return;
617
+ finished = true;
618
+ cleanup();
619
+ },
620
+ });
621
+ }
622
+
623
+ /**
624
+ * Builds Google's `AudioTranscriptionConfig` from provider options; returns
625
+ * undefined when no options are set.
626
+ */
627
+ function buildAudioTranscriptionConfig(
628
+ options: GoogleVertexTranscriptionModelGeminiOptions | undefined,
629
+ ): Record<string, unknown> | undefined {
630
+ if (options == null) return undefined;
631
+ const config: Record<string, unknown> = {};
632
+ if (options.languageCodes != null) {
633
+ config.languageCodes = options.languageCodes;
634
+ }
635
+ if (options.customVocabulary != null) {
636
+ config.customVocabulary = options.customVocabulary;
637
+ }
638
+ if (options.wordTimestamp != null) {
639
+ config.wordTimestamp = options.wordTimestamp;
640
+ }
641
+ if (options.diarization != null) {
642
+ config.diarization = options.diarization;
643
+ }
644
+ if (options.mode != null) {
645
+ config.mode = options.mode;
646
+ }
647
+ return Object.keys(config).length > 0 ? config : undefined;
648
+ }
649
+
650
+ function validateLiveInputAudioFormat(
651
+ inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'],
652
+ ) {
653
+ if (
654
+ inputAudioFormat.type !== 'audio/pcm' ||
655
+ (inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000)
656
+ ) {
657
+ throw new InvalidArgumentError({
658
+ argument: 'inputAudioFormat',
659
+ message:
660
+ 'The Gemini Live transcription API only supports 16kHz 16-bit PCM input audio.',
661
+ });
662
+ }
663
+ }
664
+
665
+ /** Parses a Google duration offset such as `"1s"` or `"9.400s"` to seconds. */
666
+ function parseOffsetSeconds(
667
+ offset: string | undefined | null,
668
+ ): number | undefined {
669
+ if (offset == null) return undefined;
670
+ const parsed = Number.parseFloat(offset);
671
+ return Number.isFinite(parsed) ? parsed : undefined;
672
+ }
673
+
674
+ const googleVertexGeminiTranscriptionWordSchema = z.object({
675
+ word: z.string().nullish(),
676
+ startOffset: z.string().nullish(),
677
+ endOffset: z.string().nullish(),
678
+ });
679
+
680
+ const googleVertexGeminiTranscriptionResponseSchema = z.object({
681
+ candidates: z
682
+ .array(
683
+ z.object({
684
+ content: z
685
+ .object({
686
+ parts: z
687
+ .array(
688
+ z.object({
689
+ text: z.string().nullish(),
690
+ audioTranscription: z
691
+ .object({
692
+ text: z.string().nullish(),
693
+ languageCode: z.string().nullish(),
694
+ speakerLabel: z.string().nullish(),
695
+ words: z
696
+ .array(googleVertexGeminiTranscriptionWordSchema)
697
+ .nullish(),
698
+ })
699
+ .nullish(),
700
+ }),
701
+ )
702
+ .nullish(),
703
+ })
704
+ .nullish(),
705
+ }),
706
+ )
707
+ .nullish(),
708
+ usageMetadata: z.record(z.string(), z.unknown()).nullish(),
709
+ });