@gullabs/xai 0.3.0 → 0.4.1

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/README.md CHANGED
@@ -5,10 +5,10 @@ xAI Grok provider adapter for any-llm. A thin mapping layer over the `openai` np
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- pnpm add @gullabs/xai @gullabs/core openai
8
+ pnpm add @gullabs/xai @gullabs/core openai # peer: openai ^6 || ^7
9
9
  ```
10
10
 
11
- **Peer dependency:** `openai ^6`
11
+ **Peer dependency:** `openai ^6 || ^7`
12
12
 
13
13
  xAI has no first-party TypeScript SDK. xAI's own quickstart recommends using the `openai` npm package with a `baseURL` override pointed at xAI's endpoint — that is the path this adapter takes. `buildXaiClient` is the only place in `packages/xai/src` that imports `openai`, so the rest of the adapter (and its tests) stay decoupled from the real SDK via the structural `XaiClientLike` interface.
14
14
 
@@ -32,6 +32,7 @@ xAI has no first-party TypeScript SDK. xAI's own quickstart recommends using the
32
32
  | `XaiProviderOptions` | `{ promptCacheKey? }` — typed `providerOptions.xai` extension shape |
33
33
  | `XaiFileStore` | Files API store: upload (TTL), get, list, idempotent delete, content |
34
34
  | `XaiFileHandle` | `{ id, filename?, bytes?, expiresAt?, … }` returned by the store |
35
+ | `FileDeleteOptions` | `{ failClosed?, signal? }` — opt-in fail-closed delete for durable release gates |
35
36
  | `XAI_FILE_TTL_*` | TTL bounds (`3600`…`2592000` seconds) and `XAI_FILE_MAX_BYTES` (48 MiB) |
36
37
 
37
38
  ## Quick example
@@ -88,19 +89,32 @@ const handle = await store.upload({
88
89
  // Attach on generate via core FileRefPart:
89
90
  // { kind: 'file-ref', fileId: handle.id }
90
91
 
91
- await store.delete(handle.id) // 404 = success (idempotent)
92
+ await store.delete(handle.id) // default fail-open; 404 = success
92
93
  await store.delete(handle.id) // safe to call twice
94
+
95
+ // Durable gate (Temporal release / orphan sweep) — mark DB only after success:
96
+ try {
97
+ await store.delete(handle.id, { failClosed: true })
98
+ await db.markReleased(handle.id)
99
+ } catch (err) {
100
+ // leave released_at null; do not rethrow from workflow finally
101
+ logger.warn({ err }, 'delete failed')
102
+ }
93
103
  ```
94
104
 
95
- | Behavior | Detail |
96
- | ------------ | ---------------------------------------------------------------------------------------------------------------- |
97
- | TTL | `expiresAfterSeconds` validated client-side; multipart sends `expires_after` **before** `file` (xAI requirement) |
98
- | Delete | Fail-open: non-404 errors call `onDeleteError` and resolve; 404 is silent success |
99
- | Storage cost | ~$0.025/GiB/day — **not** injected into `computeCost` token lanes |
100
- | ZDR teams | New uploads and `file_id` attachments are blocked by xAI; errors mention Zero Data Retention when detectable |
101
- | Max size | 48 MiB (conservative vs docs 48–50 MB) |
105
+ | Behavior | Detail |
106
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | TTL | `expiresAfterSeconds` validated client-side; multipart sends `expires_after` **before** `file` (xAI requirement) |
108
+ | Delete (default) | Fail-open: non-404 errors call `onDeleteError` and resolve; 404 is silent success |
109
+ | Delete (`failClosed: true`) | Non-404 failures **throw** `LlmError`; `onDeleteError` is not called. Prefer **per-id** delete + markReleased when writing durable release state — fail-closed `deleteAll` does not cancel in-flight siblings |
110
+ | Empty `fileId` | Always throws `bad_request` (both modes) |
111
+ | Storage cost | ~$0.025/GiB/day — **not** injected into `computeCost` token lanes |
112
+ | ZDR teams | New uploads and `file_id` attachments are blocked by xAI; errors mention Zero Data Retention when detectable |
113
+ | Max size | 48 MiB (conservative vs docs 48–50 MB) |
114
+
115
+ **Billing note:** attaching files on Responses implicitly enables xAI's `attachment_search` agentic tool. Expect tool-invocation fees and reasoning tokens beyond a plain completion. When xAI returns numeric counters such as `num_server_side_tools_used` / `num_sources_used`, they appear on `usage.details` under those raw names (and full payload in `usage.raw`) for host visibility — they are **not** folded into `computeXaiCost` token lanes yet (no tool Cost lane). Collections / public URL minting are out of scope for this store.
102
116
 
103
- **Billing note:** attaching files on Responses implicitly enables xAI's `attachment_search` agentic tool. Expect tool-invocation fees and reasoning tokens beyond a plain completion. Collections / public URL minting are out of scope for this store.
117
+ **Host tests:** `@gullabs/testing` exports `FakeXaiFileStore` (in-memory upload/get/delete with optional TTL clock and `failClosed`).
104
118
 
105
119
  ## Vision constraints
106
120
 
package/dist/index.cjs CHANGED
@@ -739,35 +739,25 @@ var XaiFileStore = class {
739
739
  }
740
740
  /**
741
741
  * Delete a file. Idempotent: HTTP 404 → success.
742
- * Other errors are forwarded to `onDeleteError` and **not** rethrown (P5).
742
+ *
743
+ * Default (`failClosed` omitted/false): non-404 errors go to `onDeleteError`
744
+ * and resolve (P5 fail-open). With `failClosed: true`, non-404 errors throw
745
+ * typed `LlmError` and `onDeleteError` is not called.
746
+ *
747
+ * Empty/blank ids always throw `bad_request` (caller fault).
743
748
  */
744
- async delete(fileIdOrHandle, signal) {
749
+ async delete(fileIdOrHandle, opts) {
745
750
  const fileId = resolveFileId(fileIdOrHandle);
746
751
  if (typeof fileId !== "string" || fileId.trim() === "") {
747
- this.onDeleteError(String(fileId), badRequest("fileId must be a non-empty string."));
748
- return;
752
+ throw badRequest("fileId must be a non-empty string.");
749
753
  }
754
+ const failClosed = opts?.failClosed === true;
755
+ const signal = opts?.signal;
750
756
  try {
751
- let res;
752
- try {
753
- res = await this.fetchImpl(
754
- this.filesUrl(fileId),
755
- this.requestInit("DELETE", signal !== void 0 ? { signal } : {})
756
- );
757
- } catch (e) {
758
- if (signal?.aborted === true) {
759
- this.onDeleteError(
760
- fileId,
761
- new core.LlmError("xAI file delete aborted", {
762
- kind: "aborted",
763
- retryable: false,
764
- provider: "xai"
765
- })
766
- );
767
- return;
768
- }
769
- throw e;
770
- }
757
+ const res = await this.fetchImpl(
758
+ this.filesUrl(fileId),
759
+ this.requestInit("DELETE", signal !== void 0 ? { signal } : {})
760
+ );
771
761
  if (res.status === 404) {
772
762
  return;
773
763
  }
@@ -778,14 +768,32 @@ var XaiFileStore = class {
778
768
  if (isNotFoundError(err)) {
779
769
  return;
780
770
  }
781
- this.onDeleteError(fileId, classifyStoreError(err));
771
+ const classified = signal?.aborted === true && !(err instanceof core.LlmError) ? new core.LlmError("xAI file delete aborted", {
772
+ kind: "aborted",
773
+ retryable: false,
774
+ provider: "xai",
775
+ cause: err
776
+ }) : classifyStoreError(err);
777
+ if (failClosed) {
778
+ throw classified;
779
+ }
780
+ this.onDeleteError(fileId, classified);
782
781
  }
783
782
  }
784
783
  /**
785
- * Delete many files. Each failure is individually fail-open; none are thrown.
784
+ * Delete many files.
785
+ *
786
+ * Fail-open (default): `Promise.allSettled` — each failure → `onDeleteError`.
787
+ * Fail-closed: `Promise.all` — first throw rejects; in-flight siblings are
788
+ * not cancelled (partial deletes may already have succeeded at the provider).
789
+ * Prefer per-id delete + host DB mark when gating durable release state.
786
790
  */
787
- async deleteAll(ids, signal) {
788
- await Promise.allSettled(ids.map((id) => this.delete(id, signal)));
791
+ async deleteAll(ids, opts) {
792
+ if (opts?.failClosed === true) {
793
+ await Promise.all(ids.map((id) => this.delete(id, opts)));
794
+ return;
795
+ }
796
+ await Promise.allSettled(ids.map((id) => this.delete(id, opts)));
789
797
  }
790
798
  /** Download raw file bytes. */
791
799
  async getContent(fileId, signal) {