@ai-sdk/google 4.0.111 → 4.0.113

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.
@@ -4,6 +4,7 @@ import type {
4
4
  Experimental_RealtimeModelV4FunctionCallOutput as RealtimeModelV4FunctionCallOutput,
5
5
  Experimental_RealtimeModelV4ServerEvent as RealtimeModelV4ServerEvent,
6
6
  Experimental_RealtimeModelV4SessionConfig as RealtimeModelV4SessionConfig,
7
+ Experimental_RealtimeModelV4Usage as RealtimeModelV4Usage,
7
8
  } from '@ai-sdk/provider';
8
9
  import { isRecord, safeParseJSON } from '@ai-sdk/provider-utils';
9
10
  import { getModelPath } from '../get-model-path';
@@ -31,6 +32,22 @@ type GoogleRealtimeServerContent = {
31
32
  waitingForInput?: boolean;
32
33
  };
33
34
 
35
+ type GoogleRealtimeTokenDetail = {
36
+ modality?: string;
37
+ tokenCount?: number;
38
+ };
39
+
40
+ type GoogleRealtimeUsageMetadata = {
41
+ promptTokenCount?: number;
42
+ responseTokenCount?: number;
43
+ totalTokenCount?: number;
44
+ toolUsePromptTokenCount?: number;
45
+ thoughtsTokenCount?: number;
46
+ cachedContentTokenCount?: number;
47
+ promptTokensDetails?: GoogleRealtimeTokenDetail[];
48
+ responseTokensDetails?: GoogleRealtimeTokenDetail[];
49
+ };
50
+
34
51
  type GoogleRealtimeWireEvent = {
35
52
  setupComplete?: unknown;
36
53
  toolCall?: {
@@ -38,6 +55,7 @@ type GoogleRealtimeWireEvent = {
38
55
  };
39
56
  toolCallCancellation?: unknown;
40
57
  serverContent?: GoogleRealtimeServerContent;
58
+ usageMetadata?: GoogleRealtimeUsageMetadata;
41
59
  inputTranscription?: { text?: string; finished?: boolean };
42
60
  goAway?: { timeLeft?: string };
43
61
  sessionResumptionUpdate?: {
@@ -150,6 +168,37 @@ export class GoogleRealtimeEventMapper {
150
168
 
151
169
  parseServerEvent(
152
170
  raw: unknown,
171
+ ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
172
+ const events = this.parseEvent(raw);
173
+ const usage = mapGoogleUsage(
174
+ (raw as GoogleRealtimeWireEvent).usageMetadata,
175
+ );
176
+ if (usage == null) return events;
177
+ const list = Array.isArray(events) ? events : [events];
178
+ // Only a shared wire frame establishes an association with a completion.
179
+ if (list.some(event => event.type === 'response-done')) {
180
+ const mapped = list.map(event =>
181
+ event.type === 'response-done' ? { ...event, usage } : event,
182
+ );
183
+ return Array.isArray(events) ? mapped : mapped[0];
184
+ }
185
+ const update: RealtimeModelV4ServerEvent = {
186
+ type: 'custom',
187
+ rawType: 'usageMetadata',
188
+ usage,
189
+ raw,
190
+ };
191
+ if (
192
+ list.length === 1 &&
193
+ list[0].type === 'custom' &&
194
+ list[0].rawType === 'usageMetadata'
195
+ )
196
+ return update;
197
+ return [...list, update];
198
+ }
199
+
200
+ private parseEvent(
201
+ raw: unknown,
153
202
  ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
154
203
  const data = raw as GoogleRealtimeWireEvent;
155
204
 
@@ -448,6 +497,80 @@ export class GoogleRealtimeEventMapper {
448
497
  }
449
498
  }
450
499
 
500
+ /**
501
+ * Maps Gemini Live `usageMetadata` to normalized usage. Google reports usage
502
+ * per modality in `promptTokensDetails` / `responseTokensDetails`.
503
+ *
504
+ * Field names are Live-API-specific: the Live `UsageMetadata` uses
505
+ * `responseTokenCount` / `responseTokensDetails` for output, NOT the
506
+ * `candidatesTokenCount` / `candidatesTokensDetails` of the regular
507
+ * `generateContent` response (see google-language-model.ts). Do not "align"
508
+ * these to `candidates*` — they are different wire shapes.
509
+ *
510
+ * Gross totals preserve provider counts even when modality details are absent
511
+ * or contain modalities without a dedicated normalized bucket. Never guess a
512
+ * text/audio split from those totals.
513
+ */
514
+ function mapGoogleUsage(
515
+ usageMetadata: GoogleRealtimeUsageMetadata | undefined,
516
+ ): RealtimeModelV4Usage | undefined {
517
+ if (usageMetadata == null) return undefined;
518
+
519
+ const input = sumByModality(usageMetadata.promptTokensDetails);
520
+ const output = sumByModality(usageMetadata.responseTokensDetails);
521
+
522
+ return compactUsage({
523
+ inputTokens:
524
+ usageMetadata.promptTokenCount != null
525
+ ? usageMetadata.promptTokenCount +
526
+ (usageMetadata.toolUsePromptTokenCount ?? 0)
527
+ : undefined,
528
+ outputTokens:
529
+ usageMetadata.responseTokenCount != null
530
+ ? usageMetadata.responseTokenCount +
531
+ (usageMetadata.thoughtsTokenCount ?? 0)
532
+ : undefined,
533
+ cachedInputTokens: usageMetadata.cachedContentTokenCount,
534
+ inputTextTokens: input.text,
535
+ inputAudioTokens: input.audio,
536
+ outputTextTokens: output.text,
537
+ outputAudioTokens: output.audio,
538
+ });
539
+ }
540
+
541
+ /** Aggregate Gemini per-modality token details into text/audio buckets. */
542
+ function sumByModality(details: GoogleRealtimeTokenDetail[] | undefined): {
543
+ text?: number;
544
+ audio?: number;
545
+ } {
546
+ if (details == null) return {};
547
+ const totals: { text?: number; audio?: number } = {};
548
+ for (const detail of details) {
549
+ const count = detail.tokenCount;
550
+ if (count == null) continue;
551
+ const modality = detail.modality?.toUpperCase();
552
+ if (modality === 'AUDIO') {
553
+ totals.audio = (totals.audio ?? 0) + count;
554
+ } else if (modality === 'TEXT') {
555
+ totals.text = (totals.text ?? 0) + count;
556
+ }
557
+ }
558
+ return totals;
559
+ }
560
+
561
+ /** Drop `undefined` fields; return `undefined` when nothing is observable. */
562
+ function compactUsage(
563
+ usage: RealtimeModelV4Usage,
564
+ ): RealtimeModelV4Usage | undefined {
565
+ const result: RealtimeModelV4Usage = {};
566
+ for (const [key, value] of Object.entries(usage)) {
567
+ if (value != null) {
568
+ result[key as keyof RealtimeModelV4Usage] = value;
569
+ }
570
+ }
571
+ return Object.keys(result).length > 0 ? result : undefined;
572
+ }
573
+
451
574
  /**
452
575
  * The value Gemini accepts for `functionResponse.response`.
453
576
  *
@@ -3,11 +3,15 @@ import type {
3
3
  Experimental_RealtimeModelV4ClientEvent as RealtimeModelV4ClientEvent,
4
4
  Experimental_RealtimeModelV4ClientSecretOptions as RealtimeModelV4ClientSecretOptions,
5
5
  Experimental_RealtimeModelV4ClientSecretResult as RealtimeModelV4ClientSecretResult,
6
+ Experimental_RealtimeModelV4ServerConnection as RealtimeModelV4ServerConnection,
6
7
  Experimental_RealtimeModelV4ServerEvent as RealtimeModelV4ServerEvent,
7
8
  Experimental_RealtimeModelV4SessionConfig as RealtimeModelV4SessionConfig,
9
+ Experimental_RealtimeModelV4SessionIntent as RealtimeModelV4SessionIntent,
8
10
  } from '@ai-sdk/provider';
9
11
  import {
10
12
  resolve,
13
+ resolveWith,
14
+ removeUndefinedEntries,
11
15
  type Resolvable,
12
16
  type FetchFunction,
13
17
  } from '@ai-sdk/provider-utils';
@@ -23,6 +27,13 @@ import {
23
27
  const realtimeWebSocketPath =
24
28
  'google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContentConstrained';
25
29
 
30
+ // Server-side connections use the unconstrained Bidi endpoint: a trusted server
31
+ // holds the API key and sends the session `setup` over the socket, rather than
32
+ // baking it into an ephemeral token at mint time (which the constrained
33
+ // endpoint requires).
34
+ const realtimeServerWebSocketPath =
35
+ 'google.ai.generativelanguage.v1beta.GenerativeService.BidiGenerateContent';
36
+
26
37
  function getAuthTokensURL(baseURL: string): string {
27
38
  const url = getRealtimeBaseURL(baseURL);
28
39
  url.pathname = `${url.pathname.replace(/\/$/, '')}/v1alpha/auth_tokens`;
@@ -33,6 +44,13 @@ function getWebSocketURL(baseURL: string): string {
33
44
  return getRealtimeWebSocketURL(baseURL, realtimeWebSocketPath).toString();
34
45
  }
35
46
 
47
+ function getServerWebSocketURL(baseURL: string): string {
48
+ const url = getRealtimeBaseURL(baseURL);
49
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
50
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/ws/${realtimeServerWebSocketPath}`;
51
+ return url.toString();
52
+ }
53
+
36
54
  export type GoogleRealtimeModelConfig = {
37
55
  provider: string;
38
56
  baseURL: string;
@@ -42,6 +60,11 @@ export type GoogleRealtimeModelConfig = {
42
60
 
43
61
  export class GoogleRealtimeModel implements RealtimeModelV4 {
44
62
  readonly specificationVersion = 'v4' as const;
63
+ readonly capabilities = {
64
+ conversation: 'turn-based',
65
+ transports: ['websocket'],
66
+ connections: ['client-secret-websocket', 'server-websocket'],
67
+ } as const;
45
68
  readonly provider: string;
46
69
  readonly modelId: string;
47
70
 
@@ -132,21 +155,56 @@ export class GoogleRealtimeModel implements RealtimeModelV4 {
132
155
  };
133
156
  }
134
157
 
158
+ getServerWebSocketConfig(options?: {
159
+ intent?: RealtimeModelV4SessionIntent;
160
+ }):
161
+ | RealtimeModelV4ServerConnection
162
+ | PromiseLike<RealtimeModelV4ServerConnection> {
163
+ // Gemini Live exposes only the conversational endpoint; fail fast instead
164
+ // of silently downgrading unsupported intents.
165
+ this.assertConversationIntent(options);
166
+ return resolveWith(this.config.headers, headers => ({
167
+ url: getServerWebSocketURL(this.config.baseURL),
168
+ headers: removeUndefinedEntries(headers),
169
+ }));
170
+ }
171
+
172
+ private assertConversationIntent(options?: {
173
+ intent?: RealtimeModelV4SessionIntent;
174
+ }): void {
175
+ if (options?.intent != null && options.intent !== 'conversation') {
176
+ throw new Error(
177
+ `Google realtime does not support the '${options.intent}' session intent.`,
178
+ );
179
+ }
180
+ }
181
+
135
182
  parseServerEvent(
136
183
  raw: unknown,
137
184
  ): RealtimeModelV4ServerEvent | RealtimeModelV4ServerEvent[] {
138
185
  return this.mapper.parseServerEvent(raw);
139
186
  }
140
187
 
188
+ createServerEventParser(): ReturnType<
189
+ NonNullable<RealtimeModelV4['createServerEventParser']>
190
+ > {
191
+ const mapper = new GoogleRealtimeEventMapper();
192
+ return raw => mapper.parseServerEvent(raw);
193
+ }
194
+
141
195
  serializeClientEvent(
142
196
  event: RealtimeModelV4ClientEvent,
197
+ options?: { intent?: RealtimeModelV4SessionIntent },
143
198
  ): ReturnType<RealtimeModelV4['serializeClientEvent']> {
199
+ this.assertConversationIntent(options);
144
200
  return this.mapper.serializeClientEvent(event, this.modelId);
145
201
  }
146
202
 
147
203
  buildSessionConfig(
148
204
  config: RealtimeModelV4SessionConfig,
205
+ options?: { intent?: RealtimeModelV4SessionIntent },
149
206
  ): Record<string, unknown> {
207
+ this.assertConversationIntent(options);
150
208
  return buildGoogleSessionConfig(config, this.modelId);
151
209
  }
152
210
  }