@ai-sdk/google 3.0.71 → 3.0.72

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/google",
3
- "version": "3.0.71",
3
+ "version": "3.0.72",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -36,8 +36,8 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@ai-sdk/provider": "3.0.10",
40
- "@ai-sdk/provider-utils": "4.0.27"
39
+ "@ai-sdk/provider-utils": "4.0.27",
40
+ "@ai-sdk/provider": "3.0.10"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "20.17.24",
@@ -0,0 +1,60 @@
1
+ import {
2
+ combineHeaders,
3
+ getRuntimeEnvironmentUserAgent,
4
+ withUserAgentSuffix,
5
+ type FetchFunction,
6
+ } from '@ai-sdk/provider-utils';
7
+
8
+ const getOriginalFetch = () => globalThis.fetch;
9
+
10
+ /**
11
+ * Best-effort `POST /interactions/{id}/cancel` to stop a background interaction
12
+ * on Google's side after the caller has aborted locally. Errors and non-2xx
13
+ * responses are swallowed so a cancel failure cannot mask the original abort.
14
+ *
15
+ * Skips the request entirely if `interactionId` is missing/empty -- e.g. when
16
+ * the interaction was created with `store: false` and the API did not return an
17
+ * id.
18
+ */
19
+ export async function cancelGoogleInteraction({
20
+ baseURL,
21
+ interactionId,
22
+ headers,
23
+ fetch = getOriginalFetch(),
24
+ }: {
25
+ baseURL: string;
26
+ interactionId: string | null | undefined;
27
+ headers: Record<string, string | undefined>;
28
+ fetch?: FetchFunction;
29
+ }): Promise<void> {
30
+ if (interactionId == null || interactionId.length === 0) {
31
+ return;
32
+ }
33
+
34
+ const url = `${baseURL}/interactions/${encodeURIComponent(interactionId)}/cancel`;
35
+
36
+ try {
37
+ const response = await fetch(url, {
38
+ method: 'POST',
39
+ headers: withUserAgentSuffix(
40
+ combineHeaders({ 'Content-Type': 'application/json' }, headers),
41
+ getRuntimeEnvironmentUserAgent(),
42
+ ),
43
+ body: '{}',
44
+ });
45
+
46
+ /*
47
+ * Drain the body so undici/Node can return the connection to the pool.
48
+ * Errors (e.g. non-2xx, network failure) are intentionally ignored: this
49
+ * is a best-effort cleanup and must not throw past the caller, which is
50
+ * already handling an aborted/failed run.
51
+ */
52
+ try {
53
+ await response.text();
54
+ } catch {
55
+ // ignore
56
+ }
57
+ } catch {
58
+ // ignore -- cancel is best-effort
59
+ }
60
+ }
@@ -44,6 +44,7 @@ import {
44
44
  pollGoogleInteractionUntilTerminal,
45
45
  } from './poll-google-interactions';
46
46
  import { prepareGoogleInteractionsTools } from './prepare-google-interactions-tools';
47
+ import { streamGoogleInteractionEvents } from './stream-google-interactions';
47
48
  import { synthesizeGoogleInteractionsAgentStream } from './synthesize-google-interactions-agent-stream';
48
49
 
49
50
  export type GoogleInteractionsConfig = {
@@ -278,12 +279,14 @@ export class GoogleInteractionsLanguageModel implements LanguageModelV3 {
278
279
  * Agent calls require `background: true` on the wire — otherwise the API
279
280
  * rejects them with `background=true is required for agent interactions.`
280
281
  * The server returns a non-terminal status (`in_progress`/`requires_action`)
281
- * and the final outputs must be polled via `GET /interactions/{id}`. This
282
- * is handled internally in `doGenerate` / `doStream` so the user-facing
283
- * surface stays identical to model-id calls.
282
+ * and the final outputs are streamed via `GET /interactions/{id}?stream=true`
283
+ * (or polled via `GET /interactions/{id}`). This is handled internally in
284
+ * `doGenerate` / `doStream` so the user-facing surface stays identical to
285
+ * model-id calls.
284
286
  *
285
287
  * Model-id calls retain their original synchronous behavior — no
286
- * `background` field is sent.
288
+ * `background` field is sent. (No documented model accepts `background:
289
+ * true` today; revisit when one does.)
287
290
  */
288
291
  const args: GoogleInteractionsRequestBody = pruneUndefined({
289
292
  ...(isAgent ? { agent: this.agent } : { model: this.modelId }),
@@ -460,14 +463,13 @@ export class GoogleInteractionsLanguageModel implements LanguageModelV3 {
460
463
 
461
464
  /*
462
465
  * Agent calls require `background: true`, which is incompatible with
463
- * `stream: true` on POST. We drive the agent flow exactly like
464
- * `doGenerate` (POST background -> poll GET) and synthesize a stream
465
- * from the final polled outputs. The user-facing stream surface stays
466
- * identical -- text-start / text-delta / text-end / finish parts are
467
- * emitted in the same order as a true SSE response.
466
+ * `stream: true` on POST. Drive these via POST background -> GET stream
467
+ * (with terminal-status short-circuit). The user-facing stream surface
468
+ * stays identical -- text-start / text-delta / text-end / finish parts
469
+ * are emitted in the same order as a true SSE response.
468
470
  */
469
471
  if (isAgent) {
470
- return this.doStreamAgent({
472
+ return this.doStreamBackground({
471
473
  args,
472
474
  warnings,
473
475
  url,
@@ -515,26 +517,24 @@ export class GoogleInteractionsLanguageModel implements LanguageModelV3 {
515
517
  }
516
518
 
517
519
  /*
518
- * Drive the streaming surface for agent calls. Agent calls require
520
+ * Drive the streaming surface for agent calls. Agents require
519
521
  * `background: true`, which is incompatible with `stream: true` on POST.
520
522
  *
521
- * In principle the API also exposes `GET /interactions/{id}?stream=true`
522
- * to replay events as the agent runs. In practice the connection is
523
- * idle for long stretches while the agent thinks (deep-research can run
524
- * for a minute or more between SSE events), and undici's default body
525
- * timeout terminates the request mid-flight with `UND_ERR_BODY_TIMEOUT`.
526
- * Tuning the timeout per-call would require the caller to thread an
527
- * `undici.Agent` through `fetch`, which contradicts the AI SDK's
528
- * pluggable-fetch contract.
523
+ * Approach:
524
+ * 1. POST `/interactions` with `background: true`. The response includes
525
+ * the interaction id and an initial (usually non-terminal) status.
526
+ * 2. If the POST status is already terminal (rare), synthesize a stream
527
+ * from the polled outputs and we're done.
528
+ * 3. Otherwise open `GET /interactions/{id}?stream=true` and pipe the
529
+ * SSE events through `buildGoogleInteractionsStreamTransform` so the
530
+ * consumer receives text deltas / thinking summaries / tool events as
531
+ * they happen instead of all at once at the end.
529
532
  *
530
- * We therefore drive `doStream` exactly like `doGenerate` for agents:
531
- * POST with `background: true`, poll `GET /interactions/{id}` until
532
- * terminal, then synthesize the stream from the final outputs. The
533
- * user-facing surface stays identical -- text-start / text-delta /
534
- * text-end / finish parts arrive in the same order as a true SSE
535
- * response, just buffered until the agent completes.
533
+ * The SSE connection can drop while the agent idles between events
534
+ * (`UND_ERR_BODY_TIMEOUT`); `streamGoogleInteractionEvents` handles the
535
+ * reconnect-with-`last_event_id` loop transparently.
536
536
  */
537
- private async doStreamAgent({
537
+ private async doStreamBackground({
538
538
  args,
539
539
  warnings,
540
540
  url,
@@ -561,38 +561,64 @@ export class GoogleInteractionsLanguageModel implements LanguageModelV3 {
561
561
  fetch: this.config.fetch,
562
562
  });
563
563
 
564
- let { responseHeaders: postHeaders, value: postResponse } = postResult;
564
+ const { responseHeaders: postHeaders, value: postResponse } = postResult;
565
565
  const interactionId = postResponse.id;
566
566
 
567
567
  if (interactionId == null || interactionId.length === 0) {
568
568
  throw new Error(
569
- 'google.interactions: agent POST response did not include an interaction id; cannot poll for the agent result.',
569
+ 'google.interactions: background POST response did not include an interaction id; cannot stream the result.',
570
570
  );
571
571
  }
572
572
 
573
- if (!isTerminalStatus(postResponse.status)) {
574
- const polled = await pollGoogleInteractionUntilTerminal({
575
- baseURL: this.config.baseURL,
576
- interactionId,
577
- headers: mergedHeaders,
578
- fetch: this.config.fetch,
579
- abortSignal: options.abortSignal,
580
- timeoutMs: pollingTimeoutMs,
573
+ const headerServiceTier = postHeaders?.['x-gemini-service-tier'];
574
+
575
+ /*
576
+ * If the POST already returned a terminal status (e.g. cached, immediate
577
+ * failure, or `incomplete`), there is nothing to stream from the GET --
578
+ * synthesize directly from the response so the caller still gets a
579
+ * complete stream.
580
+ */
581
+ if (isTerminalStatus(postResponse.status)) {
582
+ const synthesized = synthesizeGoogleInteractionsAgentStream({
583
+ response: postResponse,
584
+ warnings,
585
+ generateId: this.config.generateId ?? defaultGenerateId,
586
+ includeRawChunks: options.includeRawChunks,
587
+ headerServiceTier,
581
588
  });
582
- postResponse = polled.response;
583
- postHeaders = polled.responseHeaders ?? postHeaders;
589
+ return {
590
+ stream: synthesized,
591
+ request: { body: args },
592
+ response: { headers: postHeaders },
593
+ };
584
594
  }
585
595
 
586
- const stream = synthesizeGoogleInteractionsAgentStream({
587
- response: postResponse,
596
+ /*
597
+ * `pollingTimeoutMs` is unused on the live-SSE path -- there's no poll
598
+ * loop to time out -- but we surface it as the per-attempt timeout for
599
+ * the AbortSignal-driven cancel that the caller already controls. Future
600
+ * iterations may use it as a backstop if the SSE+resume loop spins
601
+ * indefinitely.
602
+ */
603
+ void pollingTimeoutMs;
604
+
605
+ const events = streamGoogleInteractionEvents({
606
+ baseURL: this.config.baseURL,
607
+ interactionId,
608
+ headers: mergedHeaders,
609
+ fetch: this.config.fetch,
610
+ abortSignal: options.abortSignal,
611
+ });
612
+
613
+ const transform = buildGoogleInteractionsStreamTransform({
588
614
  warnings,
589
615
  generateId: this.config.generateId ?? defaultGenerateId,
590
616
  includeRawChunks: options.includeRawChunks,
591
- headerServiceTier: postHeaders?.['x-gemini-service-tier'],
617
+ serviceTier: headerServiceTier,
592
618
  });
593
619
 
594
620
  return {
595
- stream,
621
+ stream: events.pipeThrough(transform),
596
622
  request: { body: args },
597
623
  response: { headers: postHeaders },
598
624
  };
@@ -2,9 +2,11 @@ import {
2
2
  createJsonResponseHandler,
3
3
  delay,
4
4
  getFromApi,
5
+ isAbortError,
5
6
  type FetchFunction,
6
7
  } from '@ai-sdk/provider-utils';
7
8
  import { googleFailedResponseHandler } from '../google-error';
9
+ import { cancelGoogleInteraction } from './cancel-google-interaction';
8
10
  import {
9
11
  googleInteractionsResponseSchema,
10
12
  type GoogleInteractionsResponse,
@@ -73,38 +75,55 @@ export async function pollGoogleInteractionUntilTerminal({
73
75
  let nextDelayMs = initialDelayMs;
74
76
  const url = `${baseURL}/interactions/${encodeURIComponent(interactionId)}`;
75
77
 
76
- while (true) {
77
- if (abortSignal?.aborted) {
78
- throw new DOMException('Polling was aborted', 'AbortError');
79
- }
78
+ /*
79
+ * When the caller aborts, fire a best-effort `POST /interactions/{id}/cancel`
80
+ * so the run stops billing on Google's side. Wrap every exit path that's
81
+ * triggered by an abort -- the explicit `abortSignal.aborted` check, the
82
+ * AbortError thrown by `delay()`, and any AbortError thrown by `getFromApi`.
83
+ */
84
+ const cancelOnServer = () =>
85
+ cancelGoogleInteraction({ baseURL, interactionId, headers, fetch });
80
86
 
81
- if (Date.now() - startedAt > timeoutMs) {
82
- throw new Error(
83
- `google.interactions: timed out polling interaction ${interactionId} after ${timeoutMs}ms.`,
84
- );
85
- }
87
+ try {
88
+ while (true) {
89
+ if (abortSignal?.aborted) {
90
+ await cancelOnServer();
91
+ throw new DOMException('Polling was aborted', 'AbortError');
92
+ }
86
93
 
87
- await delay(nextDelayMs, { abortSignal });
94
+ if (Date.now() - startedAt > timeoutMs) {
95
+ throw new Error(
96
+ `google.interactions: timed out polling interaction ${interactionId} after ${timeoutMs}ms.`,
97
+ );
98
+ }
88
99
 
89
- const {
90
- value: response,
91
- rawValue: rawResponse,
92
- responseHeaders,
93
- } = await getFromApi({
94
- url,
95
- headers,
96
- failedResponseHandler: googleFailedResponseHandler,
97
- successfulResponseHandler: createJsonResponseHandler(
98
- googleInteractionsResponseSchema,
99
- ),
100
- abortSignal,
101
- fetch,
102
- });
100
+ await delay(nextDelayMs, { abortSignal });
103
101
 
104
- if (isTerminalStatus(response.status)) {
105
- return { response, rawResponse, responseHeaders };
106
- }
102
+ const {
103
+ value: response,
104
+ rawValue: rawResponse,
105
+ responseHeaders,
106
+ } = await getFromApi({
107
+ url,
108
+ headers,
109
+ failedResponseHandler: googleFailedResponseHandler,
110
+ successfulResponseHandler: createJsonResponseHandler(
111
+ googleInteractionsResponseSchema,
112
+ ),
113
+ abortSignal,
114
+ fetch,
115
+ });
107
116
 
108
- nextDelayMs = Math.min(nextDelayMs * 2, maxDelayMs);
117
+ if (isTerminalStatus(response.status)) {
118
+ return { response, rawResponse, responseHeaders };
119
+ }
120
+
121
+ nextDelayMs = Math.min(nextDelayMs * 2, maxDelayMs);
122
+ }
123
+ } catch (error) {
124
+ if (isAbortError(error)) {
125
+ await cancelOnServer();
126
+ }
127
+ throw error;
109
128
  }
110
129
  }
@@ -0,0 +1,239 @@
1
+ import {
2
+ createEventSourceResponseHandler,
3
+ delay,
4
+ getFromApi,
5
+ isAbortError,
6
+ type FetchFunction,
7
+ type ParseResult,
8
+ } from '@ai-sdk/provider-utils';
9
+ import { googleFailedResponseHandler } from '../google-error';
10
+ import { cancelGoogleInteraction } from './cancel-google-interaction';
11
+ import {
12
+ googleInteractionsEventSchema,
13
+ type GoogleInteractionsEvent,
14
+ } from './google-interactions-api';
15
+
16
+ const DEFAULT_MAX_RETRIES = 3;
17
+ const DEFAULT_RETRY_DELAY_MS = 500;
18
+
19
+ /**
20
+ * Connects to `GET {baseURL}/interactions/{id}?stream=true` and surfaces the
21
+ * server-sent events as a `ReadableStream<ParseResult<GoogleInteractionsEvent>>`
22
+ * so the existing `buildGoogleInteractionsStreamTransform` can consume them
23
+ * unchanged.
24
+ *
25
+ * The connection can drop mid-run: deep-research agents idle for long
26
+ * stretches between SSE events and undici's default body timeout terminates
27
+ * the request with `UND_ERR_BODY_TIMEOUT`. We track the last seen `event_id`
28
+ * and reconnect with `?last_event_id=<id>` on any unexpected end. After
29
+ * `maxRetries` consecutive failures the stream errors out so the caller can
30
+ * decide whether to fall back to polling.
31
+ *
32
+ * The stream completes cleanly when an `interaction.complete` event with a
33
+ * terminal status arrives, or when an `error` event arrives.
34
+ */
35
+ export function streamGoogleInteractionEvents({
36
+ baseURL,
37
+ interactionId,
38
+ headers,
39
+ fetch,
40
+ abortSignal,
41
+ maxRetries = DEFAULT_MAX_RETRIES,
42
+ retryDelayMs = DEFAULT_RETRY_DELAY_MS,
43
+ }: {
44
+ baseURL: string;
45
+ interactionId: string;
46
+ headers: Record<string, string | undefined>;
47
+ fetch?: FetchFunction;
48
+ abortSignal?: AbortSignal;
49
+ maxRetries?: number;
50
+ retryDelayMs?: number;
51
+ }): ReadableStream<ParseResult<GoogleInteractionsEvent>> {
52
+ if (interactionId.length === 0) {
53
+ throw new Error(
54
+ 'google.interactions: cannot stream a background interaction without an id.',
55
+ );
56
+ }
57
+
58
+ const eventSourceHeaders = {
59
+ ...headers,
60
+ accept: 'text/event-stream',
61
+ };
62
+
63
+ let lastEventId: string | undefined;
64
+ let complete = false;
65
+ let attempt = 0;
66
+ let receivedAnyEventThisAttempt = false;
67
+ let currentReader:
68
+ | ReadableStreamDefaultReader<ParseResult<GoogleInteractionsEvent>>
69
+ | undefined;
70
+
71
+ /*
72
+ * Forwards `cancel()` from the consumer (and the upstream `abortSignal`) to
73
+ * any in-flight `getFromApi` or `delay` so the loop unblocks immediately
74
+ * instead of waiting for the next iteration to notice a flag.
75
+ */
76
+ const internalAbort = new AbortController();
77
+ const upstreamAbortHandler = () => internalAbort.abort();
78
+ if (abortSignal != null) {
79
+ if (abortSignal.aborted) {
80
+ internalAbort.abort();
81
+ } else {
82
+ abortSignal.addEventListener('abort', upstreamAbortHandler, {
83
+ once: true,
84
+ });
85
+ }
86
+ }
87
+ const effectiveSignal = internalAbort.signal;
88
+
89
+ function buildUrl(): string {
90
+ const base = `${baseURL}/interactions/${encodeURIComponent(interactionId)}`;
91
+ const params = new URLSearchParams({ stream: 'true' });
92
+ if (lastEventId != null) {
93
+ params.set('last_event_id', lastEventId);
94
+ }
95
+ return `${base}?${params.toString()}`;
96
+ }
97
+
98
+ async function openReader() {
99
+ const { value: stream } = await getFromApi({
100
+ url: buildUrl(),
101
+ headers: eventSourceHeaders,
102
+ failedResponseHandler: googleFailedResponseHandler,
103
+ successfulResponseHandler: createEventSourceResponseHandler(
104
+ googleInteractionsEventSchema,
105
+ ),
106
+ abortSignal: effectiveSignal,
107
+ fetch,
108
+ });
109
+ return stream.getReader();
110
+ }
111
+
112
+ return new ReadableStream<ParseResult<GoogleInteractionsEvent>>({
113
+ async start(controller) {
114
+ try {
115
+ while (!complete && !effectiveSignal.aborted) {
116
+ if (currentReader == null) {
117
+ try {
118
+ currentReader = await openReader();
119
+ receivedAnyEventThisAttempt = false;
120
+ } catch (error) {
121
+ if (isAbortError(error) || effectiveSignal.aborted) {
122
+ controller.error(error);
123
+ return;
124
+ }
125
+ attempt++;
126
+ if (attempt >= maxRetries) {
127
+ controller.error(error);
128
+ return;
129
+ }
130
+ await delay(retryDelayMs * attempt, {
131
+ abortSignal: effectiveSignal,
132
+ });
133
+ continue;
134
+ }
135
+ }
136
+
137
+ try {
138
+ const { done, value } = await currentReader.read();
139
+ if (done) {
140
+ /*
141
+ * Underlying stream ended. If we already saw the terminal event
142
+ * we exit cleanly; otherwise this is an unexpected disconnect
143
+ * and we'll reconnect with `last_event_id`.
144
+ *
145
+ * If the connection closed without producing any events at all
146
+ * this attempt, count it as a failed attempt -- otherwise an
147
+ * empty/misbehaving server response would loop forever.
148
+ */
149
+ currentReader = undefined;
150
+ if (complete) break;
151
+ if (!receivedAnyEventThisAttempt) {
152
+ attempt++;
153
+ if (attempt >= maxRetries) {
154
+ controller.error(
155
+ new Error(
156
+ 'google.interactions: SSE stream closed without producing any events.',
157
+ ),
158
+ );
159
+ return;
160
+ }
161
+ await delay(retryDelayMs * attempt, {
162
+ abortSignal: effectiveSignal,
163
+ });
164
+ } else {
165
+ attempt = 0;
166
+ }
167
+ continue;
168
+ }
169
+
170
+ receivedAnyEventThisAttempt = true;
171
+
172
+ if (value.success) {
173
+ const ev = value.value as {
174
+ event_id?: string;
175
+ event_type?: string;
176
+ };
177
+ if (typeof ev.event_id === 'string' && ev.event_id.length > 0) {
178
+ lastEventId = ev.event_id;
179
+ }
180
+ if (
181
+ ev.event_type === 'interaction.complete' ||
182
+ ev.event_type === 'error'
183
+ ) {
184
+ complete = true;
185
+ }
186
+ }
187
+
188
+ controller.enqueue(value);
189
+ } catch (error) {
190
+ if (isAbortError(error) || effectiveSignal.aborted) {
191
+ controller.error(error);
192
+ return;
193
+ }
194
+ currentReader = undefined;
195
+ attempt++;
196
+ if (attempt >= maxRetries) {
197
+ controller.error(error);
198
+ return;
199
+ }
200
+ await delay(retryDelayMs * attempt, {
201
+ abortSignal: effectiveSignal,
202
+ });
203
+ }
204
+ }
205
+ controller.close();
206
+ } catch (error) {
207
+ controller.error(error);
208
+ } finally {
209
+ if (abortSignal != null) {
210
+ abortSignal.removeEventListener('abort', upstreamAbortHandler);
211
+ }
212
+ currentReader?.cancel().catch(() => {});
213
+ currentReader = undefined;
214
+
215
+ /*
216
+ * If we're exiting because the caller aborted (or the consumer
217
+ * cancelled the stream) before the agent finished, fire
218
+ * `POST /interactions/{id}/cancel` so the run stops billing on
219
+ * Google's side. Skipped when `complete` is set -- the agent already
220
+ * reported terminal status via `interaction.complete` / `error`.
221
+ */
222
+ if (effectiveSignal.aborted && !complete) {
223
+ await cancelGoogleInteraction({
224
+ baseURL,
225
+ interactionId,
226
+ headers,
227
+ fetch,
228
+ });
229
+ }
230
+ }
231
+ },
232
+
233
+ cancel() {
234
+ internalAbort.abort();
235
+ currentReader?.cancel().catch(() => {});
236
+ currentReader = undefined;
237
+ },
238
+ });
239
+ }