@ai-sdk/google 4.0.82 → 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.82",
3
+ "version": "4.0.84",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -35,8 +35,8 @@
35
35
  }
36
36
  },
37
37
  "dependencies": {
38
- "@ai-sdk/provider": "4.0.18",
39
- "@ai-sdk/provider-utils": "5.0.49"
38
+ "@ai-sdk/provider": "4.0.19",
39
+ "@ai-sdk/provider-utils": "5.0.50"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@ai-sdk/test-server": "2.0.2",
@@ -32,6 +32,12 @@ interface GoogleImageModelConfig {
32
32
  };
33
33
  }
34
34
 
35
+ const googleImageModelsWithFileInputSupport = new Set<string>([
36
+ 'gemini-2.5-flash-image',
37
+ 'gemini-3-pro-image-preview',
38
+ 'gemini-3.1-flash-image-preview',
39
+ ]);
40
+
35
41
  export class GoogleImageModel implements ImageModelV4 {
36
42
  readonly specificationVersion = 'v4';
37
43
 
@@ -56,6 +62,16 @@ export class GoogleImageModel implements ImageModelV4 {
56
62
  return 1;
57
63
  }
58
64
 
65
+ get supportsFileInputs(): boolean | undefined {
66
+ return googleImageModelsWithFileInputSupport.has(this.modelId)
67
+ ? true
68
+ : undefined;
69
+ }
70
+
71
+ get supportsMaskInputs(): boolean | undefined {
72
+ return this.supportsFileInputs === true ? false : undefined;
73
+ }
74
+
59
75
  get provider(): string {
60
76
  return this.config.provider;
61
77
  }
@@ -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) {
@@ -481,14 +567,24 @@ export function buildGoogleSessionConfig(
481
567
  setup.outputAudioTranscription = {};
482
568
  }
483
569
 
484
- // Default to the lowest-latency thinking level so a session on a
485
- // background-reasoning model works without provider options. Merged last so
486
- // it survives a raw `providerOptions.generationConfig`.
487
- const thinkingConfig =
570
+ // Background-reasoning models require a `thinkingLevel` or `thinkingBudget`.
571
+ // Default to the lowest-latency level when the effective thinking config (the
572
+ // typed option, else a raw `providerOptions.generationConfig.thinkingConfig`)
573
+ // sets neither. Merged last so it survives a raw
574
+ // `providerOptions.generationConfig`.
575
+ const rawGenerationConfig = restProviderOptions.generationConfig;
576
+ const explicitThinkingConfig =
488
577
  googleOptions?.thinkingConfig ??
489
- (isThinkingLiveModel(modelId)
490
- ? { thinkingLevel: 'low' as const }
578
+ (isRecord(rawGenerationConfig) &&
579
+ isRecord(rawGenerationConfig.thinkingConfig)
580
+ ? rawGenerationConfig.thinkingConfig
491
581
  : undefined);
582
+ const thinkingConfig =
583
+ isThinkingLiveModel(modelId) &&
584
+ explicitThinkingConfig?.thinkingLevel == null &&
585
+ explicitThinkingConfig?.thinkingBudget == null
586
+ ? { ...explicitThinkingConfig, thinkingLevel: 'low' as const }
587
+ : googleOptions?.thinkingConfig;
492
588
  const applyThinkingConfig = () => {
493
589
  if (thinkingConfig == null) return;
494
590
  const target = isRecord(setup.generationConfig)