@ai-sdk/google 4.0.25 → 4.0.26

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,612 @@
1
+ import {
2
+ InvalidArgumentError,
3
+ type Experimental_SpeechTranslationModelV4 as TranslationModelV4,
4
+ type Experimental_SpeechTranslationModelV4StreamOptions as SpeechTranslationModelV4StreamOptions,
5
+ type Experimental_SpeechTranslationModelV4StreamPart as SpeechTranslationModelV4StreamPart,
6
+ type Experimental_SpeechTranslationModelV4Usage as SpeechTranslationModelV4Usage,
7
+ type SharedV4Warning,
8
+ } from '@ai-sdk/provider';
9
+ import {
10
+ connectToWebSocket,
11
+ combineHeaders,
12
+ convertBase64ToUint8Array,
13
+ convertToBase64,
14
+ parseProviderOptions,
15
+ safeParseJSON,
16
+ serializeModelOptions,
17
+ WORKFLOW_DESERIALIZE,
18
+ WORKFLOW_SERIALIZE,
19
+ waitForWebSocketBufferDrain,
20
+ type WebSocketConnection,
21
+ type WebSocketConstructor,
22
+ type WebSocketLike,
23
+ } from '@ai-sdk/provider-utils';
24
+ import { getModelPath } from '../get-model-path';
25
+ import { getRealtimeWebSocketURL } from '../get-realtime-base-url';
26
+ import {
27
+ googleTranslationModelOptions,
28
+ type GoogleTranslationModelId,
29
+ type GoogleTranslationModelOptions,
30
+ } from './google-translation-model-options';
31
+
32
+ const liveWebSocketPath =
33
+ 'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
34
+
35
+ /**
36
+ * After the input audio has ended, finish after this much trailing output
37
+ * silence. Live Translation is continuous and does not emit turnComplete.
38
+ */
39
+ const defaultFinishGraceMs = 1000;
40
+ const googleLiveOutputAudioRate = 24000;
41
+ const pcm16SilenceAmplitudeThreshold = 128;
42
+
43
+ function getLiveWebSocketURL(baseURL: string, apiKey: string): URL {
44
+ const url = getRealtimeWebSocketURL(baseURL, liveWebSocketPath);
45
+ url.searchParams.set('key', apiKey);
46
+ return url;
47
+ }
48
+
49
+ type GoogleLiveTokensDetail = {
50
+ modality?: string;
51
+ tokenCount?: number;
52
+ };
53
+
54
+ type GoogleLiveServerMessage = {
55
+ setupComplete?: unknown;
56
+ serverContent?: {
57
+ modelTurn?: {
58
+ parts?: Array<{
59
+ inlineData?: { data?: string };
60
+ }>;
61
+ };
62
+ outputTranscription?: { text?: string };
63
+ inputTranscription?: { text?: string };
64
+ turnComplete?: boolean;
65
+ };
66
+ inputTranscription?: { text?: string };
67
+ usageMetadata?: {
68
+ promptTokensDetails?: GoogleLiveTokensDetail[];
69
+ responseTokensDetails?: GoogleLiveTokensDetail[];
70
+ };
71
+ error?: { message?: string };
72
+ };
73
+
74
+ export type GoogleTranslationModelConfig = {
75
+ provider: string;
76
+ baseURL: string;
77
+ headers: () => Record<string, string | undefined>;
78
+ webSocket?: WebSocketConstructor;
79
+ _internal?: {
80
+ currentDate?: () => Date;
81
+ finishGraceMs?: number;
82
+ };
83
+ };
84
+
85
+ export class GoogleTranslationModel implements TranslationModelV4 {
86
+ readonly specificationVersion = 'v4';
87
+ readonly modelId: GoogleTranslationModelId;
88
+
89
+ private readonly config: GoogleTranslationModelConfig;
90
+
91
+ static [WORKFLOW_SERIALIZE](model: GoogleTranslationModel) {
92
+ return serializeModelOptions({
93
+ modelId: model.modelId,
94
+ config: model.config,
95
+ });
96
+ }
97
+
98
+ static [WORKFLOW_DESERIALIZE](options: {
99
+ modelId: GoogleTranslationModelId;
100
+ config: GoogleTranslationModelConfig;
101
+ }) {
102
+ return new GoogleTranslationModel(options.modelId, options.config);
103
+ }
104
+
105
+ get provider(): string {
106
+ return this.config.provider;
107
+ }
108
+
109
+ constructor(
110
+ modelId: GoogleTranslationModelId,
111
+ config: GoogleTranslationModelConfig,
112
+ ) {
113
+ this.modelId = modelId;
114
+ this.config = config;
115
+ }
116
+
117
+ async doStream(
118
+ options: SpeechTranslationModelV4StreamOptions,
119
+ ): Promise<Awaited<ReturnType<TranslationModelV4['doStream']>>> {
120
+ if (options.targetLanguage == null) {
121
+ throw new InvalidArgumentError({
122
+ argument: 'targetLanguage',
123
+ message: `targetLanguage is required for translation model '${this.modelId}'.`,
124
+ });
125
+ }
126
+
127
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
128
+ const googleOptions = await parseProviderOptions({
129
+ provider: 'google',
130
+ providerOptions: options.providerOptions,
131
+ schema: googleTranslationModelOptions,
132
+ });
133
+ const warnings: SharedV4Warning[] = [];
134
+
135
+ validateGoogleTranslationInputAudioFormat(options.inputAudioFormat);
136
+
137
+ if (options.sourceLanguage != null) {
138
+ warnings.push({
139
+ type: 'unsupported',
140
+ feature: 'sourceLanguage',
141
+ details:
142
+ 'The Gemini Live translation API auto-detects the source language and does not accept a source language.',
143
+ });
144
+ }
145
+
146
+ if (options.outputAudioFormat != null) {
147
+ warnings.push({
148
+ type: 'unsupported',
149
+ feature: 'outputAudioFormat',
150
+ details:
151
+ 'The Gemini Live API always outputs 24kHz 16-bit PCM audio and does not accept an output audio format.',
152
+ });
153
+ }
154
+
155
+ const headers = combineHeaders(this.config.headers(), options.headers);
156
+ // last case-variant wins: combineHeaders keeps case-distinct keys and
157
+ // spreads per-call headers after configuration headers
158
+ let apiKey: string | undefined;
159
+ for (const [key, value] of Object.entries(headers)) {
160
+ if (key.toLowerCase() === 'x-goog-api-key' && value != null) {
161
+ apiKey = value;
162
+ }
163
+ }
164
+ if (apiKey == null) {
165
+ throw new Error(
166
+ 'Google Generative AI API key is required for streaming translation.',
167
+ );
168
+ }
169
+ const webSocketHeaders = Object.fromEntries(
170
+ Object.entries(headers).filter(
171
+ ([key]) => key.toLowerCase() !== 'x-goog-api-key',
172
+ ),
173
+ );
174
+
175
+ const setup = buildGoogleLiveTranslationSetup({
176
+ modelId: this.modelId,
177
+ targetLanguage: options.targetLanguage,
178
+ providerOptions: googleOptions,
179
+ });
180
+
181
+ return {
182
+ request: { body: setup },
183
+ response: {
184
+ timestamp: currentDate,
185
+ modelId: this.modelId,
186
+ },
187
+ stream: createGoogleLiveTranslationStream({
188
+ webSocket: this.config.webSocket,
189
+ url: getLiveWebSocketURL(this.config.baseURL, apiKey),
190
+ headers: webSocketHeaders,
191
+ setup,
192
+ inputAudioRate: options.inputAudioFormat.rate ?? 16000,
193
+ finishGraceMs:
194
+ this.config._internal?.finishGraceMs ?? defaultFinishGraceMs,
195
+ warnings,
196
+ audio: options.audio,
197
+ abortSignal: options.abortSignal,
198
+ includeRawChunks: options.includeRawChunks,
199
+ }),
200
+ };
201
+ }
202
+ }
203
+
204
+ function createGoogleLiveTranslationStream({
205
+ webSocket,
206
+ url,
207
+ headers,
208
+ setup,
209
+ inputAudioRate,
210
+ finishGraceMs,
211
+ warnings,
212
+ audio,
213
+ abortSignal,
214
+ includeRawChunks,
215
+ }: {
216
+ webSocket: WebSocketConstructor | undefined;
217
+ url: URL;
218
+ headers: Record<string, string | undefined>;
219
+ setup: unknown;
220
+ inputAudioRate: number;
221
+ finishGraceMs: number;
222
+ warnings: SharedV4Warning[];
223
+ audio: ReadableStream<Uint8Array | string>;
224
+ abortSignal: AbortSignal | undefined;
225
+ includeRawChunks: boolean | undefined;
226
+ }) {
227
+ let finished = false;
228
+ let cleanup: (closeCode?: number) => void = () => {};
229
+
230
+ return new ReadableStream<SpeechTranslationModelV4StreamPart>({
231
+ start: controller => {
232
+ let audioReader:
233
+ | ReadableStreamDefaultReader<Uint8Array | string>
234
+ | undefined;
235
+ let connection: WebSocketConnection | undefined;
236
+
237
+ // The Live API contract requires waiting for the `setupComplete`
238
+ // server message before sending realtime input: the audio send loop
239
+ // is gated on this promise.
240
+ let resolveSetupComplete!: () => void;
241
+ const setupComplete = new Promise<void>(resolve => {
242
+ resolveSetupComplete = resolve;
243
+ });
244
+
245
+ // Google Live messages carry no response/item IDs; a turn counter
246
+ // generates consistent synthetic IDs (like the realtime event mapper).
247
+ let turnCounter = 0;
248
+ // Transcription fragments arrive incrementally and are accumulated per
249
+ // turn; `turnComplete` finalizes the current turn.
250
+ let sourceText = '';
251
+ let sourceTurnBuffer = '';
252
+ let translationText = '';
253
+ let translationTurnBuffer = '';
254
+ let audioEnded = false;
255
+ let usage: SpeechTranslationModelV4Usage | undefined;
256
+
257
+ // Live Translation is a continuous pipeline rather than a turn-based
258
+ // model. After audioStreamEnd it keeps sending PCM silence indefinitely
259
+ // and does not emit turnComplete. Drain translated speech, then finish
260
+ // after enough trailing silence. Keep turnComplete handling as a
261
+ // fallback for compatible server implementations and test doubles.
262
+ let openTurn = false;
263
+ let sawTurnComplete = false;
264
+ let trailingSilenceMs = 0;
265
+ let finishTimer: ReturnType<typeof setTimeout> | undefined;
266
+
267
+ const itemId = () => `google-item-${turnCounter}`;
268
+
269
+ const cancelPendingFinish = () => {
270
+ if (finishTimer != null) {
271
+ clearTimeout(finishTimer);
272
+ finishTimer = undefined;
273
+ }
274
+ };
275
+
276
+ const schedulePendingFinish = () => {
277
+ if (finished || finishTimer != null) return;
278
+ finishTimer = setTimeout(() => {
279
+ finishTimer = undefined;
280
+ finish();
281
+ }, finishGraceMs);
282
+ };
283
+
284
+ const onTurnActivity = () => {
285
+ openTurn = true;
286
+ trailingSilenceMs = 0;
287
+ cancelPendingFinish();
288
+ };
289
+
290
+ cleanup = (closeCode?: number) => {
291
+ cancelPendingFinish();
292
+ if (audioReader != null) {
293
+ void audioReader.cancel().catch(() => {});
294
+ } else {
295
+ // pre-open failure or abort: cancel the caller's audio stream so an
296
+ // upstream producer piping into it does not hang:
297
+ void audio.cancel().catch(() => {});
298
+ }
299
+ connection?.close(closeCode);
300
+ };
301
+
302
+ const finishWithError = (error: unknown) => {
303
+ if (finished) return;
304
+ finished = true;
305
+ cleanup();
306
+ controller.error(error);
307
+ };
308
+
309
+ const finish = () => {
310
+ if (finished) return;
311
+ if (sourceTurnBuffer !== '' || translationTurnBuffer !== '') {
312
+ completeTurn();
313
+ }
314
+ finished = true;
315
+ controller.enqueue({
316
+ type: 'finish',
317
+ sourceText,
318
+ outputText: translationText,
319
+ usage,
320
+ });
321
+ controller.close();
322
+ cleanup(1000);
323
+ };
324
+
325
+ const completeTurn = () => {
326
+ if (sourceTurnBuffer !== '') {
327
+ controller.enqueue({
328
+ type: 'source-transcript-final',
329
+ id: itemId(),
330
+ text: sourceTurnBuffer,
331
+ });
332
+ sourceText += sourceTurnBuffer;
333
+ sourceTurnBuffer = '';
334
+ }
335
+ if (translationTurnBuffer !== '') {
336
+ controller.enqueue({
337
+ type: 'output-text-final',
338
+ id: itemId(),
339
+ text: translationTurnBuffer,
340
+ });
341
+ translationText += translationTurnBuffer;
342
+ translationTurnBuffer = '';
343
+ }
344
+ turnCounter++;
345
+ };
346
+
347
+ const sendAudio = async (socket: WebSocketLike) => {
348
+ audioReader = audio.getReader();
349
+ try {
350
+ while (true) {
351
+ const { done, value } = await audioReader.read();
352
+ if (done || finished) break;
353
+ socket.send(
354
+ JSON.stringify({
355
+ realtimeInput: {
356
+ audio: {
357
+ data: convertToBase64(value),
358
+ mimeType: `audio/pcm;rate=${inputAudioRate}`,
359
+ },
360
+ },
361
+ }),
362
+ );
363
+ // backpressure: pause reads while the socket buffer is full
364
+ await waitForWebSocketBufferDrain(socket);
365
+ }
366
+ } finally {
367
+ audioReader.releaseLock();
368
+ // unlocked again: cleanup must cancel `audio`, not the reader
369
+ audioReader = undefined;
370
+ }
371
+ if (!finished) {
372
+ socket.send(
373
+ JSON.stringify({ realtimeInput: { audioStreamEnd: true } }),
374
+ );
375
+ audioEnded = true;
376
+ // a turnComplete already received after the final audio chunk
377
+ // satisfies the finish condition:
378
+ if (sawTurnComplete && !openTurn) {
379
+ schedulePendingFinish();
380
+ }
381
+ }
382
+ };
383
+
384
+ connection = connectToWebSocket({
385
+ url,
386
+ headers,
387
+ webSocket,
388
+ abortSignal,
389
+ onAbort: finishWithError,
390
+ onProcessingError: finishWithError,
391
+ onOpen: socket => {
392
+ controller.enqueue({ type: 'stream-start', warnings });
393
+ socket.send(JSON.stringify({ setup }));
394
+ // audio may only be sent after the server acknowledged the setup:
395
+ void setupComplete
396
+ .then(() => (finished ? undefined : sendAudio(socket)))
397
+ .catch(finishWithError);
398
+ },
399
+ onMessageText: async text => {
400
+ if (finished) return;
401
+ const parsed = await safeParseJSON({ text });
402
+ if (!parsed.success) return;
403
+ const message = parsed.value as GoogleLiveServerMessage;
404
+
405
+ if (includeRawChunks) {
406
+ controller.enqueue({ type: 'raw', rawValue: message });
407
+ }
408
+
409
+ if (message.setupComplete != null) {
410
+ resolveSetupComplete();
411
+ }
412
+
413
+ if (message.usageMetadata != null) {
414
+ usage = accumulateGoogleLiveUsage(usage, message.usageMetadata);
415
+ }
416
+
417
+ if (message.error != null) {
418
+ finishWithError(
419
+ new Error(message.error.message ?? 'Google Live API error'),
420
+ );
421
+ return;
422
+ }
423
+
424
+ const inputTranscriptionText =
425
+ message.serverContent?.inputTranscription?.text ??
426
+ message.inputTranscription?.text;
427
+ if (inputTranscriptionText) {
428
+ onTurnActivity();
429
+ sourceTurnBuffer += inputTranscriptionText;
430
+ controller.enqueue({
431
+ type: 'source-transcript-delta',
432
+ id: itemId(),
433
+ delta: inputTranscriptionText,
434
+ });
435
+ }
436
+
437
+ const serverContent = message.serverContent;
438
+ if (serverContent == null) {
439
+ return;
440
+ }
441
+
442
+ for (const part of serverContent.modelTurn?.parts ?? []) {
443
+ if (part.inlineData?.data) {
444
+ controller.enqueue({
445
+ type: 'audio',
446
+ id: itemId(),
447
+ audio: part.inlineData.data,
448
+ });
449
+
450
+ const silenceDurationMs = getPcm16SilenceDurationMs(
451
+ part.inlineData.data,
452
+ );
453
+ if (audioEnded && silenceDurationMs != null) {
454
+ trailingSilenceMs += silenceDurationMs;
455
+ if (trailingSilenceMs >= finishGraceMs) {
456
+ finish();
457
+ return;
458
+ }
459
+ } else {
460
+ onTurnActivity();
461
+ }
462
+ }
463
+ }
464
+
465
+ if (serverContent.outputTranscription?.text) {
466
+ onTurnActivity();
467
+ translationTurnBuffer += serverContent.outputTranscription.text;
468
+ controller.enqueue({
469
+ type: 'output-text-delta',
470
+ id: itemId(),
471
+ delta: serverContent.outputTranscription.text,
472
+ });
473
+ }
474
+
475
+ if (serverContent.turnComplete) {
476
+ completeTurn();
477
+ openTurn = false;
478
+ sawTurnComplete = true;
479
+ if (audioEnded) {
480
+ schedulePendingFinish();
481
+ }
482
+ }
483
+ },
484
+ onSocketError: () => {
485
+ finishWithError(new Error('Google Live translation error'));
486
+ },
487
+ onClose: ({ code, reason }) => {
488
+ if (finished) return;
489
+ // a close while a finish is pending confirms that no further turn
490
+ // activity follows:
491
+ if (finishTimer != null) {
492
+ finish();
493
+ return;
494
+ }
495
+ // a close before the finish condition was reached is an abnormal
496
+ // termination: surface the close diagnostics
497
+ finishWithError(
498
+ new Error(
499
+ `Google Live translation WebSocket closed unexpectedly before finishing` +
500
+ ` (code ${code ?? 'unknown'}${reason ? `, reason: ${reason}` : ''}).`,
501
+ ),
502
+ );
503
+ },
504
+ });
505
+ },
506
+
507
+ cancel: () => {
508
+ if (finished) return;
509
+ finished = true;
510
+ cleanup();
511
+ },
512
+ });
513
+ }
514
+
515
+ function accumulateGoogleLiveUsage(
516
+ usage: SpeechTranslationModelV4Usage | undefined,
517
+ usageMetadata: {
518
+ promptTokensDetails?: GoogleLiveTokensDetail[];
519
+ responseTokensDetails?: GoogleLiveTokensDetail[];
520
+ },
521
+ ): SpeechTranslationModelV4Usage | undefined {
522
+ let inputAudioTokens = usage?.inputAudioTokens;
523
+ let outputAudioTokens = usage?.outputAudioTokens;
524
+
525
+ // Live Translation emits periodic usage deltas. Its TEXT prompt detail is
526
+ // internal translation context (the public input is audio-only), so only
527
+ // aggregate the billable input/output audio modalities.
528
+ for (const detail of usageMetadata.promptTokensDetails ?? []) {
529
+ if (detail.modality === 'AUDIO' && detail.tokenCount != null) {
530
+ inputAudioTokens = (inputAudioTokens ?? 0) + detail.tokenCount;
531
+ }
532
+ }
533
+
534
+ for (const detail of usageMetadata.responseTokensDetails ?? []) {
535
+ if (detail.modality === 'AUDIO' && detail.tokenCount != null) {
536
+ outputAudioTokens = (outputAudioTokens ?? 0) + detail.tokenCount;
537
+ }
538
+ }
539
+
540
+ if (inputAudioTokens == null && outputAudioTokens == null) {
541
+ return usage;
542
+ }
543
+
544
+ return {
545
+ ...usage,
546
+ ...(inputAudioTokens != null ? { inputAudioTokens } : {}),
547
+ ...(outputAudioTokens != null ? { outputAudioTokens } : {}),
548
+ };
549
+ }
550
+
551
+ function getPcm16SilenceDurationMs(audio: string): number | undefined {
552
+ let bytes: Uint8Array;
553
+ try {
554
+ bytes = convertBase64ToUint8Array(audio);
555
+ } catch {
556
+ return undefined;
557
+ }
558
+
559
+ if (bytes.byteLength < 2) {
560
+ return undefined;
561
+ }
562
+
563
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
564
+ const sampleCount = Math.floor(bytes.byteLength / 2);
565
+ for (let i = 0; i < sampleCount; i++) {
566
+ if (Math.abs(view.getInt16(i * 2, true)) > pcm16SilenceAmplitudeThreshold) {
567
+ return undefined;
568
+ }
569
+ }
570
+
571
+ return (sampleCount / googleLiveOutputAudioRate) * 1000;
572
+ }
573
+
574
+ function buildGoogleLiveTranslationSetup({
575
+ modelId,
576
+ targetLanguage,
577
+ providerOptions,
578
+ }: {
579
+ modelId: string;
580
+ targetLanguage: string;
581
+ providerOptions: GoogleTranslationModelOptions | undefined;
582
+ }) {
583
+ return {
584
+ model: getModelPath(modelId),
585
+ generationConfig: {
586
+ responseModalities: ['AUDIO'],
587
+ translationConfig: {
588
+ targetLanguageCode: targetLanguage,
589
+ ...(providerOptions?.echoTargetLanguage != null
590
+ ? { echoTargetLanguage: providerOptions.echoTargetLanguage }
591
+ : {}),
592
+ },
593
+ },
594
+ inputAudioTranscription: {},
595
+ outputAudioTranscription: {},
596
+ };
597
+ }
598
+
599
+ function validateGoogleTranslationInputAudioFormat(
600
+ inputAudioFormat: SpeechTranslationModelV4StreamOptions['inputAudioFormat'],
601
+ ) {
602
+ if (
603
+ inputAudioFormat.type !== 'audio/pcm' ||
604
+ (inputAudioFormat.rate != null && inputAudioFormat.rate !== 16000)
605
+ ) {
606
+ throw new InvalidArgumentError({
607
+ argument: 'inputAudioFormat',
608
+ message:
609
+ 'The Gemini Live translation API only supports 16kHz 16-bit PCM input audio.',
610
+ });
611
+ }
612
+ }