@ai-sdk/google 4.0.83 → 4.0.84

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.
@@ -1147,6 +1147,18 @@ emitted as custom events. Inspect `rawType` for `goAway`,
1147
1147
  `sessionResumptionUpdate`, and `generationComplete` and use the event's `raw`
1148
1148
  payload for provider-specific details.
1149
1149
 
1150
+ With input audio transcription enabled, `parseServerEvent` emits
1151
+ `input-transcription-completed` events with a stable `itemId` for each user
1152
+ utterance. Each `transcript` contains the accumulated text so far. If you consume
1153
+ these events directly, replace the text for that `itemId` with `transcript` on
1154
+ each event. The realtime session handles this automatically.
1155
+
1156
+ Google's `finished` transcription marker ends an utterance even when the marker
1157
+ contains no text. For payloads without this marker, response completion and
1158
+ interruption provide fallback boundaries. These fallback boundaries cannot
1159
+ unambiguously associate all delayed transcription fragments. Input item IDs use
1160
+ their own sequence and need not match the model's response IDs.
1161
+
1150
1162
  ### Live Translation
1151
1163
 
1152
1164
  Use `gemini-3.5-live-translate-preview` for low-latency speech-to-speech
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/google",
3
- "version": "4.0.83",
3
+ "version": "4.0.84",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -25,8 +25,8 @@ type GoogleRealtimeServerContent = {
25
25
  text?: string;
26
26
  }>;
27
27
  };
28
- outputTranscription?: { text?: string };
29
- inputTranscription?: { text?: string };
28
+ outputTranscription?: { text?: string; finished?: boolean };
29
+ inputTranscription?: { text?: string; finished?: boolean };
30
30
  turnComplete?: boolean;
31
31
  waitingForInput?: boolean;
32
32
  };
@@ -38,7 +38,7 @@ type GoogleRealtimeWireEvent = {
38
38
  };
39
39
  toolCallCancellation?: unknown;
40
40
  serverContent?: GoogleRealtimeServerContent;
41
- inputTranscription?: { text?: string };
41
+ inputTranscription?: { text?: string; finished?: boolean };
42
42
  goAway?: { timeLeft?: string };
43
43
  sessionResumptionUpdate?: {
44
44
  newHandle?: string;
@@ -56,6 +56,11 @@ type GoogleRealtimeWireEvent = {
56
56
  */
57
57
  export class GoogleRealtimeEventMapper {
58
58
  private turnCounter = 0;
59
+ private inputTranscriptionCounter = 0;
60
+ private inputTranscriptionBuffer = '';
61
+ private inputTranscriptionBoundary: 'none' | 'interrupted' | 'turn-complete' =
62
+ 'none';
63
+ private preserveInputTranscriptionAcrossTurnComplete = false;
59
64
  private hasAudio = false;
60
65
  private hasText = false;
61
66
  private hasTranscript = false;
@@ -71,12 +76,11 @@ export class GoogleRealtimeEventMapper {
71
76
  }
72
77
 
73
78
  /**
74
- * Rolls over to the next turn lazily, only once new model content actually
75
- * arrives. `turnComplete` merely marks the current turn closed; the counter
76
- * is not advanced until the next response begins. This keeps a transcript
77
- * that arrives shortly after `turnComplete` attached to the turn it belongs
78
- * to, since Google delivers transcription independently with no guaranteed
79
- * ordering relative to `turnComplete`.
79
+ * Rolls over to the next turn lazily, once model content for the next turn
80
+ * arrives. `turnComplete` merely marks the current turn closed.
81
+ * This keeps an output transcript that arrives shortly after `turnComplete`
82
+ * attached to the turn it belongs to, since Google delivers transcription
83
+ * independently with no guaranteed ordering relative to `turnComplete`.
80
84
  */
81
85
  private beginTurnIfClosed(): void {
82
86
  if (!this.turnClosed) return;
@@ -85,6 +89,63 @@ export class GoogleRealtimeEventMapper {
85
89
  this.hasText = false;
86
90
  this.hasTranscript = false;
87
91
  this.turnClosed = false;
92
+ // Once the next response starts, its completion must delimit its own
93
+ // input even if the interrupted response never sent a turnComplete.
94
+ this.preserveInputTranscriptionAcrossTurnComplete = false;
95
+ }
96
+
97
+ // Google streams input transcription as a sequence of non-accumulating
98
+ // fragments for a single user utterance (e.g. "The quick brown fox",
99
+ // " jumps over the", " lazy dog."). Concatenate consecutive fragments into
100
+ // one running transcript under a stable synthetic id so the realtime reducer
101
+ // (which overwrites the message for a given id) surfaces them as a single
102
+ // coherent user message rather than one message per fragment. Google's
103
+ // `finished` signal is the authoritative utterance boundary. For older
104
+ // payloads without that signal, response boundaries remain a fallback.
105
+ //
106
+ // Input transcriptions can arrive independently of response turn events, so
107
+ // their synthetic IDs use a sequence independent of `turnCounter`.
108
+ private accumulateInputTranscription({
109
+ text,
110
+ finished,
111
+ }: {
112
+ text?: string;
113
+ finished?: boolean;
114
+ }): { itemId: string; transcript: string } | undefined {
115
+ // Google can send `finished` separately from text. Process it even when
116
+ // text is absent or empty, without emitting an empty user message.
117
+ if (!text) {
118
+ if (finished === true) {
119
+ this.finishInputTranscription();
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ const hasFinishedSignal = finished != null;
125
+ if (
126
+ this.inputTranscriptionBuffer !== '' &&
127
+ (this.inputTranscriptionBoundary === 'interrupted' ||
128
+ (this.inputTranscriptionBoundary === 'turn-complete' &&
129
+ !hasFinishedSignal))
130
+ ) {
131
+ this.finishInputTranscription();
132
+ }
133
+ this.inputTranscriptionBoundary = 'none';
134
+ this.inputTranscriptionBuffer += text;
135
+ const result = {
136
+ itemId: `google-input-${this.inputTranscriptionCounter}`,
137
+ transcript: this.inputTranscriptionBuffer,
138
+ };
139
+ if (finished === true) {
140
+ this.finishInputTranscription();
141
+ }
142
+ return result;
143
+ }
144
+
145
+ private finishInputTranscription(): void {
146
+ if (this.inputTranscriptionBuffer === '') return;
147
+ this.inputTranscriptionCounter++;
148
+ this.inputTranscriptionBuffer = '';
88
149
  }
89
150
 
90
151
  parseServerEvent(
@@ -151,13 +212,17 @@ export class GoogleRealtimeEventMapper {
151
212
  return this.parseServerContent(data.serverContent, raw);
152
213
  }
153
214
 
154
- if (data.inputTranscription?.text != null) {
155
- return {
156
- type: 'input-transcription-completed',
157
- itemId: `google-input-${this.turnCounter}`,
158
- transcript: data.inputTranscription.text,
159
- raw,
160
- };
215
+ if (data.inputTranscription != null) {
216
+ const transcription = this.accumulateInputTranscription(
217
+ data.inputTranscription,
218
+ );
219
+ if (transcription != null) {
220
+ return {
221
+ type: 'input-transcription-completed',
222
+ ...transcription,
223
+ raw,
224
+ };
225
+ }
161
226
  }
162
227
 
163
228
  return { type: 'custom', rawType: String(Object.keys(data)[0]), raw };
@@ -170,6 +235,12 @@ export class GoogleRealtimeEventMapper {
170
235
  const events: RealtimeModelV4ServerEvent[] = [];
171
236
 
172
237
  if (serverContent.interrupted) {
238
+ // A barge-in ends the current user utterance; the next input
239
+ // transcription fragment belongs to a fresh utterance. The interrupted
240
+ // response's trailing turnComplete must not split that new utterance.
241
+ this.inputTranscriptionBoundary = 'interrupted';
242
+ this.preserveInputTranscriptionAcrossTurnComplete = true;
243
+ this.turnClosed = true;
173
244
  events.push({
174
245
  type: 'speech-started',
175
246
  raw,
@@ -214,13 +285,17 @@ export class GoogleRealtimeEventMapper {
214
285
  });
215
286
  }
216
287
 
217
- if (serverContent.inputTranscription?.text) {
218
- events.push({
219
- type: 'input-transcription-completed',
220
- itemId: `google-input-${this.turnCounter}`,
221
- transcript: serverContent.inputTranscription.text,
222
- raw,
223
- });
288
+ if (serverContent.inputTranscription != null) {
289
+ const transcription = this.accumulateInputTranscription(
290
+ serverContent.inputTranscription,
291
+ );
292
+ if (transcription != null) {
293
+ events.push({
294
+ type: 'input-transcription-completed',
295
+ ...transcription,
296
+ raw,
297
+ });
298
+ }
224
299
  }
225
300
 
226
301
  // `generationComplete` means generation has stopped, but playback and the
@@ -292,6 +367,17 @@ export class GoogleRealtimeEventMapper {
292
367
  // Mark the turn closed but defer advancing the counter until the next
293
368
  // response actually begins (see `beginTurnIfClosed`).
294
369
  this.turnClosed = true;
370
+ if (this.preserveInputTranscriptionAcrossTurnComplete) {
371
+ this.preserveInputTranscriptionAcrossTurnComplete = false;
372
+ } else {
373
+ // For payloads without `finished`, the completed response remains the
374
+ // fallback signal that the next transcription is a new utterance.
375
+ // A transcription carrying `finished` can still arrive late and close
376
+ // the preceding utterance after this response boundary.
377
+ if (this.inputTranscriptionBoundary !== 'interrupted') {
378
+ this.inputTranscriptionBoundary = 'turn-complete';
379
+ }
380
+ }
295
381
  }
296
382
 
297
383
  if (events.length === 0) {