@ai-sdk/gateway 4.0.121 → 4.0.123

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.
@@ -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 = () =>
@@ -0,0 +1,71 @@
1
+ import { getErrorMessage } from '@ai-sdk/provider';
2
+ import { asGatewayError, createGatewayErrorFromResponse } from './errors';
3
+
4
+ /**
5
+ * `asGatewayError` is async while the WebSocket event handlers are
6
+ * synchronous, hence this helper.
7
+ */
8
+ export async function errorControllerWithGatewayError<PART>(
9
+ controller: ReadableStreamDefaultController<PART>,
10
+ error: unknown,
11
+ authMethod: 'api-key' | 'oidc' | undefined,
12
+ ): Promise<void> {
13
+ controller.error(await asGatewayError(error, authMethod));
14
+ }
15
+
16
+ /**
17
+ * Extract a human-readable message from an `error` stream part's payload for
18
+ * the terminal close error.
19
+ */
20
+ function getServerErrorMessage(error: unknown): string {
21
+ if (
22
+ error != null &&
23
+ typeof error === 'object' &&
24
+ 'message' in error &&
25
+ typeof error.message === 'string'
26
+ ) {
27
+ return error.message;
28
+ }
29
+ // JSON-stringifies object payloads (`String` would yield '[object Object]').
30
+ return getErrorMessage(error);
31
+ }
32
+
33
+ /** Canonical status codes for server error-part types (no HTTP status exists on the WebSocket). */
34
+ const SERVER_ERROR_STATUS_CODES: Record<string, number> = {
35
+ authentication_error: 401,
36
+ failed_dependency: 424,
37
+ forbidden: 403,
38
+ internal_server_error: 500,
39
+ invalid_request_error: 400,
40
+ model_not_found: 404,
41
+ rate_limit_exceeded: 429,
42
+ };
43
+
44
+ /**
45
+ * Maps a server error-part payload (`{ message, type }`) to the public
46
+ * Gateway error class for its type; unknown shapes keep the generic message.
47
+ */
48
+ export async function createErrorFromServerErrorPart(
49
+ error: unknown,
50
+ authMethod: 'api-key' | 'oidc' | undefined,
51
+ streamLabel = 'transcription',
52
+ ): Promise<unknown> {
53
+ if (
54
+ typeof error === 'object' &&
55
+ error != null &&
56
+ 'message' in error &&
57
+ typeof error.message === 'string' &&
58
+ 'type' in error &&
59
+ typeof error.type === 'string' &&
60
+ error.type in SERVER_ERROR_STATUS_CODES
61
+ ) {
62
+ return createGatewayErrorFromResponse({
63
+ response: { error: { message: error.message, type: error.type } },
64
+ statusCode: SERVER_ERROR_STATUS_CODES[error.type],
65
+ authMethod,
66
+ });
67
+ }
68
+ return new Error(
69
+ `AI Gateway ${streamLabel} stream failed: ${getServerErrorMessage(error)}`,
70
+ );
71
+ }
@@ -4,6 +4,8 @@ export type GatewayTranscriptionModelId =
4
4
  | 'google/gemini-3.5-transcribe-live'
5
5
  | 'microsoft/mai-transcribe-2'
6
6
  | 'microsoft/mai-transcribe-2-streaming'
7
+ | 'mistral/voxtral-mini-transcribe-3'
8
+ | 'mistral/voxtral-small-transcribe-3'
7
9
  | 'openai/gpt-4o-mini-transcribe'
8
10
  | 'openai/gpt-4o-transcribe'
9
11
  | 'openai/gpt-realtime-whisper'
@@ -30,9 +30,13 @@ import {
30
30
  type WebSocketLike,
31
31
  } from '@ai-sdk/provider-utils';
32
32
  import { z } from './zod';
33
- import { asGatewayError, createGatewayErrorFromResponse } from './errors';
33
+ import { asGatewayError } from './errors';
34
34
  import { parseAuthMethod } from './errors/parse-auth-method';
35
35
  import type { GatewayConfig } from './gateway-config';
36
+ import {
37
+ createErrorFromServerErrorPart,
38
+ errorControllerWithGatewayError,
39
+ } from './gateway-stream-errors';
36
40
  import { VERCEL_AI_GATEWAY_TEAM_HEADER } from './gateway-headers';
37
41
  import {
38
42
  GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
@@ -247,8 +251,18 @@ export function toGatewayTranscriptionUrl(
247
251
  * case-insensitive because `combineHeaders` does not normalize casing and
248
252
  * callers can pass arbitrary casing via the `headers` option.
249
253
  */
250
- function getProtocolsFromHeaders(
254
+ export function getProtocolsFromHeaders(
251
255
  headers: Record<string, string | undefined>,
256
+ {
257
+ marker = GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
258
+ getProtocols = getGatewayTranscriptionProtocols,
259
+ }: {
260
+ marker?: string;
261
+ getProtocols?: (
262
+ token: string,
263
+ options: { teamIdOrSlug?: string },
264
+ ) => string[];
265
+ } = {},
252
266
  ): string[] {
253
267
  const normalizedHeaders = normalizeHeaders(headers);
254
268
  const authorization = normalizedHeaders.authorization;
@@ -257,8 +271,8 @@ function getProtocolsFromHeaders(
257
271
  : undefined;
258
272
 
259
273
  return token == null
260
- ? [GATEWAY_TRANSCRIPTION_SUBPROTOCOL]
261
- : getGatewayTranscriptionProtocols(token, {
274
+ ? [marker]
275
+ : getProtocols(token, {
262
276
  teamIdOrSlug: normalizedHeaders[VERCEL_AI_GATEWAY_TEAM_HEADER],
263
277
  });
264
278
  }
@@ -493,71 +507,3 @@ const gatewayTranscriptionResponseSchema = lazySchema(() =>
493
507
  }),
494
508
  ),
495
509
  );
496
-
497
- /**
498
- * `asGatewayError` is async while the WebSocket event handlers are
499
- * synchronous, hence this helper.
500
- */
501
- async function errorControllerWithGatewayError(
502
- controller: ReadableStreamDefaultController<TranscriptionModelV4StreamPart>,
503
- error: unknown,
504
- authMethod: 'api-key' | 'oidc' | undefined,
505
- ): Promise<void> {
506
- controller.error(await asGatewayError(error, authMethod));
507
- }
508
-
509
- /**
510
- * Extract a human-readable message from an `error` stream part's payload for
511
- * the terminal close error.
512
- */
513
- function getServerErrorMessage(error: unknown): string {
514
- if (
515
- error != null &&
516
- typeof error === 'object' &&
517
- 'message' in error &&
518
- typeof error.message === 'string'
519
- ) {
520
- return error.message;
521
- }
522
- // JSON-stringifies object payloads (`String` would yield '[object Object]').
523
- return getErrorMessage(error);
524
- }
525
-
526
- /** Canonical status codes for server error-part types (no HTTP status exists on the WebSocket). */
527
- const SERVER_ERROR_STATUS_CODES: Record<string, number> = {
528
- authentication_error: 401,
529
- failed_dependency: 424,
530
- forbidden: 403,
531
- internal_server_error: 500,
532
- invalid_request_error: 400,
533
- model_not_found: 404,
534
- rate_limit_exceeded: 429,
535
- };
536
-
537
- /**
538
- * Maps a server error-part payload (`{ message, type }`) to the public
539
- * Gateway error class for its type; unknown shapes keep the generic message.
540
- */
541
- async function createErrorFromServerErrorPart(
542
- error: unknown,
543
- authMethod: 'api-key' | 'oidc' | undefined,
544
- ): Promise<unknown> {
545
- if (
546
- typeof error === 'object' &&
547
- error != null &&
548
- 'message' in error &&
549
- typeof error.message === 'string' &&
550
- 'type' in error &&
551
- typeof error.type === 'string' &&
552
- error.type in SERVER_ERROR_STATUS_CODES
553
- ) {
554
- return createGatewayErrorFromResponse({
555
- response: { error: { message: error.message, type: error.type } },
556
- statusCode: SERVER_ERROR_STATUS_CODES[error.type],
557
- authMethod,
558
- });
559
- }
560
- return new Error(
561
- `AI Gateway transcription stream failed: ${getServerErrorMessage(error)}`,
562
- );
563
- }
package/src/index.ts CHANGED
@@ -8,11 +8,13 @@ export type { GatewayModelId } from './gateway-language-model-settings';
8
8
  export {
9
9
  GATEWAY_AUTH_SUBPROTOCOL_PREFIX,
10
10
  GATEWAY_REALTIME_SUBPROTOCOL,
11
+ GATEWAY_SPEECH_SUBPROTOCOL,
11
12
  GATEWAY_TEAM_SUBPROTOCOL_PREFIX,
12
13
  GATEWAY_TRANSCRIPTION_SUBPROTOCOL,
13
14
  getGatewayRealtimeAuthToken,
14
15
  getGatewayRealtimeProtocols,
15
16
  getGatewayRealtimeTeamIdOrSlug,
17
+ getGatewaySpeechProtocols,
16
18
  getGatewayTranscriptionProtocols,
17
19
  } from './gateway-realtime-auth';
18
20
  export type { GatewayRealtimeModelId } from './gateway-realtime-model-settings';