@opengeni/sdk 0.20.0 → 0.25.0

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": "@opengeni/sdk",
3
- "version": "0.20.0",
3
+ "version": "0.25.0",
4
4
  "description": "Framework-agnostic TypeScript SDK for the OpenGeni API: typed client, session lifecycle, SSE event streaming with reconnect + replay-by-sequence, and proxy re-streaming helpers.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/client.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { OpenGeniApiContractMismatchError, OpenGeniApiError } from "./errors";
1
+ import {
2
+ OpenGeniApiContractMismatchError,
3
+ OpenGeniApiError,
4
+ OpenGeniSessionListCursorError,
5
+ } from "./errors";
2
6
  import {
3
7
  streamSessionEvents,
4
8
  type SessionEventStreamTransport,
@@ -16,6 +20,8 @@ import type {
16
20
  CodexAccount,
17
21
  CodexAccountsResponse,
18
22
  CodexRotationSettings,
23
+ CodexOverviewResponse,
24
+ CodexAllocatorUpdate,
19
25
  CodexConnectionStatus,
20
26
  CodexConnectPoll,
21
27
  CodexConnectStart,
@@ -28,6 +34,7 @@ import type {
28
34
  CapabilityInstallation,
29
35
  AddDocumentRequest,
30
36
  ClientConfig,
37
+ WorkspaceModelCatalogResponse,
31
38
  ClientSessionEventInput,
32
39
  CompactSessionContextResult,
33
40
  CompleteFileUploadResponse,
@@ -82,6 +89,9 @@ import type {
82
89
  ListWorkspaceMembersResponse,
83
90
  PackInstallation,
84
91
  ReasoningEffort,
92
+ RetainedArtifactContent,
93
+ RetainedArtifactContentOptions,
94
+ RetainedArtifactMetadata,
85
95
  RegisterCapabilityPackRequest,
86
96
  ResourceRef,
87
97
  ScheduledTask,
@@ -90,19 +100,25 @@ import type {
90
100
  SessionListResponse,
91
101
  UpdateSessionPinRequest,
92
102
  SessionEvent,
103
+ SessionEventCompactResult,
104
+ SessionEventCompactResultOptions,
93
105
  SessionEventListOptions,
94
106
  SessionEventPage,
95
107
  SessionGoal,
96
108
  SessionHumanInputRequest,
97
109
  SessionLineageResponse,
98
110
  SessionMcpCredentialUpdateInput,
111
+ UpdateSessionMcpApprovalPolicyRequest,
112
+ UpdateSessionMcpApprovalPolicyResponse,
99
113
  SessionQueueSnapshot,
100
114
  SessionQueueMutationResponse,
101
115
  ComposerDraft,
102
116
  DeleteSessionQueueItemRequest,
103
117
  EditSessionQueueItemRequest,
104
118
  MoveSessionQueueItemRequest,
119
+ NewSessionDraft,
105
120
  SaveComposerDraftRequest,
121
+ SaveNewSessionDraftRequest,
106
122
  SteerSessionQueueItemRequest,
107
123
  SessionControlResponse,
108
124
  WorkspaceInferenceControlResponse,
@@ -177,7 +193,22 @@ import type {
177
193
  OAuthStartRequest,
178
194
  OAuthStartResponse,
179
195
  } from "./types";
180
- import { OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION } from "./types";
196
+ import {
197
+ OPENGENI_API_CONTRACT_HEADER,
198
+ OPENGENI_API_CONTRACT_REVISION,
199
+ RETAINED_OUTPUT_MAX_PAGE_BYTES,
200
+ } from "./types";
201
+
202
+ function sessionListQuery(options: {
203
+ limit?: number;
204
+ parentSessionId?: string | null;
205
+ }): Record<string, string> {
206
+ const { limit, parentSessionId } = options;
207
+ return {
208
+ ...(limit === undefined ? {} : { limit: String(limit) }),
209
+ ...(parentSessionId === undefined ? {} : { parentSessionId: parentSessionId ?? "null" }),
210
+ };
211
+ }
181
212
 
182
213
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
183
214
 
@@ -255,6 +286,24 @@ export class OpenGeniClient {
255
286
  );
256
287
  }
257
288
 
289
+ async getNewSessionDraft(workspaceId: string): Promise<NewSessionDraft> {
290
+ return await this.requestJson<NewSessionDraft>(
291
+ "GET",
292
+ `/v1/workspaces/${workspaceId}/new-session-draft`,
293
+ );
294
+ }
295
+
296
+ async saveNewSessionDraft(
297
+ workspaceId: string,
298
+ request: SaveNewSessionDraftRequest,
299
+ ): Promise<NewSessionDraft> {
300
+ return await this.requestJson<NewSessionDraft>(
301
+ "PUT",
302
+ `/v1/workspaces/${workspaceId}/new-session-draft`,
303
+ request,
304
+ );
305
+ }
306
+
258
307
  async getSession(workspaceId: string, sessionId: string): Promise<Session> {
259
308
  return await this.requestJson<Session>(
260
309
  "GET",
@@ -274,6 +323,24 @@ export class OpenGeniClient {
274
323
  );
275
324
  }
276
325
 
326
+ /**
327
+ * Replace one attached MCP server's approval policy. The change is captured
328
+ * by the next claimed attempt; already-claimed work keeps its immutable
329
+ * policy snapshot.
330
+ */
331
+ async updateSessionMcpApprovalPolicy(
332
+ workspaceId: string,
333
+ sessionId: string,
334
+ serverId: string,
335
+ request: UpdateSessionMcpApprovalPolicyRequest,
336
+ ): Promise<UpdateSessionMcpApprovalPolicyResponse> {
337
+ return await this.requestJson<UpdateSessionMcpApprovalPolicyResponse>(
338
+ "PATCH",
339
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/mcp-servers/${encodeURIComponent(serverId)}/approval-policy`,
340
+ request,
341
+ );
342
+ }
343
+
277
344
  async listSessions(
278
345
  workspaceId: string,
279
346
  options: {
@@ -282,21 +349,21 @@ export class OpenGeniClient {
282
349
  search?: string;
283
350
  } = {},
284
351
  ): Promise<Session[]> {
352
+ // Search was added with the pin-aware page endpoint. An older API silently
353
+ // ignores unknown query parameters on the historical array endpoint, which
354
+ // would turn a search into a plausible-looking unfiltered result. Route
355
+ // searches through listSessionPage so its rolling-version shape check can
356
+ // fail explicitly on an older server; retain the array endpoint for every
357
+ // pre-existing call shape.
358
+ if (options.search?.trim()) {
359
+ const page = await this.listSessionPage(workspaceId, options);
360
+ return [...page.pinned, ...page.sessions];
361
+ }
285
362
  return await this.requestJson<Session[]>(
286
363
  "GET",
287
364
  `/v1/workspaces/${workspaceId}/sessions`,
288
365
  undefined,
289
- {
290
- ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
291
- ...(options.search?.trim() ? { search: options.search.trim() } : {}),
292
- ...(Object.prototype.hasOwnProperty.call(options, "parentSessionId") &&
293
- options.parentSessionId !== undefined
294
- ? {
295
- parentSessionId:
296
- options.parentSessionId === null ? "null" : String(options.parentSessionId),
297
- }
298
- : {}),
299
- },
366
+ sessionListQuery(options),
300
367
  );
301
368
  }
302
369
 
@@ -308,26 +375,51 @@ export class OpenGeniClient {
308
375
  parentSessionId?: string | null;
309
376
  cursor?: string;
310
377
  search?: string;
378
+ /** Return only the complete personal pinned projection. */
379
+ pinsOnly?: boolean;
311
380
  } = {},
312
381
  ): Promise<SessionListResponse> {
313
- return await this.requestJson<SessionListResponse>(
314
- "GET",
315
- `/v1/workspaces/${workspaceId}/sessions`,
316
- undefined,
317
- {
318
- view: "page",
319
- ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
320
- ...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
321
- ...(options.search?.trim() ? { search: options.search.trim() } : {}),
322
- ...(Object.prototype.hasOwnProperty.call(options, "parentSessionId") &&
323
- options.parentSessionId !== undefined
324
- ? {
325
- parentSessionId:
326
- options.parentSessionId === null ? "null" : String(options.parentSessionId),
327
- }
328
- : {}),
329
- },
330
- );
382
+ const search = options.search?.trim();
383
+ let response: SessionListResponse | Session[];
384
+ try {
385
+ response = await this.requestJson<SessionListResponse | Session[]>(
386
+ "GET",
387
+ `/v1/workspaces/${workspaceId}/sessions`,
388
+ undefined,
389
+ {
390
+ view: "page",
391
+ ...sessionListQuery(options),
392
+ ...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
393
+ ...(search ? { search } : {}),
394
+ ...(options.pinsOnly ? { pinsOnly: "true" } : {}),
395
+ },
396
+ );
397
+ } catch (error) {
398
+ if (error instanceof OpenGeniApiError && error.status === 410) {
399
+ throw new OpenGeniSessionListCursorError(error.status, error.body);
400
+ }
401
+ throw error;
402
+ }
403
+ if (Array.isArray(response)) {
404
+ // Rolling/same-major compatibility: an older API ignores `view=page` and
405
+ // returns the historical array. That is an honest one-page projection;
406
+ // never pretend it honored a cursor supplied directly by a caller.
407
+ if (options.cursor) {
408
+ throw new Error("The connected OpenGeni API does not support stable session-page cursors");
409
+ }
410
+ // Older APIs ignore unknown query parameters. Treating their unfiltered
411
+ // array as a successful search would be worse than an explicit rolling-
412
+ // upgrade error (and client-side filtering cannot recover matches beyond
413
+ // the old endpoint's bounded first page).
414
+ if (search) {
415
+ throw new Error("The connected OpenGeni API does not support session search");
416
+ }
417
+ if (options.pinsOnly) {
418
+ throw new Error("The connected OpenGeni API does not support pins-only session lists");
419
+ }
420
+ return { pinned: [], sessions: response, nextCursor: null };
421
+ }
422
+ return response;
331
423
  }
332
424
 
333
425
  /** Set this authenticated member's personal workspace pin for a session. */
@@ -533,8 +625,18 @@ export class OpenGeniClient {
533
625
  async listEventPage(
534
626
  workspaceId: string,
535
627
  sessionId: string,
536
- options: SessionEventListOptions = {},
537
- ): Promise<SessionEventPage> {
628
+ options: SessionEventCompactResultOptions,
629
+ ): Promise<SessionEventCompactResult | null>;
630
+ async listEventPage(
631
+ workspaceId: string,
632
+ sessionId: string,
633
+ options?: SessionEventListOptions,
634
+ ): Promise<SessionEventPage>;
635
+ async listEventPage(
636
+ workspaceId: string,
637
+ sessionId: string,
638
+ options: SessionEventListOptions | SessionEventCompactResultOptions = {},
639
+ ): Promise<SessionEventPage | SessionEventCompactResult | null> {
538
640
  if (
539
641
  options.latest &&
540
642
  ["includeTypes", "excludeTypes", "includeClasses", "excludeClasses"].some((name) =>
@@ -543,22 +645,32 @@ export class OpenGeniClient {
543
645
  ) {
544
646
  throw new TypeError("latest cannot be combined with event filters");
545
647
  }
648
+ if (options.resultMode === "compact" && !options.latest) {
649
+ throw new TypeError("resultMode=compact requires latest");
650
+ }
651
+ const listOptions: SessionEventListOptions | null =
652
+ options.resultMode === "compact" ? null : options;
546
653
  const response = await this.fetchImpl(
547
654
  this.url(`/v1/workspaces/${workspaceId}/sessions/${sessionId}/events`, {
548
- ...(options.after !== undefined ? { after: String(options.after) } : {}),
549
- ...(options.before !== undefined ? { before: String(options.before) } : {}),
550
- ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
551
- ...(options.compact ? { compact: "1" } : {}),
655
+ ...(listOptions?.after !== undefined ? { after: String(listOptions.after) } : {}),
656
+ ...(listOptions?.before !== undefined ? { before: String(listOptions.before) } : {}),
657
+ ...(listOptions?.limit !== undefined ? { limit: String(listOptions.limit) } : {}),
658
+ ...(listOptions?.compact ? { compact: "1" } : {}),
552
659
  ...(options.mode ? { mode: options.mode } : {}),
553
- ...(options.direction ? { direction: options.direction } : {}),
660
+ ...(listOptions?.direction ? { direction: listOptions.direction } : {}),
554
661
  ...(options.payloadMode ? { payloadMode: options.payloadMode } : {}),
555
- ...(options.includeTypes?.length ? { includeTypes: options.includeTypes.join(",") } : {}),
556
- ...(options.excludeTypes?.length ? { excludeTypes: options.excludeTypes.join(",") } : {}),
557
- ...(options.includeClasses?.length
558
- ? { includeClasses: options.includeClasses.join(",") }
662
+ ...(options.resultMode ? { resultMode: options.resultMode } : {}),
663
+ ...(listOptions?.includeTypes?.length
664
+ ? { includeTypes: listOptions.includeTypes.join(",") }
665
+ : {}),
666
+ ...(listOptions?.excludeTypes?.length
667
+ ? { excludeTypes: listOptions.excludeTypes.join(",") }
668
+ : {}),
669
+ ...(listOptions?.includeClasses?.length
670
+ ? { includeClasses: listOptions.includeClasses.join(",") }
559
671
  : {}),
560
- ...(options.excludeClasses?.length
561
- ? { excludeClasses: options.excludeClasses.join(",") }
672
+ ...(listOptions?.excludeClasses?.length
673
+ ? { excludeClasses: listOptions.excludeClasses.join(",") }
562
674
  : {}),
563
675
  ...(options.latest ? { latest: options.latest } : {}),
564
676
  }),
@@ -569,7 +681,11 @@ export class OpenGeniClient {
569
681
  );
570
682
  assertApiContractResponse(response);
571
683
  if (!response.ok) throw new OpenGeniApiError(response.status, await safeText(response));
572
- const events = (await response.json()) as SessionEvent[];
684
+ const body = await response.json();
685
+ if (options.resultMode === "compact") {
686
+ return body as SessionEventCompactResult;
687
+ }
688
+ const events = body as SessionEvent[];
573
689
  const integerHeader = (name: string): number | null => {
574
690
  const raw = response.headers.get(name);
575
691
  if (raw === null) return null;
@@ -613,6 +729,23 @@ export class OpenGeniClient {
613
729
  };
614
730
  }
615
731
 
732
+ /**
733
+ * Fetch the authoritative newest-sequence semantic result directly. This is
734
+ * the callback-loss recovery path: it reads one compact durable result and
735
+ * never creates a model turn. `latest: "receipt"` aliases `tool_receipt`;
736
+ * turn generation remains scoped retry metadata.
737
+ */
738
+ async getLatestEventResult(
739
+ workspaceId: string,
740
+ sessionId: string,
741
+ options: Omit<SessionEventCompactResultOptions, "resultMode"> = { latest: "terminal" },
742
+ ): Promise<SessionEventCompactResult | null> {
743
+ return await this.listEventPage(workspaceId, sessionId, {
744
+ ...options,
745
+ resultMode: "compact",
746
+ });
747
+ }
748
+
616
749
  /** POST a user/control event to the session. Returns the accepted event. */
617
750
  async sendEvent(
618
751
  workspaceId: string,
@@ -1418,6 +1551,14 @@ export class OpenGeniClient {
1418
1551
  return config;
1419
1552
  }
1420
1553
 
1554
+ /** Authenticated model definitions plus workspace-specific selectability. */
1555
+ async getWorkspaceModelCatalog(workspaceId: string): Promise<WorkspaceModelCatalogResponse> {
1556
+ return await this.requestJson<WorkspaceModelCatalogResponse>(
1557
+ "GET",
1558
+ `/v1/workspaces/${workspaceId}/model-catalog`,
1559
+ );
1560
+ }
1561
+
1421
1562
  /** The caller's access context: subject, account + workspace grants, defaults. */
1422
1563
  async getAccessContext(): Promise<AccessContext> {
1423
1564
  return await this.requestJson<AccessContext>("GET", "/v1/access/me");
@@ -1838,21 +1979,27 @@ export class OpenGeniClient {
1838
1979
  * -> complete. Returns the ready `FileAsset`.
1839
1980
  */
1840
1981
  async uploadFile(workspaceId: string, input: UploadFileInput): Promise<FileAsset> {
1841
- // Copy Uint8Array views into a Blob so byte offsets/shared buffers can't
1842
- // leak surrounding bytes into the PUT body.
1982
+ // Snapshot mutable inputs before hashing so the digest always describes the
1983
+ // exact bytes later sent to object storage. Copy Uint8Array views into a
1984
+ // Blob so byte offsets/shared buffers can't leak surrounding bytes.
1843
1985
  const body: Blob | ArrayBuffer | string =
1844
- input.data instanceof Uint8Array ? new Blob([input.data.slice()]) : input.data;
1986
+ input.data instanceof Uint8Array
1987
+ ? new Blob([input.data.slice()])
1988
+ : input.data instanceof ArrayBuffer
1989
+ ? input.data.slice(0)
1990
+ : input.data;
1845
1991
  const sizeBytes =
1846
1992
  typeof body === "string"
1847
1993
  ? new TextEncoder().encode(body).byteLength
1848
1994
  : body instanceof Blob
1849
1995
  ? body.size
1850
1996
  : body.byteLength;
1997
+ const sha256 = input.sha256 ?? (await sha256ForUpload(body));
1851
1998
  const upload = await this.beginFileUpload(workspaceId, {
1852
1999
  filename: input.filename,
1853
2000
  contentType: input.contentType,
1854
2001
  sizeBytes,
1855
- ...(input.sha256 !== undefined ? { sha256: input.sha256 } : {}),
2002
+ sha256,
1856
2003
  });
1857
2004
  const putResponse = await this.fetchImpl(upload.putUrl, {
1858
2005
  method: "PUT",
@@ -1878,6 +2025,80 @@ export class OpenGeniClient {
1878
2025
  );
1879
2026
  }
1880
2027
 
2028
+ /** Read provider-neutral retained evidence metadata; never returns a storage location. */
2029
+ async getRetainedArtifact(
2030
+ workspaceId: string,
2031
+ artifactId: string,
2032
+ ): Promise<RetainedArtifactMetadata> {
2033
+ return await this.requestJson<RetainedArtifactMetadata>(
2034
+ "GET",
2035
+ `/v1/workspaces/${workspaceId}/artifacts/${artifactId}`,
2036
+ );
2037
+ }
2038
+
2039
+ /**
2040
+ * Read at most one authenticated retained-evidence range from the API. This
2041
+ * deliberately does not use the ordinary signed file-download URL.
2042
+ */
2043
+ async getRetainedArtifactContent(
2044
+ workspaceId: string,
2045
+ artifactId: string,
2046
+ options: RetainedArtifactContentOptions = {},
2047
+ ): Promise<RetainedArtifactContent> {
2048
+ if (options.range && (options.range.length > 128 || /[^\x20-\x7e]/.test(options.range))) {
2049
+ throw new RangeError("retained artifact range must be at most 128 printable ASCII bytes");
2050
+ }
2051
+ const response = await this.fetchImpl(
2052
+ this.url(`/v1/workspaces/${workspaceId}/artifacts/${artifactId}/content`),
2053
+ {
2054
+ method: "GET",
2055
+ headers: {
2056
+ ...this.headers(),
2057
+ Accept: "application/octet-stream",
2058
+ ...(options.range ? { Range: options.range } : {}),
2059
+ },
2060
+ ...(options.signal ? { signal: options.signal } : {}),
2061
+ },
2062
+ );
2063
+ try {
2064
+ assertApiContractResponse(response);
2065
+ } catch (error) {
2066
+ await cancelResponseBody(response, "retained artifact API contract mismatch");
2067
+ throw error;
2068
+ }
2069
+ if (!response.ok) {
2070
+ throw new OpenGeniApiError(response.status, await safeBoundedText(response));
2071
+ }
2072
+ if (response.status !== 200 && response.status !== 206) {
2073
+ await cancelResponseBody(response, "unexpected retained artifact response status");
2074
+ throw new OpenGeniApiError(response.status, "unexpected retained artifact response status");
2075
+ }
2076
+ if (response.headers.get("accept-ranges") !== "bytes") {
2077
+ await cancelResponseBody(response, "retained artifact response omitted byte-range support");
2078
+ throw new OpenGeniApiError(502, "retained artifact response omitted byte-range support");
2079
+ }
2080
+ let declaredLength: number | null;
2081
+ try {
2082
+ declaredLength = parseBoundedContentLength(response.headers.get("content-length"));
2083
+ } catch (error) {
2084
+ await cancelResponseBody(response, "invalid retained artifact content-length");
2085
+ throw error;
2086
+ }
2087
+ const bytes = await readBoundedResponseBytes(
2088
+ response,
2089
+ RETAINED_OUTPUT_MAX_PAGE_BYTES,
2090
+ declaredLength,
2091
+ );
2092
+ return {
2093
+ bytes,
2094
+ status: response.status,
2095
+ contentType: response.headers.get("content-type") ?? "application/octet-stream",
2096
+ contentLength: bytes.byteLength,
2097
+ contentRange: response.headers.get("content-range"),
2098
+ acceptRanges: "bytes",
2099
+ };
2100
+ }
2101
+
1881
2102
  /** Mint a short-lived signed download URL for a ready file. */
1882
2103
  async createFileDownloadUrl(
1883
2104
  workspaceId: string,
@@ -2416,6 +2637,14 @@ export class OpenGeniClient {
2416
2637
  );
2417
2638
  }
2418
2639
 
2640
+ /** Live independently-settled quota + reset-credit overview for every account. */
2641
+ async codexOverview(workspaceId: string): Promise<CodexOverviewResponse> {
2642
+ return await this.requestJson<CodexOverviewResponse>(
2643
+ "GET",
2644
+ `/v1/workspaces/${workspaceId}/codex/overview`,
2645
+ );
2646
+ }
2647
+
2419
2648
  /** Disconnect ALL accounts (legacy workspace-wide). Prefer `disconnectCodexAccount`. */
2420
2649
  async codexDisconnect(workspaceId: string): Promise<{ disconnected: boolean }> {
2421
2650
  return await this.requestJson<{ disconnected: boolean }>(
@@ -2458,6 +2687,19 @@ export class OpenGeniClient {
2458
2687
  );
2459
2688
  }
2460
2689
 
2690
+ /** Toggle only NEW automatic allocations under independent allocator OCC. */
2691
+ async setCodexAccountAllocator(
2692
+ workspaceId: string,
2693
+ accountId: string,
2694
+ input: { enabled: boolean; expectedVersion: number },
2695
+ ): Promise<CodexAllocatorUpdate> {
2696
+ return await this.requestJson<CodexAllocatorUpdate>(
2697
+ "PATCH",
2698
+ `/v1/workspaces/${workspaceId}/codex/accounts/${accountId}/allocator`,
2699
+ input,
2700
+ );
2701
+ }
2702
+
2461
2703
  /** Disconnect ONE Codex account by id (re-picks active when the removed one was active). */
2462
2704
  async disconnectCodexAccount(
2463
2705
  workspaceId: string,
@@ -2544,6 +2786,17 @@ function assertApiContractResponse(response: Response): void {
2544
2786
  }
2545
2787
  }
2546
2788
 
2789
+ async function sha256ForUpload(body: Blob | ArrayBuffer | string): Promise<string> {
2790
+ const bytes =
2791
+ typeof body === "string"
2792
+ ? new TextEncoder().encode(body)
2793
+ : body instanceof Blob
2794
+ ? new Uint8Array(await body.arrayBuffer())
2795
+ : new Uint8Array(body);
2796
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
2797
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2798
+ }
2799
+
2547
2800
  async function safeText(response: Response): Promise<string> {
2548
2801
  try {
2549
2802
  return await response.text();
@@ -2551,3 +2804,69 @@ async function safeText(response: Response): Promise<string> {
2551
2804
  return "";
2552
2805
  }
2553
2806
  }
2807
+
2808
+ async function safeBoundedText(response: Response): Promise<string> {
2809
+ try {
2810
+ return new TextDecoder().decode(await readBoundedResponseBytes(response, 64 * 1024, null));
2811
+ } catch {
2812
+ return "";
2813
+ }
2814
+ }
2815
+
2816
+ async function cancelResponseBody(response: Response, reason: string): Promise<void> {
2817
+ await response.body?.cancel(reason).catch(() => undefined);
2818
+ }
2819
+
2820
+ function parseBoundedContentLength(value: string | null): number | null {
2821
+ if (value === null) return null;
2822
+ if (!/^\d+$/.test(value)) {
2823
+ throw new OpenGeniApiError(502, "invalid retained artifact content-length");
2824
+ }
2825
+ const length = Number(value);
2826
+ if (!Number.isSafeInteger(length) || length > RETAINED_OUTPUT_MAX_PAGE_BYTES) {
2827
+ throw new OpenGeniApiError(502, "retained artifact response exceeds the SDK byte limit");
2828
+ }
2829
+ return length;
2830
+ }
2831
+
2832
+ async function readBoundedResponseBytes(
2833
+ response: Response,
2834
+ maxBytes: number,
2835
+ expectedBytes: number | null,
2836
+ ): Promise<Uint8Array> {
2837
+ if (!response.body) {
2838
+ if (expectedBytes !== null && expectedBytes !== 0) {
2839
+ throw new OpenGeniApiError(502, "retained artifact response length mismatch");
2840
+ }
2841
+ return new Uint8Array();
2842
+ }
2843
+ const reader = response.body.getReader();
2844
+ const chunks: Uint8Array[] = [];
2845
+ let totalBytes = 0;
2846
+ try {
2847
+ while (true) {
2848
+ const { done, value } = await reader.read();
2849
+ if (done) break;
2850
+ totalBytes += value.byteLength;
2851
+ if (totalBytes > maxBytes) {
2852
+ await reader
2853
+ .cancel("retained artifact response exceeded the SDK byte limit")
2854
+ .catch(() => undefined);
2855
+ throw new OpenGeniApiError(502, "retained artifact response exceeds the SDK byte limit");
2856
+ }
2857
+ chunks.push(value);
2858
+ }
2859
+ } finally {
2860
+ reader.releaseLock();
2861
+ }
2862
+ if (expectedBytes !== null && totalBytes !== expectedBytes) {
2863
+ throw new OpenGeniApiError(502, "retained artifact response length mismatch");
2864
+ }
2865
+ const bytes = new Uint8Array(totalBytes);
2866
+ let offset = 0;
2867
+ for (const chunk of chunks) {
2868
+ bytes.set(chunk, offset);
2869
+ offset += chunk.byteLength;
2870
+ }
2871
+ return bytes;
2872
+ }
package/src/errors.ts CHANGED
@@ -1,16 +1,37 @@
1
1
  /** Error for a non-2xx OpenGeni API response. */
2
2
  export class OpenGeniApiError extends Error {
3
3
  readonly status: number;
4
+ readonly code: string | undefined;
4
5
  readonly body: string;
5
6
 
6
7
  constructor(status: number, body: string) {
7
- super(`OpenGeni API ${status}: ${body || "(empty body)"}`);
8
+ const decoded = decodeApiErrorBody(body);
9
+ super(`OpenGeni API ${status}: ${decoded.message ?? (body || "(empty body)")}`);
8
10
  this.name = "OpenGeniApiError";
9
11
  this.status = status;
12
+ this.code = decoded.code;
10
13
  this.body = body;
11
14
  }
12
15
  }
13
16
 
17
+ function decodeApiErrorBody(body: string): { code?: string; message?: string } {
18
+ if (!body) return {};
19
+ try {
20
+ const decoded: unknown = JSON.parse(body);
21
+ if (!decoded || typeof decoded !== "object" || Array.isArray(decoded)) return {};
22
+ const record = decoded as Record<string, unknown>;
23
+ return {
24
+ ...(typeof record.code === "string" && record.code ? { code: record.code } : {}),
25
+ ...(typeof record.message === "string" && record.message ? { message: record.message } : {}),
26
+ };
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
31
+
32
+ /** A short-lived session-list snapshot cursor can no longer be continued. */
33
+ export class OpenGeniSessionListCursorError extends OpenGeniApiError {}
34
+
14
35
  /** The browser bundle and API disagree about their state-changing wire contract. */
15
36
  export class OpenGeniApiContractMismatchError extends Error {
16
37
  readonly expected: string;