@ai-sdk/xai 4.0.5 → 4.0.6

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/docs/01-xai.mdx CHANGED
@@ -725,11 +725,38 @@ const result = await transcribe({
725
725
 
726
726
  Includes filler words such as `uh` and `um` in the transcript.
727
727
 
728
+ - **streaming** _object_
729
+
730
+ Options for streaming speech-to-text over WebSocket. Use with `experimental_streamTranscribe`.
731
+ - **interimResults** _boolean_
732
+
733
+ Emits partial transcripts while speech is being processed.
734
+
735
+ - **endpointing** _number_
736
+
737
+ Silence duration in milliseconds before an utterance-final event. Range: 0-5000.
738
+
739
+ - **smartTurn** _number_
740
+
741
+ End-of-turn detection threshold. When set, enables Smart Turn. Range: 0.0-1.0.
742
+
743
+ - **smartTurnTimeout** _number_
744
+
745
+ Maximum silence duration in milliseconds before forcing `speech_final`. Range: 1-5000.
746
+
728
747
  ### Model Capabilities
729
748
 
730
- | Model | Word Timestamps | Diarization | Multichannel |
731
- | --------- | ------------------- | ------------------- | ------------------- |
732
- | `default` | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
749
+ | Model | Request/Response | Streaming | Word Timestamps | Diarization | Multichannel |
750
+ | --------- | ------------------- | ------------------- | ------------------- | ------------------- | ------------------- |
751
+ | `default` | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> | <Check size={18} /> |
752
+
753
+ <Note>
754
+ Word timestamps, diarization results, and segments are only returned on the
755
+ request/response path (`transcribe`). Streaming transcription
756
+ (`experimental_streamTranscribe`) emits partial and final transcript text with
757
+ utterance-level timing, but does not surface per-word timestamps, speaker
758
+ labels, or segments.
759
+ </Note>
733
760
 
734
761
  ## Image Models
735
762
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/xai",
3
- "version": "4.0.5",
3
+ "version": "4.0.6",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -29,17 +29,17 @@
29
29
  }
30
30
  },
31
31
  "dependencies": {
32
- "@ai-sdk/provider": "4.0.1",
33
- "@ai-sdk/provider-utils": "5.0.4",
34
- "@ai-sdk/openai-compatible": "3.0.4"
32
+ "@ai-sdk/openai-compatible": "3.0.5",
33
+ "@ai-sdk/provider": "4.0.2",
34
+ "@ai-sdk/provider-utils": "5.0.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "22.19.19",
38
38
  "tsup": "^8.5.1",
39
39
  "typescript": "5.8.3",
40
40
  "zod": "3.25.76",
41
- "@vercel/ai-tsconfig": "0.0.0",
42
- "@ai-sdk/test-server": "2.0.0"
41
+ "@ai-sdk/test-server": "2.0.0",
42
+ "@vercel/ai-tsconfig": "0.0.0"
43
43
  },
44
44
  "peerDependencies": {
45
45
  "zod": "^3.25.76 || ^4.1.8"
@@ -16,6 +16,7 @@ import {
16
16
  withoutTrailingSlash,
17
17
  withUserAgentSuffix,
18
18
  type FetchFunction,
19
+ type WebSocketConstructor,
19
20
  } from '@ai-sdk/provider-utils';
20
21
  import { XaiChatLanguageModel } from './xai-chat-language-model';
21
22
  import type { XaiChatModelId } from './xai-chat-language-model-options';
@@ -129,6 +130,12 @@ export interface XaiProviderSettings {
129
130
  * or to provide a custom fetch implementation for e.g. testing.
130
131
  */
131
132
  fetch?: FetchFunction;
133
+
134
+ /**
135
+ * Custom WebSocket implementation. Required in runtimes whose native
136
+ * WebSocket constructor does not support headers for xAI streaming STT.
137
+ */
138
+ webSocket?: WebSocketConstructor;
132
139
  }
133
140
 
134
141
  export function createXai(options: XaiProviderSettings = {}): XaiProvider {
@@ -210,6 +217,7 @@ export function createXai(options: XaiProviderSettings = {}): XaiProvider {
210
217
  baseURL,
211
218
  headers: getHeaders,
212
219
  fetch: options.fetch,
220
+ webSocket: options.webSocket,
213
221
  });
214
222
  };
215
223
 
@@ -61,6 +61,33 @@ export const xaiTranscriptionModelOptionsSchema = lazySchema(() =>
61
61
  * Include filler words such as "uh" and "um" in the transcript.
62
62
  */
63
63
  fillerWords: z.boolean().nullish(),
64
+
65
+ /**
66
+ * Options for streaming speech-to-text over WebSocket.
67
+ */
68
+ streaming: z
69
+ .object({
70
+ /**
71
+ * Emit interim transcript results while speech is being processed.
72
+ */
73
+ interimResults: z.boolean().optional(),
74
+
75
+ /**
76
+ * Silence duration in milliseconds before an utterance-final event.
77
+ */
78
+ endpointing: z.number().int().min(0).max(5000).optional(),
79
+
80
+ /**
81
+ * End-of-turn detection threshold. When set, enables Smart Turn.
82
+ */
83
+ smartTurn: z.number().min(0).max(1).optional(),
84
+
85
+ /**
86
+ * Maximum silence duration in milliseconds before forcing speech_final.
87
+ */
88
+ smartTurnTimeout: z.number().int().min(1).max(5000).optional(),
89
+ })
90
+ .optional(),
64
91
  }),
65
92
  ),
66
93
  );
@@ -1,30 +1,56 @@
1
- import type { SharedV4Warning, TranscriptionModelV4 } from '@ai-sdk/provider';
1
+ import {
2
+ InvalidArgumentError,
3
+ type Experimental_TranscriptionModelV4StreamOptions as TranscriptionModelV4StreamOptions,
4
+ type SharedV4Warning,
5
+ type TranscriptionModelV4,
6
+ } from '@ai-sdk/provider';
2
7
  import {
3
8
  combineHeaders,
4
9
  convertBase64ToUint8Array,
5
10
  createJsonResponseHandler,
11
+ getWebSocketConstructor,
6
12
  mediaTypeToExtension,
7
13
  parseProviderOptions,
8
14
  postFormDataToApi,
15
+ readWebSocketMessageText,
16
+ safeParseJSON,
9
17
  serializeModelOptions,
18
+ toWebSocketUrl,
10
19
  WORKFLOW_DESERIALIZE,
11
20
  WORKFLOW_SERIALIZE,
12
21
  type FetchFunction,
22
+ type WebSocketConstructor,
13
23
  } from '@ai-sdk/provider-utils';
14
24
  import { z } from 'zod/v4';
15
25
  import { xaiFailedResponseHandler } from './xai-error';
16
- import { xaiTranscriptionModelOptionsSchema } from './xai-transcription-model-options';
26
+ import {
27
+ xaiTranscriptionModelOptionsSchema,
28
+ type XaiTranscriptionModelOptions,
29
+ } from './xai-transcription-model-options';
17
30
 
18
31
  interface XaiTranscriptionModelConfig {
19
32
  provider: string;
20
33
  baseURL: string | undefined;
21
34
  headers?: () => Record<string, string | undefined>;
22
35
  fetch?: FetchFunction;
36
+ webSocket?: WebSocketConstructor;
23
37
  _internal?: {
24
38
  currentDate?: () => Date;
25
39
  };
26
40
  }
27
41
 
42
+ type XaiStreamingTranscriptionEvent = {
43
+ type?: string;
44
+ text?: string;
45
+ words?: Array<{ text?: string; start?: number; end?: number }>;
46
+ is_final?: boolean;
47
+ speech_final?: boolean;
48
+ start?: number;
49
+ duration?: number;
50
+ channel_index?: number;
51
+ message?: string;
52
+ };
53
+
28
54
  export class XaiTranscriptionModel implements TranscriptionModelV4 {
29
55
  readonly specificationVersion = 'v4';
30
56
 
@@ -148,6 +174,359 @@ export class XaiTranscriptionModel implements TranscriptionModelV4 {
148
174
  },
149
175
  };
150
176
  }
177
+
178
+ async doStream(
179
+ options: TranscriptionModelV4StreamOptions,
180
+ ): Promise<
181
+ Awaited<ReturnType<NonNullable<TranscriptionModelV4['doStream']>>>
182
+ > {
183
+ const currentDate = this.config._internal?.currentDate?.() ?? new Date();
184
+ const warnings: SharedV4Warning[] = [];
185
+ const xaiOptions = await parseProviderOptions({
186
+ provider: 'xai',
187
+ providerOptions: options.providerOptions,
188
+ schema: xaiTranscriptionModelOptionsSchema,
189
+ });
190
+
191
+ if (xaiOptions?.multichannel === true && xaiOptions.channels == null) {
192
+ throw new InvalidArgumentError({
193
+ argument: 'providerOptions',
194
+ message:
195
+ 'providerOptions.xai.channels is required when providerOptions.xai.multichannel is true',
196
+ });
197
+ }
198
+
199
+ if (xaiOptions?.format != null) {
200
+ warnings.push({
201
+ type: 'unsupported',
202
+ feature: 'providerOptions.xai.format',
203
+ details: 'xAI streaming transcription does not support format.',
204
+ });
205
+ }
206
+
207
+ if (
208
+ xaiOptions?.audioFormat == null &&
209
+ !isKnownInputAudioFormat(options.inputAudioFormat.type)
210
+ ) {
211
+ warnings.push({
212
+ type: 'other',
213
+ message:
214
+ `Unrecognized inputAudioFormat.type "${options.inputAudioFormat.type}"; ` +
215
+ `falling back to raw PCM encoding. ` +
216
+ `Use audio/pcm, audio/pcmu, or audio/pcma, ` +
217
+ `or set providerOptions.xai.audioFormat explicitly.`,
218
+ });
219
+ }
220
+
221
+ const url = buildXaiStreamingTranscriptionUrl({
222
+ baseURL: this.config.baseURL ?? 'https://api.x.ai/v1',
223
+ inputAudioFormat: options.inputAudioFormat,
224
+ providerOptions: xaiOptions,
225
+ });
226
+ const headers = combineHeaders(this.config.headers?.(), options.headers);
227
+
228
+ return {
229
+ request: { body: url.toString() },
230
+ response: {
231
+ timestamp: currentDate,
232
+ modelId: this.modelId,
233
+ },
234
+ stream: createXaiStreamingTranscriptionStream({
235
+ webSocket: this.config.webSocket,
236
+ url,
237
+ headers,
238
+ warnings,
239
+ language: xaiOptions?.language ?? undefined,
240
+ expectedDoneCount:
241
+ xaiOptions?.multichannel === true ? xaiOptions.channels! : 1,
242
+ audio: options.audio,
243
+ abortSignal: options.abortSignal,
244
+ includeRawChunks: options.includeRawChunks,
245
+ }),
246
+ };
247
+ }
248
+ }
249
+
250
+ function createXaiStreamingTranscriptionStream({
251
+ webSocket,
252
+ url,
253
+ headers,
254
+ warnings,
255
+ language,
256
+ expectedDoneCount,
257
+ audio,
258
+ abortSignal,
259
+ includeRawChunks,
260
+ }: {
261
+ webSocket: WebSocketConstructor | undefined;
262
+ url: URL;
263
+ headers: Record<string, string | undefined>;
264
+ warnings: SharedV4Warning[];
265
+ language: string | undefined;
266
+ expectedDoneCount: number;
267
+ audio: ReadableStream<Uint8Array | string>;
268
+ abortSignal: AbortSignal | undefined;
269
+ includeRawChunks: boolean | undefined;
270
+ }) {
271
+ let finished = false;
272
+ let cleanup: (closeCode?: number) => void = () => {};
273
+
274
+ return new ReadableStream({
275
+ start: controller => {
276
+ const WebSocketConstructor = getWebSocketConstructor(webSocket);
277
+ const ws = new WebSocketConstructor(url, undefined, { headers });
278
+ const doneTexts = new Map<number, string>();
279
+ let doneDuration: number | undefined;
280
+ let audioReader:
281
+ | ReadableStreamDefaultReader<Uint8Array | string>
282
+ | undefined;
283
+
284
+ cleanup = (closeCode?: number) => {
285
+ abortSignal?.removeEventListener('abort', abort);
286
+ void audioReader?.cancel().catch(() => {});
287
+ try {
288
+ ws.close(closeCode);
289
+ } catch {}
290
+ };
291
+
292
+ const finishWithError = (error: unknown) => {
293
+ if (finished) return;
294
+ finished = true;
295
+ cleanup();
296
+ controller.error(error);
297
+ };
298
+
299
+ const maybeFinish = () => {
300
+ if (finished || doneTexts.size < expectedDoneCount) return;
301
+ finished = true;
302
+ const text = [...doneTexts.entries()]
303
+ .sort(([a], [b]) => a - b)
304
+ .map(([, value]) => value)
305
+ .join('\n');
306
+ controller.enqueue({
307
+ type: 'finish',
308
+ text,
309
+ segments: [],
310
+ language,
311
+ durationInSeconds: doneDuration,
312
+ });
313
+ controller.close();
314
+ cleanup(1000);
315
+ };
316
+
317
+ const abort = () => {
318
+ finishWithError(abortSignal?.reason ?? new Error('Aborted'));
319
+ };
320
+ if (abortSignal?.aborted) {
321
+ abort();
322
+ return;
323
+ }
324
+ abortSignal?.addEventListener('abort', abort, { once: true });
325
+
326
+ const sendAudio = async () => {
327
+ audioReader = audio.getReader();
328
+ try {
329
+ while (true) {
330
+ const { done, value } = await audioReader.read();
331
+ if (done || finished) break;
332
+ ws.send(
333
+ value instanceof Uint8Array
334
+ ? value
335
+ : convertBase64ToUint8Array(value),
336
+ );
337
+ }
338
+ } finally {
339
+ audioReader.releaseLock();
340
+ }
341
+ if (!finished) {
342
+ ws.send(JSON.stringify({ type: 'audio.done' }));
343
+ }
344
+ };
345
+
346
+ ws.onmessage = event => {
347
+ void readWebSocketMessageText(event.data)
348
+ .then(async text => {
349
+ const parsed = await safeParseJSON({ text });
350
+ if (!parsed.success) return;
351
+ const raw = parsed.value as XaiStreamingTranscriptionEvent;
352
+
353
+ if (includeRawChunks) {
354
+ controller.enqueue({ type: 'raw', rawValue: raw });
355
+ }
356
+
357
+ switch (raw.type) {
358
+ case 'transcript.created': {
359
+ controller.enqueue({ type: 'stream-start', warnings });
360
+ void sendAudio().catch(finishWithError);
361
+ break;
362
+ }
363
+
364
+ case 'transcript.partial': {
365
+ const id = channelId(raw.channel_index);
366
+ const timing = timingFromXaiEvent(raw);
367
+ if (raw.is_final) {
368
+ controller.enqueue({
369
+ type: 'transcript-final',
370
+ id,
371
+ text: raw.text ?? '',
372
+ ...timing,
373
+ channelIndex: raw.channel_index,
374
+ });
375
+ } else {
376
+ controller.enqueue({
377
+ type: 'transcript-partial',
378
+ id,
379
+ text: raw.text ?? '',
380
+ startSecond: raw.start,
381
+ durationInSeconds: raw.duration,
382
+ channelIndex: raw.channel_index,
383
+ });
384
+ }
385
+ break;
386
+ }
387
+
388
+ case 'transcript.done': {
389
+ const channelIndex = raw.channel_index ?? 0;
390
+ doneTexts.set(channelIndex, raw.text ?? '');
391
+ doneDuration = raw.duration ?? doneDuration;
392
+ maybeFinish();
393
+ break;
394
+ }
395
+
396
+ case 'error': {
397
+ // xAI STT errors are terminal: surface the server message
398
+ // instead of letting the socket close mask it.
399
+ finishWithError(new Error(raw.message ?? 'xAI STT error'));
400
+ break;
401
+ }
402
+ }
403
+ })
404
+ .catch(finishWithError);
405
+ };
406
+
407
+ ws.onerror = () => {
408
+ finishWithError(
409
+ new Error(
410
+ 'xAI streaming transcription error.' +
411
+ (webSocket == null
412
+ ? ' Note: the native WebSocket implementation in browsers,' +
413
+ ' Node.js, Deno, and Bun cannot send the Authorization' +
414
+ ' header required by xAI. Pass a header-capable WebSocket' +
415
+ " implementation (e.g. the 'ws' package) via" +
416
+ ' createXai({ webSocket }).'
417
+ : ''),
418
+ ),
419
+ );
420
+ };
421
+
422
+ ws.onclose = () => {
423
+ if (finished) return;
424
+ finished = true;
425
+ cleanup();
426
+ controller.close();
427
+ };
428
+ },
429
+
430
+ cancel: () => {
431
+ if (finished) return;
432
+ finished = true;
433
+ cleanup();
434
+ },
435
+ });
436
+ }
437
+
438
+ function buildXaiStreamingTranscriptionUrl({
439
+ baseURL,
440
+ inputAudioFormat,
441
+ providerOptions,
442
+ }: {
443
+ baseURL: string;
444
+ inputAudioFormat: TranscriptionModelV4StreamOptions['inputAudioFormat'];
445
+ providerOptions: XaiTranscriptionModelOptions | undefined;
446
+ }) {
447
+ const url = toWebSocketUrl(`${baseURL}/stt`);
448
+
449
+ appendSearchParam(
450
+ url,
451
+ 'sample_rate',
452
+ providerOptions?.sampleRate ?? inputAudioFormat.rate,
453
+ );
454
+ appendSearchParam(
455
+ url,
456
+ 'encoding',
457
+ providerOptions?.audioFormat ??
458
+ encodingFromInputAudioFormat(inputAudioFormat.type),
459
+ );
460
+ appendSearchParam(url, 'language', providerOptions?.language);
461
+ appendSearchParam(url, 'diarize', providerOptions?.diarize);
462
+ appendSearchParam(url, 'filler_words', providerOptions?.fillerWords);
463
+ appendSearchParam(url, 'multichannel', providerOptions?.multichannel);
464
+ appendSearchParam(url, 'channels', providerOptions?.channels);
465
+ appendSearchParam(
466
+ url,
467
+ 'interim_results',
468
+ providerOptions?.streaming?.interimResults,
469
+ );
470
+ appendSearchParam(
471
+ url,
472
+ 'endpointing',
473
+ providerOptions?.streaming?.endpointing,
474
+ );
475
+ appendSearchParam(url, 'smart_turn', providerOptions?.streaming?.smartTurn);
476
+ appendSearchParam(
477
+ url,
478
+ 'smart_turn_timeout',
479
+ providerOptions?.streaming?.smartTurnTimeout,
480
+ );
481
+
482
+ if (providerOptions?.keyterm != null) {
483
+ const keyterms = Array.isArray(providerOptions.keyterm)
484
+ ? providerOptions.keyterm
485
+ : [providerOptions.keyterm];
486
+ for (const keyterm of keyterms) {
487
+ url.searchParams.append('keyterm', keyterm);
488
+ }
489
+ }
490
+
491
+ return url;
492
+ }
493
+
494
+ function appendSearchParam(
495
+ url: URL,
496
+ key: string,
497
+ value: string | number | boolean | null | undefined,
498
+ ) {
499
+ if (value != null) {
500
+ url.searchParams.set(key, String(value));
501
+ }
502
+ }
503
+
504
+ function isKnownInputAudioFormat(type: string): boolean {
505
+ return type === 'audio/pcm' || type === 'audio/pcmu' || type === 'audio/pcma';
506
+ }
507
+
508
+ function encodingFromInputAudioFormat(type: string): 'pcm' | 'mulaw' | 'alaw' {
509
+ switch (type) {
510
+ case 'audio/pcmu':
511
+ return 'mulaw';
512
+ case 'audio/pcma':
513
+ return 'alaw';
514
+ default:
515
+ return 'pcm';
516
+ }
517
+ }
518
+
519
+ function channelId(channelIndex: number | undefined): string | undefined {
520
+ return channelIndex == null ? undefined : `channel-${channelIndex}`;
521
+ }
522
+
523
+ function timingFromXaiEvent(event: XaiStreamingTranscriptionEvent) {
524
+ return {
525
+ ...(event.start != null ? { startSecond: event.start } : {}),
526
+ ...(event.start != null && event.duration != null
527
+ ? { endSecond: event.start + event.duration }
528
+ : {}),
529
+ };
151
530
  }
152
531
 
153
532
  const xaiTranscriptionResponseSchema = z.object({