@ai-sdk/xai 4.0.10 → 4.0.13

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/xai",
3
- "version": "4.0.10",
3
+ "version": "4.0.13",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -29,9 +29,9 @@
29
29
  }
30
30
  },
31
31
  "dependencies": {
32
- "@ai-sdk/openai-compatible": "3.0.7",
32
+ "@ai-sdk/openai-compatible": "3.0.10",
33
33
  "@ai-sdk/provider": "4.0.3",
34
- "@ai-sdk/provider-utils": "5.0.7"
34
+ "@ai-sdk/provider-utils": "5.0.10"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/node": "22.19.19",
@@ -201,6 +201,9 @@ export class XaiImageModel implements ImageModelV4 {
201
201
  ): Promise<Uint8Array> {
202
202
  const { value } = await getFromApi({
203
203
  url,
204
+ // url is a generated-image URL from the provider response; validate it.
205
+ validateUrl: true,
206
+ trustedOrigin: this.config.baseURL,
204
207
  abortSignal,
205
208
  failedResponseHandler: createStatusCodeErrorResponseHandler(),
206
209
  successfulResponseHandler: createBinaryResponseHandler(),
@@ -8,18 +8,20 @@ import {
8
8
  combineHeaders,
9
9
  convertBase64ToUint8Array,
10
10
  createJsonResponseHandler,
11
- getWebSocketConstructor,
11
+ connectToWebSocket,
12
12
  mediaTypeToExtension,
13
13
  parseProviderOptions,
14
14
  postFormDataToApi,
15
- readWebSocketMessageText,
16
15
  safeParseJSON,
17
16
  serializeModelOptions,
18
17
  toWebSocketUrl,
18
+ waitForWebSocketBufferDrain,
19
19
  WORKFLOW_DESERIALIZE,
20
20
  WORKFLOW_SERIALIZE,
21
21
  type FetchFunction,
22
+ type WebSocketConnection,
22
23
  type WebSocketConstructor,
24
+ type WebSocketLike,
23
25
  } from '@ai-sdk/provider-utils';
24
26
  import { z } from 'zod/v4';
25
27
  import { xaiFailedResponseHandler } from './xai-error';
@@ -273,20 +275,26 @@ function createXaiStreamingTranscriptionStream({
273
275
 
274
276
  return new ReadableStream({
275
277
  start: controller => {
276
- const WebSocketConstructor = getWebSocketConstructor(webSocket);
277
- const ws = new WebSocketConstructor(url, undefined, { headers });
278
278
  const doneTexts = new Map<number, string>();
279
+ // per-channel finalized utterances + latest revisable text, used to
280
+ // reconstruct the finish text (xAI's `transcript.done` has empty text):
281
+ const finalizedTexts = new Map<number, string[]>();
282
+ const pendingTexts = new Map<number, string>();
279
283
  let doneDuration: number | undefined;
280
284
  let audioReader:
281
285
  | ReadableStreamDefaultReader<Uint8Array | string>
282
286
  | undefined;
287
+ let connection: WebSocketConnection | undefined;
283
288
 
284
289
  cleanup = (closeCode?: number) => {
285
- abortSignal?.removeEventListener('abort', abort);
286
- void audioReader?.cancel().catch(() => {});
287
- try {
288
- ws.close(closeCode);
289
- } catch {}
290
+ if (audioReader != null) {
291
+ void audioReader.cancel().catch(() => {});
292
+ } else {
293
+ // pre-open failure or abort: cancel the caller's audio stream so an
294
+ // upstream producer piping into it does not hang:
295
+ void audio.cancel().catch(() => {});
296
+ }
297
+ connection?.close(closeCode);
290
298
  };
291
299
 
292
300
  const finishWithError = (error: unknown) => {
@@ -314,117 +322,138 @@ function createXaiStreamingTranscriptionStream({
314
322
  cleanup(1000);
315
323
  };
316
324
 
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 () => {
325
+ const sendAudio = async (socket: WebSocketLike) => {
327
326
  audioReader = audio.getReader();
328
327
  try {
329
328
  while (true) {
330
329
  const { done, value } = await audioReader.read();
331
330
  if (done || finished) break;
332
- ws.send(
331
+ socket.send(
333
332
  value instanceof Uint8Array
334
333
  ? value
335
334
  : convertBase64ToUint8Array(value),
336
335
  );
336
+ // backpressure: pause reads while the socket buffer is full
337
+ await waitForWebSocketBufferDrain(socket);
337
338
  }
338
339
  } finally {
339
340
  audioReader.releaseLock();
341
+ // unlocked again: cleanup must cancel `audio`, not the reader
342
+ audioReader = undefined;
340
343
  }
341
344
  if (!finished) {
342
- ws.send(JSON.stringify({ type: 'audio.done' }));
345
+ socket.send(JSON.stringify({ type: 'audio.done' }));
343
346
  }
344
347
  };
345
348
 
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
- }
349
+ connection = connectToWebSocket({
350
+ url,
351
+ headers,
352
+ webSocket,
353
+ abortSignal,
354
+ onAbort: finishWithError,
355
+ onProcessingError: finishWithError,
356
+ onMessageText: async text => {
357
+ const parsed = await safeParseJSON({ text });
358
+ if (!parsed.success) return;
359
+ const raw = parsed.value as XaiStreamingTranscriptionEvent;
360
+
361
+ if (includeRawChunks) {
362
+ controller.enqueue({ type: 'raw', rawValue: raw });
363
+ }
356
364
 
357
- switch (raw.type) {
358
- case 'transcript.created': {
359
- controller.enqueue({ type: 'stream-start', warnings });
360
- void sendAudio().catch(finishWithError);
365
+ switch (raw.type) {
366
+ case 'transcript.created': {
367
+ controller.enqueue({ type: 'stream-start', warnings });
368
+ const socket = connection?.socket;
369
+ if (socket == null) {
370
+ finishWithError(new Error('WebSocket is not connected.'));
361
371
  break;
362
372
  }
373
+ void sendAudio(socket).catch(finishWithError);
374
+ break;
375
+ }
363
376
 
364
- case 'transcript.partial': {
365
- const id = channelId(raw.channel_index);
377
+ case 'transcript.partial': {
378
+ const id = channelId(raw.channel_index);
379
+ const channelIndex = raw.channel_index ?? 0;
380
+ // only `speech_final` completes an utterance; `is_final`
381
+ // fragments are re-sent/revised later, so they stay partials:
382
+ if (raw.is_final && raw.speech_final) {
366
383
  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
+ if (raw.text) {
385
+ finalizedTexts.set(channelIndex, [
386
+ ...(finalizedTexts.get(channelIndex) ?? []),
387
+ raw.text,
388
+ ]);
384
389
  }
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;
390
+ pendingTexts.delete(channelIndex);
391
+ controller.enqueue({
392
+ type: 'transcript-final',
393
+ id,
394
+ text: raw.text ?? '',
395
+ ...timing,
396
+ channelIndex: raw.channel_index,
397
+ });
398
+ } else {
399
+ pendingTexts.set(channelIndex, raw.text ?? '');
400
+ controller.enqueue({
401
+ type: 'transcript-partial',
402
+ id,
403
+ text: raw.text ?? '',
404
+ startSecond: raw.start,
405
+ durationInSeconds: raw.duration,
406
+ channelIndex: raw.channel_index,
407
+ });
401
408
  }
409
+ break;
402
410
  }
403
- })
404
- .catch(finishWithError);
405
- };
406
411
 
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
- };
412
+ case 'transcript.done': {
413
+ const channelIndex = raw.channel_index ?? 0;
414
+ // `transcript.done` text is empty; fall back to the
415
+ // accumulated utterances plus any trailing unfinalized text:
416
+ const accumulated = [
417
+ ...(finalizedTexts.get(channelIndex) ?? []),
418
+ ...(pendingTexts.get(channelIndex)
419
+ ? [pendingTexts.get(channelIndex) as string]
420
+ : []),
421
+ ].join(' ');
422
+ doneTexts.set(channelIndex, raw.text || accumulated);
423
+ doneDuration = raw.duration ?? doneDuration;
424
+ maybeFinish();
425
+ break;
426
+ }
421
427
 
422
- ws.onclose = () => {
423
- if (finished) return;
424
- finished = true;
425
- cleanup();
426
- controller.close();
427
- };
428
+ case 'error': {
429
+ // xAI STT errors are terminal: surface the server message
430
+ // instead of letting the socket close mask it.
431
+ finishWithError(new Error(raw.message ?? 'xAI STT error'));
432
+ break;
433
+ }
434
+ }
435
+ },
436
+ onSocketError: () => {
437
+ finishWithError(
438
+ new Error(
439
+ 'xAI streaming transcription error.' +
440
+ (webSocket == null
441
+ ? ' Note: the native WebSocket implementation in browsers,' +
442
+ ' Node.js, Deno, and Bun cannot send the Authorization' +
443
+ ' header required by xAI. Pass a header-capable WebSocket' +
444
+ " implementation (e.g. the 'ws' package) via" +
445
+ ' createXai({ webSocket }).'
446
+ : ''),
447
+ ),
448
+ );
449
+ },
450
+ onClose: () => {
451
+ if (finished) return;
452
+ finished = true;
453
+ cleanup();
454
+ controller.close();
455
+ },
456
+ });
428
457
  },
429
458
 
430
459
  cancel: () => {
@@ -428,6 +428,7 @@ export class XaiVideoModel implements Experimental_VideoModelV4 {
428
428
  const { value: statusResponse, responseHeaders: pollHeaders } =
429
429
  await getFromApi({
430
430
  url: `${baseURL}/videos/${requestId}`,
431
+ validateUrl: false,
431
432
  headers: combineHeaders(this.config.headers(), options.headers),
432
433
  successfulResponseHandler: createJsonResponseHandler(
433
434
  xaiVideoStatusResponseSchema,