@ai-sdk/gateway 4.0.122 → 4.0.124

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.
@@ -464,6 +464,69 @@ console.log(ranking);
464
464
 
465
465
  Reranking models are useful for improving search results in retrieval-augmented generation (RAG) pipelines by re-scoring candidate documents after an initial retrieval step.
466
466
 
467
+ ## Streaming Speech Models
468
+
469
+ <Note type="warning">
470
+ Streaming speech is experimental and the API may change in patch releases.
471
+ </Note>
472
+
473
+ Speech models that support streaming can be used with
474
+ `experimental_streamSpeech`. The gateway provider connects to AI Gateway over a
475
+ WebSocket, forwards text deltas as they arrive, and streams audio chunks back.
476
+ Gateway WebSocket auth is carried in the subprotocols, so no header-capable
477
+ WebSocket implementation is needed.
478
+
479
+ Streaming speech works only with models that the
480
+ [AI Gateway model catalog](https://vercel.com/ai-gateway/models) lists with
481
+ streaming speech support. The model ID below is illustrative; check the catalog
482
+ for current availability.
483
+
484
+ ```ts
485
+ import { experimental_streamSpeech as streamSpeech } from 'ai';
486
+
487
+ const result = streamSpeech({
488
+ model: 'elevenlabs/eleven-flash-v2.5',
489
+ text: textStream, // a string, or an array or stream of strings and/or text parts
490
+ outputFormat: { type: 'audio/pcm', rate: 24000 },
491
+ });
492
+
493
+ for await (const chunk of result.audioStream) {
494
+ // Uint8Array audio bytes
495
+ }
496
+
497
+ console.log(await result.usage); // { inputCharacters: ... }
498
+ ```
499
+
500
+ ### Streaming Speech in the Browser
501
+
502
+ To stream speech from browser code without exposing your Gateway credential,
503
+ mint a short-lived client secret on your server with
504
+ `gateway.experimental_speech.getToken()` and pass the token to the browser.
505
+ The token is bound to the streaming speech surface and the requested model.
506
+
507
+ ```ts
508
+ // Server
509
+ import { gateway } from '@ai-sdk/gateway';
510
+
511
+ const { token } = await gateway.experimental_speech.getToken({
512
+ model: 'elevenlabs/eleven-flash-v2.5',
513
+ expiresAfterSeconds: 60,
514
+ });
515
+ ```
516
+
517
+ ```ts
518
+ // Browser
519
+ import { createGateway } from '@ai-sdk/gateway';
520
+ import { experimental_streamSpeech as streamSpeech } from 'ai';
521
+
522
+ const gateway = createGateway({ apiKey: token });
523
+
524
+ const result = streamSpeech({
525
+ model: gateway.experimental_speech('elevenlabs/eleven-flash-v2.5'),
526
+ text: textStream,
527
+ });
528
+ ```
529
+
467
530
  ## Realtime Models
468
531
 
469
532
  <Note type="warning">
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ai-sdk/gateway",
3
3
  "private": false,
4
- "version": "4.0.122",
4
+ "version": "4.0.124",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "sideEffects": false,
@@ -30,8 +30,8 @@
30
30
  }
31
31
  },
32
32
  "dependencies": {
33
- "@ai-sdk/provider": "4.0.32",
34
- "@ai-sdk/provider-utils": "5.0.66",
33
+ "@ai-sdk/provider": "4.0.33",
34
+ "@ai-sdk/provider-utils": "5.0.67",
35
35
  "@vercel/oidc": "3.2.0"
36
36
  },
37
37
  "devDependencies": {
@@ -18,7 +18,6 @@ export type GatewayImageModelId =
18
18
  | 'meta/muse-image-1.0'
19
19
  | 'microsoft/mai-image-2.6'
20
20
  | 'microsoft/mai-image-2.6-flash'
21
- | 'openai/gpt-image-1'
22
21
  | 'openai/gpt-image-1-mini'
23
22
  | 'openai/gpt-image-1.5'
24
23
  | 'openai/gpt-image-2'
@@ -174,8 +174,6 @@ export type GatewayModelId =
174
174
  | 'nvidia/nemotron-3.5-super-vl'
175
175
  | 'nvidia/nemotron-nano-12b-v2-vl'
176
176
  | 'nvidia/nemotron-nano-9b-v2'
177
- | 'openai/gpt-3.5-turbo'
178
- | 'openai/gpt-4-turbo'
179
177
  | 'openai/gpt-4.1'
180
178
  | 'openai/gpt-4.1-batch'
181
179
  | 'openai/gpt-4.1-fast'
@@ -183,8 +181,6 @@ export type GatewayModelId =
183
181
  | 'openai/gpt-4.1-mini-batch'
184
182
  | 'openai/gpt-4.1-mini-fast'
185
183
  | 'openai/gpt-4.1-nano'
186
- | 'openai/gpt-4.1-nano-batch'
187
- | 'openai/gpt-4.1-nano-fast'
188
184
  | 'openai/gpt-4o'
189
185
  | 'openai/gpt-4o-batch'
190
186
  | 'openai/gpt-4o-fast'
@@ -259,7 +255,6 @@ export type GatewayModelId =
259
255
  | 'openai/o3-mini'
260
256
  | 'openai/o3-pro'
261
257
  | 'openai/o4-mini'
262
- | 'openai/o4-mini-fast'
263
258
  | 'perplexity/sonar'
264
259
  | 'poolside/laguna-s-2.1'
265
260
  | 'poolside/laguna-s-2.1-free'
@@ -273,8 +268,6 @@ export type GatewayModelId =
273
268
  | 'sakana/namazu'
274
269
  | 'scaledown/scaledown-extract-1'
275
270
  | 'scaledown/scaledown-summarize-1'
276
- | 'spacexai/grok-4.1-fast-non-reasoning'
277
- | 'spacexai/grok-4.1-fast-reasoning'
278
271
  | 'spacexai/grok-4.20-multi-agent'
279
272
  | 'spacexai/grok-4.20-multi-agent-batch'
280
273
  | 'spacexai/grok-4.20-multi-agent-beta'
@@ -40,7 +40,7 @@ import { GatewayImageModel } from './gateway-image-model';
40
40
  import { GatewayVideoModel } from './gateway-video-model';
41
41
  import { GatewayDecisionModel } from './gateway-decision-model';
42
42
  import { GatewayRerankingModel } from './gateway-reranking-model';
43
- import { GatewaySpeechModel } from './gateway-speech-model';
43
+ import { GatewaySpeechModel, toGatewaySpeechUrl } from './gateway-speech-model';
44
44
  import {
45
45
  GatewayTranscriptionModel,
46
46
  toGatewayTranscriptionUrl,
@@ -66,6 +66,7 @@ import type {
66
66
  TranscriptionModelV4,
67
67
  Experimental_VideoModelV4,
68
68
  Experimental_DecisionModelV4,
69
+ Experimental_StreamingSpeechModelV4,
69
70
  Experimental_RealtimeFactoryV4 as RealtimeFactoryV4,
70
71
  Experimental_RealtimeFactoryV4GetTokenOptions as RealtimeFactoryV4GetTokenOptions,
71
72
  ProviderV4,
@@ -216,6 +217,14 @@ export interface GatewayProvider extends ProviderV4 {
216
217
  */
217
218
  experimental_transcription: GatewayTranscriptionFactory;
218
219
 
220
+ /**
221
+ * Experimental streaming-speech entry point. Callable like
222
+ * `speech(modelId)`, plus `getToken` for minting a short-lived client
223
+ * secret (`vcst_`) a browser can use to open the streaming speech
224
+ * WebSocket without holding the Gateway credential.
225
+ */
226
+ experimental_speech: GatewaySpeechFactory;
227
+
219
228
  /**
220
229
  * Gateway-specific tools executed server-side.
221
230
  */
@@ -252,6 +261,36 @@ export interface GatewayTranscriptionFactory {
252
261
  ): Promise<GatewayTranscriptionFactoryGetTokenResult>;
253
262
  }
254
263
 
264
+ export type GatewaySpeechFactoryGetTokenOptions = {
265
+ model: GatewaySpeechModelId;
266
+ /** Token lifetime in seconds. Gateway default is 60s (max 300s). */
267
+ expiresAfterSeconds?: number;
268
+ };
269
+
270
+ export type GatewaySpeechFactoryGetTokenResult = {
271
+ /** The minted `vcst_` client secret. */
272
+ token: string;
273
+ /** WebSocket URL of the streaming speech surface for this model. */
274
+ url: string;
275
+ /** Token expiry, epoch seconds. */
276
+ expiresAt?: number;
277
+ };
278
+
279
+ /**
280
+ * Streaming-speech factory: callable like `speech(modelId)`, plus a
281
+ * server-side `getToken` that mints a speech-bound short-lived client secret
282
+ * (`vcst_`). The browser connects with
283
+ * `createGateway({ apiKey: token }).speech(modelId)`; the token rides the
284
+ * same auth subprotocol an API key does, without exposing the credential.
285
+ */
286
+ export interface GatewaySpeechFactory {
287
+ (modelId: GatewaySpeechModelId): Experimental_StreamingSpeechModelV4;
288
+
289
+ getToken(
290
+ options: GatewaySpeechFactoryGetTokenOptions,
291
+ ): Promise<GatewaySpeechFactoryGetTokenResult>;
292
+ }
293
+
255
294
  export interface GatewayProviderSettings {
256
295
  /** Settings for downloading JSON Lines batch results. */
257
296
  batchResultDownloads?: {
@@ -288,10 +327,10 @@ export interface GatewayProviderSettings {
288
327
  fetch?: FetchFunction;
289
328
 
290
329
  /**
291
- * Custom WebSocket implementation used for streaming transcription. This is
292
- * useful for testing or for runtimes without a global WebSocket. A
293
- * header-capable implementation is not required — Gateway WebSocket auth is
294
- * carried in the subprotocols.
330
+ * Custom WebSocket implementation used for streaming transcription and
331
+ * streaming speech. This is useful for testing or for runtimes without a
332
+ * global WebSocket. A header-capable implementation is not required —
333
+ * Gateway WebSocket auth is carried in the subprotocols.
295
334
  */
296
335
  webSocket?: WebSocketConstructor;
297
336
 
@@ -391,7 +430,7 @@ export function createGateway(
391
430
  const mintClientSecret = async (params: {
392
431
  modelId: string;
393
432
  expiresAfterSeconds?: number;
394
- routeKind?: 'transcription';
433
+ routeKind?: 'speech' | 'transcription';
395
434
  }): Promise<{ token: string; expiresAt?: number }> => {
396
435
  assertGatewayClientSecretServerEnvironment();
397
436
  const auth = await getRealtimeAuthToken();
@@ -624,10 +663,35 @@ export function createGateway(
624
663
  headers: getHeaders,
625
664
  fetch: options.fetch,
626
665
  o11yHeaders: createO11yHeaders(),
666
+ webSocket: options.webSocket,
627
667
  });
628
668
  };
629
669
  provider.speechModel = createSpeechModel;
630
670
  provider.speech = createSpeechModel;
671
+ // Callable like `speech(modelId)`; `getToken` mints a speech-bound client
672
+ // secret (server-side only) the browser uses to connect via
673
+ // `createGateway({ apiKey: token }).speech(modelId)`.
674
+ provider.experimental_speech = Object.assign(
675
+ (modelId: GatewaySpeechModelId) => createSpeechModel(modelId),
676
+ {
677
+ getToken: async (
678
+ tokenOptions: GatewaySpeechFactoryGetTokenOptions,
679
+ ): Promise<GatewaySpeechFactoryGetTokenResult> => {
680
+ const secret = await mintClientSecret({
681
+ modelId: tokenOptions.model,
682
+ routeKind: 'speech',
683
+ ...(tokenOptions.expiresAfterSeconds != null && {
684
+ expiresAfterSeconds: tokenOptions.expiresAfterSeconds,
685
+ }),
686
+ });
687
+ return {
688
+ token: secret.token,
689
+ url: toGatewaySpeechUrl(baseURL, tokenOptions.model),
690
+ ...(secret.expiresAt != null && { expiresAt: secret.expiresAt }),
691
+ };
692
+ },
693
+ },
694
+ ) as GatewaySpeechFactory;
631
695
  const createTranscriptionModel = (modelId: GatewayTranscriptionModelId) => {
632
696
  return new GatewayTranscriptionModel(modelId, {
633
697
  provider: 'gateway',
@@ -728,7 +792,7 @@ export async function getGatewayAuthToken(
728
792
  function assertGatewayClientSecretServerEnvironment(): void {
729
793
  if (typeof globalThis.window !== 'undefined') {
730
794
  throw new Error(
731
- 'AI Gateway client secrets must be minted server-side: minting needs your Gateway credential, which must never reach the browser. Call gateway.experimental_realtime.getToken() or gateway.experimental_transcription.getToken() from your server and pass the returned token to the client.',
795
+ 'AI Gateway client secrets must be minted server-side: minting needs your Gateway credential, which must never reach the browser. Call gateway.experimental_realtime.getToken(), gateway.experimental_transcription.getToken(), or gateway.experimental_speech.getToken() from your server and pass the returned token to the client.',
732
796
  );
733
797
  }
734
798
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Shared WebSocket subprotocol contract for AI Gateway realtime and streaming
3
- * transcription auth.
2
+ * Shared WebSocket subprotocol contract for AI Gateway realtime, streaming
3
+ * transcription, and streaming speech auth.
4
4
  *
5
5
  * The browser `WebSocket` API cannot set request headers, so the Gateway auth
6
6
  * (bearer) token is carried through the `Sec-WebSocket-Protocol` handshake
@@ -9,7 +9,8 @@
9
9
  *
10
10
  * This module is the single source of truth for that contract so the client and
11
11
  * the Gateway server can't drift: the client encodes values with
12
- * `getGatewayRealtimeProtocols` / `getGatewayTranscriptionProtocols`, and the
12
+ * `getGatewayRealtimeProtocols` / `getGatewayTranscriptionProtocols` /
13
+ * `getGatewaySpeechProtocols`, and the
13
14
  * Gateway server decodes them with `getGatewayRealtimeAuthToken` /
14
15
  * `getGatewayRealtimeTeamIdOrSlug`.
15
16
  *
@@ -33,6 +34,12 @@ export const GATEWAY_REALTIME_SUBPROTOCOL = 'ai-gateway-realtime.v1';
33
34
  */
34
35
  export const GATEWAY_TRANSCRIPTION_SUBPROTOCOL = 'ai-gateway-transcription.v1';
35
36
 
37
+ /**
38
+ * Marker subprotocol for streaming speech handshakes (same negotiation
39
+ * purpose as `GATEWAY_REALTIME_SUBPROTOCOL`).
40
+ */
41
+ export const GATEWAY_SPEECH_SUBPROTOCOL = 'ai-gateway-speech.v1';
42
+
36
43
  /** Subprotocol prefix that carries the Gateway auth (bearer) token. */
37
44
  export const GATEWAY_AUTH_SUBPROTOCOL_PREFIX = 'ai-gateway-auth.';
38
45
 
@@ -65,6 +72,17 @@ export function getGatewayTranscriptionProtocols(
65
72
  );
66
73
  }
67
74
 
75
+ /**
76
+ * Client-side: `getGatewayRealtimeProtocols`, but with the streaming speech
77
+ * marker subprotocol.
78
+ */
79
+ export function getGatewaySpeechProtocols(
80
+ token: string,
81
+ options?: { teamIdOrSlug?: string },
82
+ ): string[] {
83
+ return buildGatewayProtocols(GATEWAY_SPEECH_SUBPROTOCOL, token, options);
84
+ }
85
+
68
86
  function buildGatewayProtocols(
69
87
  marker: string,
70
88
  token: string,
@@ -1,4 +1,9 @@
1
1
  import type {
2
+ Experimental_SpeechModelV4StreamOptions as SpeechModelV4StreamOptions,
3
+ Experimental_SpeechModelV4StreamPart as SpeechModelV4StreamPart,
4
+ Experimental_SpeechModelV4StreamResult as SpeechModelV4StreamResult,
5
+ Experimental_SpeechModelV4StreamTextPart as SpeechModelV4StreamTextPart,
6
+ Experimental_StreamingSpeechModelV4 as StreamingSpeechModelV4,
2
7
  SharedV4ProviderMetadata,
3
8
  SharedV4Warning,
4
9
  SpeechModelV4,
@@ -7,19 +12,41 @@ import {
7
12
  lazySchema,
8
13
  zodSchema,
9
14
  combineHeaders,
15
+ connectToWebSocket,
10
16
  createJsonErrorResponseHandler,
11
17
  createJsonResponseHandler,
12
18
  getErrorMessage,
19
+ experimental_parseSpeechStreamPart as parseSpeechStreamPart,
13
20
  postJsonToApi,
14
21
  resolve,
22
+ EXPERIMENTAL_SPEECH_STREAM_START_FRAME_TYPE as SPEECH_STREAM_START_FRAME_TYPE,
23
+ EXPERIMENTAL_SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE as SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
24
+ EXPERIMENTAL_SPEECH_STREAM_TEXT_DONE_FRAME_TYPE as SPEECH_STREAM_TEXT_DONE_FRAME_TYPE,
25
+ waitForWebSocketBufferDrain,
26
+ type Experimental_SpeechStreamStartFrame,
27
+ type Experimental_SpeechStreamTextDeltaFrame,
15
28
  type Resolvable,
29
+ type WebSocketConnection,
30
+ type WebSocketConstructor,
31
+ type WebSocketLike,
16
32
  } from '@ai-sdk/provider-utils';
17
33
  import { z } from './zod';
18
34
  import { asGatewayError } from './errors';
19
35
  import { parseAuthMethod } from './errors/parse-auth-method';
20
36
  import type { GatewayConfig } from './gateway-config';
37
+ import {
38
+ GATEWAY_SPEECH_SUBPROTOCOL,
39
+ getGatewaySpeechProtocols,
40
+ } from './gateway-realtime-auth';
41
+ import {
42
+ createErrorFromServerErrorPart,
43
+ errorControllerWithGatewayError,
44
+ } from './gateway-stream-errors';
45
+ import { getProtocolsFromHeaders } from './gateway-transcription-model';
21
46
 
22
- export class GatewaySpeechModel implements SpeechModelV4 {
47
+ export class GatewaySpeechModel
48
+ implements SpeechModelV4, StreamingSpeechModelV4
49
+ {
23
50
  readonly specificationVersion = 'v4' as const;
24
51
 
25
52
  constructor(
@@ -27,6 +54,9 @@ export class GatewaySpeechModel implements SpeechModelV4 {
27
54
  private readonly config: GatewayConfig & {
28
55
  provider: string;
29
56
  o11yHeaders: Resolvable<Record<string, string>>;
57
+ _internal?: {
58
+ currentDate?: () => Date;
59
+ };
30
60
  },
31
61
  ) {}
32
62
 
@@ -104,6 +134,59 @@ export class GatewaySpeechModel implements SpeechModelV4 {
104
134
  }
105
135
  }
106
136
 
137
+ async doStream(
138
+ options: SpeechModelV4StreamOptions,
139
+ ): Promise<SpeechModelV4StreamResult> {
140
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
141
+
142
+ const headers = combineHeaders(
143
+ await resolve(this.config.headers ?? {}),
144
+ options.headers ?? {},
145
+ this.getModelConfigHeaders(),
146
+ await resolve(this.config.o11yHeaders),
147
+ );
148
+ const authMethod = await parseAuthMethod(headers);
149
+
150
+ // First frame after the WebSocket opens, per the speech-stream envelope.
151
+ // Optional keys are omitted so the frame stays minimal.
152
+ const startFrame: Experimental_SpeechStreamStartFrame = {
153
+ type: SPEECH_STREAM_START_FRAME_TYPE,
154
+ ...(options.voice != null && { voice: options.voice }),
155
+ ...(options.outputFormat != null && {
156
+ outputFormat: options.outputFormat,
157
+ }),
158
+ ...(options.instructions != null && {
159
+ instructions: options.instructions,
160
+ }),
161
+ ...(options.speed != null && { speed: options.speed }),
162
+ ...(options.language != null && { language: options.language }),
163
+ ...(options.providerOptions != null && {
164
+ providerOptions: options.providerOptions,
165
+ }),
166
+ ...(options.includeRawChunks != null && {
167
+ includeRawChunks: options.includeRawChunks,
168
+ }),
169
+ };
170
+
171
+ return {
172
+ stream: createGatewaySpeechStream({
173
+ webSocket: this.config.webSocket,
174
+ url: toGatewaySpeechUrl(this.config.baseURL, this.modelId),
175
+ protocols: getProtocolsFromHeaders(headers, {
176
+ marker: GATEWAY_SPEECH_SUBPROTOCOL,
177
+ getProtocols: getGatewaySpeechProtocols,
178
+ }),
179
+ headers,
180
+ startFrame,
181
+ text: options.text,
182
+ abortSignal: options.abortSignal,
183
+ authMethod,
184
+ }),
185
+ request: { body: startFrame },
186
+ response: { timestamp: currentDate, modelId: this.modelId },
187
+ };
188
+ }
189
+
107
190
  private getUrl() {
108
191
  return `${this.config.baseURL}/speech-model`;
109
192
  }
@@ -116,6 +199,227 @@ export class GatewaySpeechModel implements SpeechModelV4 {
116
199
  }
117
200
  }
118
201
 
202
+ /**
203
+ * Gateway streaming speech WebSocket URL: HTTP(S) base upgraded to WS(S),
204
+ * model id in `?ai-model-id=` (browser `WebSocket` cannot set headers).
205
+ */
206
+ export function toGatewaySpeechUrl(baseURL: string, modelId: string): string {
207
+ const url = new URL(`${baseURL.replace(/^http/, 'ws')}/speech-model`);
208
+ url.searchParams.set('ai-model-id', modelId);
209
+ return url.toString();
210
+ }
211
+
212
+ /**
213
+ * Keeps text-delta frames under 64 KiB (envelope rule 7) even when JSON
214
+ * escapes every code unit as `\uXXXX`.
215
+ */
216
+ const MAX_TEXT_DELTA_CODE_UNITS = 8 * 1024;
217
+
218
+ /**
219
+ * Splits a long text delta into pieces of at most `MAX_TEXT_DELTA_CODE_UNITS`
220
+ * UTF-16 code units without separating a surrogate pair.
221
+ */
222
+ export function splitTextDelta(delta: string): string[] {
223
+ const pieces: string[] = [];
224
+ let offset = 0;
225
+ while (offset < delta.length) {
226
+ let end = Math.min(offset + MAX_TEXT_DELTA_CODE_UNITS, delta.length);
227
+ const lastCodeUnit = delta.charCodeAt(end - 1);
228
+ if (
229
+ end < delta.length &&
230
+ lastCodeUnit >= 0xd800 &&
231
+ lastCodeUnit <= 0xdbff
232
+ ) {
233
+ end--;
234
+ }
235
+ pieces.push(delta.slice(offset, end));
236
+ offset = end;
237
+ }
238
+ return pieces;
239
+ }
240
+
241
+ function createGatewaySpeechStream({
242
+ webSocket,
243
+ url,
244
+ protocols,
245
+ headers,
246
+ startFrame,
247
+ text,
248
+ abortSignal,
249
+ authMethod,
250
+ }: {
251
+ webSocket: WebSocketConstructor | undefined;
252
+ url: string;
253
+ protocols: string[];
254
+ headers: Record<string, string | undefined>;
255
+ startFrame: Experimental_SpeechStreamStartFrame;
256
+ text: ReadableStream<SpeechModelV4StreamTextPart>;
257
+ abortSignal: AbortSignal | undefined;
258
+ authMethod: 'api-key' | 'oidc' | undefined;
259
+ }): ReadableStream<SpeechModelV4StreamPart> {
260
+ let finished = false;
261
+ let cleanup: (closeCode?: number) => void = () => {};
262
+
263
+ return new ReadableStream<SpeechModelV4StreamPart>({
264
+ start: controller => {
265
+ let textReader:
266
+ | ReadableStreamDefaultReader<SpeechModelV4StreamTextPart>
267
+ | undefined;
268
+ let hasServerErrorPart = false;
269
+ let lastServerError: unknown;
270
+ let textStopped = false;
271
+ let connection: WebSocketConnection | undefined;
272
+
273
+ const cancelText = () => {
274
+ if (textReader != null) {
275
+ void textReader.cancel().catch(() => {});
276
+ textReader = undefined;
277
+ } else {
278
+ void text.cancel().catch(() => {});
279
+ }
280
+ };
281
+
282
+ cleanup = (closeCode?: number) => {
283
+ cancelText();
284
+ connection?.close(closeCode);
285
+ };
286
+
287
+ const finishWithError = (error: unknown) => {
288
+ if (finished) return;
289
+ finished = true;
290
+ cleanup();
291
+ void errorControllerWithGatewayError(controller, error, authMethod);
292
+ };
293
+
294
+ const sendText = async (socket: WebSocketLike) => {
295
+ const reader = text.getReader();
296
+ textReader = reader;
297
+ try {
298
+ while (true) {
299
+ const { done, value } = await reader.read();
300
+ if (done || finished || textStopped) break;
301
+ let first = true;
302
+ // An empty part still carries its voice or provider options.
303
+ const pieces = splitTextDelta(value.text);
304
+ if (
305
+ pieces.length === 0 &&
306
+ (value.voice != null || value.providerOptions != null)
307
+ ) {
308
+ pieces.push('');
309
+ }
310
+ for (const delta of pieces) {
311
+ if (finished || textStopped) break;
312
+ // The voice applies to every frame of the part; provider
313
+ // options (e.g. a turn break) only to its first frame.
314
+ const frame: Experimental_SpeechStreamTextDeltaFrame = {
315
+ type: SPEECH_STREAM_TEXT_DELTA_FRAME_TYPE,
316
+ delta,
317
+ ...(value.voice != null && { voice: value.voice }),
318
+ ...(first &&
319
+ value.providerOptions != null && {
320
+ providerOptions: value.providerOptions,
321
+ }),
322
+ };
323
+ first = false;
324
+ socket.send(JSON.stringify(frame));
325
+ await waitForWebSocketBufferDrain(socket);
326
+ }
327
+ }
328
+ } finally {
329
+ reader.releaseLock();
330
+ if (textReader === reader) {
331
+ textReader = undefined;
332
+ }
333
+ }
334
+ if (!finished && !textStopped) {
335
+ socket.send(
336
+ JSON.stringify({ type: SPEECH_STREAM_TEXT_DONE_FRAME_TYPE }),
337
+ );
338
+ }
339
+ };
340
+
341
+ connection = connectToWebSocket({
342
+ url,
343
+ protocols,
344
+ headers,
345
+ webSocket,
346
+ abortSignal,
347
+ onAbort: reason => {
348
+ if (finished) return;
349
+ finished = true;
350
+ cleanup();
351
+ controller.error(reason);
352
+ },
353
+ onProcessingError: finishWithError,
354
+ onOpen: socket => {
355
+ socket.send(JSON.stringify(startFrame));
356
+ void sendText(socket).catch(finishWithError);
357
+ },
358
+ onMessageText: async messageText => {
359
+ if (finished) return;
360
+ const part = parseSpeechStreamPart(messageText);
361
+ if (part == null) return;
362
+ if (part.type === 'finish') {
363
+ finished = true;
364
+ controller.enqueue(part);
365
+ controller.close();
366
+ cleanup(1000);
367
+ return;
368
+ }
369
+
370
+ if (part.type === 'error') {
371
+ // Error parts are terminal (envelope rule 5): stop sending text
372
+ // and surface the server's message when the socket closes.
373
+ hasServerErrorPart = true;
374
+ lastServerError = part.error;
375
+ textStopped = true;
376
+ cancelText();
377
+ part.error = await createErrorFromServerErrorPart(
378
+ part.error,
379
+ authMethod,
380
+ 'speech',
381
+ );
382
+ if (finished) return;
383
+ }
384
+
385
+ controller.enqueue(part);
386
+ },
387
+ onSocketError: () => {
388
+ finishWithError(
389
+ new Error('Connection error on AI Gateway speech stream'),
390
+ );
391
+ },
392
+ onClose: ({ code, reason }) => {
393
+ if (hasServerErrorPart) {
394
+ if (finished) return;
395
+ void createErrorFromServerErrorPart(
396
+ lastServerError,
397
+ authMethod,
398
+ 'speech',
399
+ ).then(finishWithError);
400
+ return;
401
+ }
402
+ finishWithError(
403
+ new Error(
404
+ 'AI Gateway speech stream closed before a finish part was received' +
405
+ (code != null
406
+ ? ` (code ${code}${reason ? `: ${reason}` : ''})`
407
+ : '') +
408
+ '.',
409
+ ),
410
+ );
411
+ },
412
+ });
413
+ },
414
+
415
+ cancel: () => {
416
+ if (finished) return;
417
+ finished = true;
418
+ cleanup();
419
+ },
420
+ });
421
+ }
422
+
119
423
  const providerMetadataEntrySchema = () => z.object({}).catchall(z.unknown());
120
424
 
121
425
  const gatewaySpeechWarningSchema = () =>