@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/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @ai-sdk/xai
2
2
 
3
+ ## 4.0.13
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [31c7be8]
8
+ - @ai-sdk/provider-utils@5.0.10
9
+ - @ai-sdk/openai-compatible@3.0.10
10
+
11
+ ## 4.0.12
12
+
13
+ ### Patch Changes
14
+
15
+ - 4be62c1: fix(provider-utils): validate provider-response URLs in `getFromApi`
16
+
17
+ `getFromApi` now has a `validateUrl` flag. It is optional so existing callers keep compiling (omitting it behaves like `false`, i.e. no validation), but all AI SDK provider packages set it explicitly at every call site so each one makes a visible trust decision. When `true`, the URL is routed through `fetchWithValidatedRedirects` — the same guard used by `downloadBlob` — which rejects private/loopback/link-local targets, re-validates every redirect hop, strips proxy/metadata/cookie request headers, and drops all caller headers except the user-agent on cross-origin redirects (custom API-key headers must not follow a redirect off-origin any more than `Authorization` may); blocked URLs throw `DownloadError`. It is enabled at the image/video/audio download and polling call sites where the URL comes from a provider response body; URLs built from developer-configured endpoints pass `validateUrl: false` and are unaffected.
18
+
19
+ A new optional `credentialedOrigin` withholds caller headers unless the URL is same-origin with it, so the API key is not sent to a response-supplied host on a different origin.
20
+
21
+ A new optional `trustedOrigin` exempts URLs (and redirect hops) that are same-origin with the developer-configured provider endpoint from target validation, so self-hosted and localhost deployments whose response URLs point back at the configured host keep working; all other hops are still validated.
22
+
23
+ Also closes range gaps in `validateDownloadUrl` (IPv4 `224.0.0.0/4` multicast and the TEST-NET documentation ranges `192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`; IPv6 documentation ranges `2001:db8::/32` and `3fff::/20`), and follows only the fetch-spec redirect status codes (301/302/303/307/308) — a `Location` header on any other status is not followed. This guard performs string/literal checks only and does not resolve DNS; hostnames that resolve to private addresses and DNS rebinding remain out of scope and must be constrained at the network layer (or by injecting a Node `fetch` that pins the resolved IP at connect time) for server deployments handling untrusted URLs. See `contributing/secure-url-handling.md`.
24
+
25
+ - Updated dependencies [4be62c1]
26
+ - Updated dependencies [7805e4a]
27
+ - Updated dependencies [cd12954]
28
+ - @ai-sdk/provider-utils@5.0.9
29
+ - @ai-sdk/openai-compatible@3.0.9
30
+
31
+ ## 4.0.11
32
+
33
+ ### Patch Changes
34
+
35
+ - e193290: Add `connectToWebSocket` to `@ai-sdk/provider-utils`: a shared WebSocket connect layer (constructor resolution, header hygiene, abort wiring, message decoding) analogous to `postToApi` for HTTP. The openai and xai streaming transcription models now use it instead of hand-rolled connects. For openai and xai this also means WebSocket constructor failures now surface as stream errors instead of throwing synchronously from `doStream`, an already-aborted signal no longer constructs a socket, and the caller's audio stream is cancelled on pre-open failures. Messages are processed in order with close handling deferred behind pending frames, audio send loops apply backpressure via the socket's bufferedAmount, and failed sends cancel the caller's audio stream.
36
+ - e193290: Strip undefined header values before the streaming transcription WebSocket constructor (header-capable implementations like `ws` throw on undefined values).
37
+ - e193290: Fix streaming transcription stream parts against the live xAI STT API: `transcript-final` was emitted for every `is_final: true` event, but xAI re-sends the finalized text with `speech_final: false` before the `speech_final: true` event (duplicating finals) and also finalizes _fragments_ whose text the eventual `speech_final` event merges and re-punctuates (later-revised finals). `transcript-final` is now only emitted on `speech_final: true`; finalized fragments surface as `transcript-partial`. Additionally, xAI's `transcript.done` event arrives with an empty `text`, which produced an empty `finish.text` and made `experimental_streamTranscribe` throw `NoTranscriptGeneratedError` despite a full transcript having streamed — the finish text now falls back to the accumulated finalized utterances (plus any trailing unfinalized text).
38
+ - Updated dependencies [e193290]
39
+ - @ai-sdk/provider-utils@5.0.8
40
+ - @ai-sdk/openai-compatible@3.0.8
41
+
3
42
  ## 4.0.10
4
43
 
5
44
  ### Patch Changes
package/dist/index.js CHANGED
@@ -1181,6 +1181,9 @@ var XaiImageModel = class _XaiImageModel {
1181
1181
  async downloadImage(url, abortSignal) {
1182
1182
  const { value } = await getFromApi({
1183
1183
  url,
1184
+ // url is a generated-image URL from the provider response; validate it.
1185
+ validateUrl: true,
1186
+ trustedOrigin: this.config.baseURL,
1184
1187
  abortSignal,
1185
1188
  failedResponseHandler: createStatusCodeErrorResponseHandler(),
1186
1189
  successfulResponseHandler: createBinaryResponseHandler(),
@@ -3474,7 +3477,7 @@ var xaiTools = {
3474
3477
  };
3475
3478
 
3476
3479
  // src/version.ts
3477
- var VERSION = true ? "4.0.10" : "0.0.0-test";
3480
+ var VERSION = true ? "4.0.13" : "0.0.0-test";
3478
3481
 
3479
3482
  // src/files/xai-files.ts
3480
3483
  import {
@@ -3912,6 +3915,7 @@ var XaiVideoModel = class {
3912
3915
  }
3913
3916
  const { value: statusResponse, responseHeaders: pollHeaders } = await getFromApi2({
3914
3917
  url: `${baseURL}/videos/${requestId}`,
3918
+ validateUrl: false,
3915
3919
  headers: combineHeaders5(this.config.headers(), options.headers),
3916
3920
  successfulResponseHandler: createJsonResponseHandler5(
3917
3921
  xaiVideoStatusResponseSchema
@@ -4172,14 +4176,14 @@ import {
4172
4176
  combineHeaders as combineHeaders7,
4173
4177
  convertBase64ToUint8Array,
4174
4178
  createJsonResponseHandler as createJsonResponseHandler6,
4175
- getWebSocketConstructor,
4179
+ connectToWebSocket,
4176
4180
  mediaTypeToExtension,
4177
4181
  parseProviderOptions as parseProviderOptions9,
4178
4182
  postFormDataToApi as postFormDataToApi2,
4179
- readWebSocketMessageText,
4180
4183
  safeParseJSON as safeParseJSON2,
4181
4184
  serializeModelOptions as serializeModelOptions5,
4182
4185
  toWebSocketUrl,
4186
+ waitForWebSocketBufferDrain,
4183
4187
  WORKFLOW_DESERIALIZE as WORKFLOW_DESERIALIZE5,
4184
4188
  WORKFLOW_SERIALIZE as WORKFLOW_SERIALIZE5
4185
4189
  } from "@ai-sdk/provider-utils";
@@ -4430,19 +4434,21 @@ function createXaiStreamingTranscriptionStream({
4430
4434
  };
4431
4435
  return new ReadableStream({
4432
4436
  start: (controller) => {
4433
- const WebSocketConstructor = getWebSocketConstructor(webSocket);
4434
- const ws = new WebSocketConstructor(url, void 0, { headers });
4435
4437
  const doneTexts = /* @__PURE__ */ new Map();
4438
+ const finalizedTexts = /* @__PURE__ */ new Map();
4439
+ const pendingTexts = /* @__PURE__ */ new Map();
4436
4440
  let doneDuration;
4437
4441
  let audioReader;
4442
+ let connection;
4438
4443
  cleanup = (closeCode) => {
4439
- abortSignal == null ? void 0 : abortSignal.removeEventListener("abort", abort);
4440
- void (audioReader == null ? void 0 : audioReader.cancel().catch(() => {
4441
- }));
4442
- try {
4443
- ws.close(closeCode);
4444
- } catch (e) {
4444
+ if (audioReader != null) {
4445
+ void audioReader.cancel().catch(() => {
4446
+ });
4447
+ } else {
4448
+ void audio.cancel().catch(() => {
4449
+ });
4445
4450
  }
4451
+ connection == null ? void 0 : connection.close(closeCode);
4446
4452
  };
4447
4453
  const finishWithError = (error) => {
4448
4454
  if (finished) return;
@@ -4464,35 +4470,34 @@ function createXaiStreamingTranscriptionStream({
4464
4470
  controller.close();
4465
4471
  cleanup(1e3);
4466
4472
  };
4467
- const abort = () => {
4468
- var _a;
4469
- finishWithError((_a = abortSignal == null ? void 0 : abortSignal.reason) != null ? _a : new Error("Aborted"));
4470
- };
4471
- if (abortSignal == null ? void 0 : abortSignal.aborted) {
4472
- abort();
4473
- return;
4474
- }
4475
- abortSignal == null ? void 0 : abortSignal.addEventListener("abort", abort, { once: true });
4476
- const sendAudio = async () => {
4473
+ const sendAudio = async (socket) => {
4477
4474
  audioReader = audio.getReader();
4478
4475
  try {
4479
4476
  while (true) {
4480
4477
  const { done, value } = await audioReader.read();
4481
4478
  if (done || finished) break;
4482
- ws.send(
4479
+ socket.send(
4483
4480
  value instanceof Uint8Array ? value : convertBase64ToUint8Array(value)
4484
4481
  );
4482
+ await waitForWebSocketBufferDrain(socket);
4485
4483
  }
4486
4484
  } finally {
4487
4485
  audioReader.releaseLock();
4486
+ audioReader = void 0;
4488
4487
  }
4489
4488
  if (!finished) {
4490
- ws.send(JSON.stringify({ type: "audio.done" }));
4489
+ socket.send(JSON.stringify({ type: "audio.done" }));
4491
4490
  }
4492
4491
  };
4493
- ws.onmessage = (event) => {
4494
- void readWebSocketMessageText(event.data).then(async (text) => {
4495
- var _a, _b, _c, _d, _e, _f;
4492
+ connection = connectToWebSocket({
4493
+ url,
4494
+ headers,
4495
+ webSocket,
4496
+ abortSignal,
4497
+ onAbort: finishWithError,
4498
+ onProcessingError: finishWithError,
4499
+ onMessageText: async (text) => {
4500
+ var _a, _b, _c, _d, _e, _f, _g, _h, _i;
4496
4501
  const parsed = await safeParseJSON2({ text });
4497
4502
  if (!parsed.success) return;
4498
4503
  const raw = parsed.value;
@@ -4502,25 +4507,39 @@ function createXaiStreamingTranscriptionStream({
4502
4507
  switch (raw.type) {
4503
4508
  case "transcript.created": {
4504
4509
  controller.enqueue({ type: "stream-start", warnings });
4505
- void sendAudio().catch(finishWithError);
4510
+ const socket = connection == null ? void 0 : connection.socket;
4511
+ if (socket == null) {
4512
+ finishWithError(new Error("WebSocket is not connected."));
4513
+ break;
4514
+ }
4515
+ void sendAudio(socket).catch(finishWithError);
4506
4516
  break;
4507
4517
  }
4508
4518
  case "transcript.partial": {
4509
4519
  const id = channelId(raw.channel_index);
4510
- const timing = timingFromXaiEvent(raw);
4511
- if (raw.is_final) {
4520
+ const channelIndex = (_a = raw.channel_index) != null ? _a : 0;
4521
+ if (raw.is_final && raw.speech_final) {
4522
+ const timing = timingFromXaiEvent(raw);
4523
+ if (raw.text) {
4524
+ finalizedTexts.set(channelIndex, [
4525
+ ...(_b = finalizedTexts.get(channelIndex)) != null ? _b : [],
4526
+ raw.text
4527
+ ]);
4528
+ }
4529
+ pendingTexts.delete(channelIndex);
4512
4530
  controller.enqueue({
4513
4531
  type: "transcript-final",
4514
4532
  id,
4515
- text: (_a = raw.text) != null ? _a : "",
4533
+ text: (_c = raw.text) != null ? _c : "",
4516
4534
  ...timing,
4517
4535
  channelIndex: raw.channel_index
4518
4536
  });
4519
4537
  } else {
4538
+ pendingTexts.set(channelIndex, (_d = raw.text) != null ? _d : "");
4520
4539
  controller.enqueue({
4521
4540
  type: "transcript-partial",
4522
4541
  id,
4523
- text: (_b = raw.text) != null ? _b : "",
4542
+ text: (_e = raw.text) != null ? _e : "",
4524
4543
  startSecond: raw.start,
4525
4544
  durationInSeconds: raw.duration,
4526
4545
  channelIndex: raw.channel_index
@@ -4529,32 +4548,36 @@ function createXaiStreamingTranscriptionStream({
4529
4548
  break;
4530
4549
  }
4531
4550
  case "transcript.done": {
4532
- const channelIndex = (_c = raw.channel_index) != null ? _c : 0;
4533
- doneTexts.set(channelIndex, (_d = raw.text) != null ? _d : "");
4534
- doneDuration = (_e = raw.duration) != null ? _e : doneDuration;
4551
+ const channelIndex = (_f = raw.channel_index) != null ? _f : 0;
4552
+ const accumulated = [
4553
+ ...(_g = finalizedTexts.get(channelIndex)) != null ? _g : [],
4554
+ ...pendingTexts.get(channelIndex) ? [pendingTexts.get(channelIndex)] : []
4555
+ ].join(" ");
4556
+ doneTexts.set(channelIndex, raw.text || accumulated);
4557
+ doneDuration = (_h = raw.duration) != null ? _h : doneDuration;
4535
4558
  maybeFinish();
4536
4559
  break;
4537
4560
  }
4538
4561
  case "error": {
4539
- finishWithError(new Error((_f = raw.message) != null ? _f : "xAI STT error"));
4562
+ finishWithError(new Error((_i = raw.message) != null ? _i : "xAI STT error"));
4540
4563
  break;
4541
4564
  }
4542
4565
  }
4543
- }).catch(finishWithError);
4544
- };
4545
- ws.onerror = () => {
4546
- finishWithError(
4547
- new Error(
4548
- "xAI streaming transcription error." + (webSocket == null ? " Note: the native WebSocket implementation in browsers, Node.js, Deno, and Bun cannot send the Authorization header required by xAI. Pass a header-capable WebSocket implementation (e.g. the 'ws' package) via createXai({ webSocket })." : "")
4549
- )
4550
- );
4551
- };
4552
- ws.onclose = () => {
4553
- if (finished) return;
4554
- finished = true;
4555
- cleanup();
4556
- controller.close();
4557
- };
4566
+ },
4567
+ onSocketError: () => {
4568
+ finishWithError(
4569
+ new Error(
4570
+ "xAI streaming transcription error." + (webSocket == null ? " Note: the native WebSocket implementation in browsers, Node.js, Deno, and Bun cannot send the Authorization header required by xAI. Pass a header-capable WebSocket implementation (e.g. the 'ws' package) via createXai({ webSocket })." : "")
4571
+ )
4572
+ );
4573
+ },
4574
+ onClose: () => {
4575
+ if (finished) return;
4576
+ finished = true;
4577
+ cleanup();
4578
+ controller.close();
4579
+ }
4580
+ });
4558
4581
  },
4559
4582
  cancel: () => {
4560
4583
  if (finished) return;