@opengeni/sdk 0.23.0 → 0.25.5

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.23.0",
3
+ "version": "0.25.5",
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,
@@ -29,6 +33,8 @@ import type {
29
33
  CapabilityCatalogResponse,
30
34
  CapabilityInstallation,
31
35
  AddDocumentRequest,
36
+ CreateKnowledgeDropRequest,
37
+ MoveDocumentRequest,
32
38
  ClientConfig,
33
39
  WorkspaceModelCatalogResponse,
34
40
  ClientSessionEventInput,
@@ -112,7 +118,9 @@ import type {
112
118
  DeleteSessionQueueItemRequest,
113
119
  EditSessionQueueItemRequest,
114
120
  MoveSessionQueueItemRequest,
121
+ NewSessionDraft,
115
122
  SaveComposerDraftRequest,
123
+ SaveNewSessionDraftRequest,
116
124
  SteerSessionQueueItemRequest,
117
125
  SessionControlResponse,
118
126
  WorkspaceInferenceControlResponse,
@@ -164,6 +172,7 @@ import type {
164
172
  UpdateScheduledTaskRequest,
165
173
  UpdateSessionGoalRequest,
166
174
  UpdateSessionRequest,
175
+ UpdateSessionToolPolicyRequest,
167
176
  UpdateVariableSetRequest,
168
177
  UpdateRigRequest,
169
178
  UpdateWorkspaceMemberRequest,
@@ -190,9 +199,21 @@ import type {
190
199
  import {
191
200
  OPENGENI_API_CONTRACT_HEADER,
192
201
  OPENGENI_API_CONTRACT_REVISION,
202
+ OPENGENI_CORRELATION_HEADER,
193
203
  RETAINED_OUTPUT_MAX_PAGE_BYTES,
194
204
  } from "./types";
195
205
 
206
+ function sessionListQuery(options: {
207
+ limit?: number;
208
+ parentSessionId?: string | null;
209
+ }): Record<string, string> {
210
+ const { limit, parentSessionId } = options;
211
+ return {
212
+ ...(limit === undefined ? {} : { limit: String(limit) }),
213
+ ...(parentSessionId === undefined ? {} : { parentSessionId: parentSessionId ?? "null" }),
214
+ };
215
+ }
216
+
196
217
  export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
197
218
 
198
219
  export type WorkspaceControlEventPage = {
@@ -269,6 +290,24 @@ export class OpenGeniClient {
269
290
  );
270
291
  }
271
292
 
293
+ async getNewSessionDraft(workspaceId: string): Promise<NewSessionDraft> {
294
+ return await this.requestJson<NewSessionDraft>(
295
+ "GET",
296
+ `/v1/workspaces/${workspaceId}/new-session-draft`,
297
+ );
298
+ }
299
+
300
+ async saveNewSessionDraft(
301
+ workspaceId: string,
302
+ request: SaveNewSessionDraftRequest,
303
+ ): Promise<NewSessionDraft> {
304
+ return await this.requestJson<NewSessionDraft>(
305
+ "PUT",
306
+ `/v1/workspaces/${workspaceId}/new-session-draft`,
307
+ request,
308
+ );
309
+ }
310
+
272
311
  async getSession(workspaceId: string, sessionId: string): Promise<Session> {
273
312
  return await this.requestJson<Session>(
274
313
  "GET",
@@ -288,6 +327,19 @@ export class OpenGeniClient {
288
327
  );
289
328
  }
290
329
 
330
+ /** Replace the durable tool policy or explicitly adopt workspace defaults. */
331
+ async updateSessionToolPolicy(
332
+ workspaceId: string,
333
+ sessionId: string,
334
+ request: UpdateSessionToolPolicyRequest,
335
+ ): Promise<Session> {
336
+ return await this.requestJson<Session>(
337
+ "PUT",
338
+ `/v1/workspaces/${workspaceId}/sessions/${sessionId}/tool-policy`,
339
+ request,
340
+ );
341
+ }
342
+
291
343
  /**
292
344
  * Replace one attached MCP server's approval policy. The change is captured
293
345
  * by the next claimed attempt; already-claimed work keeps its immutable
@@ -314,21 +366,21 @@ export class OpenGeniClient {
314
366
  search?: string;
315
367
  } = {},
316
368
  ): Promise<Session[]> {
369
+ // Search was added with the pin-aware page endpoint. An older API silently
370
+ // ignores unknown query parameters on the historical array endpoint, which
371
+ // would turn a search into a plausible-looking unfiltered result. Route
372
+ // searches through listSessionPage so its rolling-version shape check can
373
+ // fail explicitly on an older server; retain the array endpoint for every
374
+ // pre-existing call shape.
375
+ if (options.search?.trim()) {
376
+ const page = await this.listSessionPage(workspaceId, options);
377
+ return [...page.pinned, ...page.sessions];
378
+ }
317
379
  return await this.requestJson<Session[]>(
318
380
  "GET",
319
381
  `/v1/workspaces/${workspaceId}/sessions`,
320
382
  undefined,
321
- {
322
- ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
323
- ...(options.search?.trim() ? { search: options.search.trim() } : {}),
324
- ...(Object.prototype.hasOwnProperty.call(options, "parentSessionId") &&
325
- options.parentSessionId !== undefined
326
- ? {
327
- parentSessionId:
328
- options.parentSessionId === null ? "null" : String(options.parentSessionId),
329
- }
330
- : {}),
331
- },
383
+ sessionListQuery(options),
332
384
  );
333
385
  }
334
386
 
@@ -340,26 +392,57 @@ export class OpenGeniClient {
340
392
  parentSessionId?: string | null;
341
393
  cursor?: string;
342
394
  search?: string;
395
+ /** Return only the complete personal pinned projection. */
396
+ pinsOnly?: boolean;
343
397
  } = {},
344
398
  ): Promise<SessionListResponse> {
345
- return await this.requestJson<SessionListResponse>(
346
- "GET",
347
- `/v1/workspaces/${workspaceId}/sessions`,
348
- undefined,
349
- {
350
- view: "page",
351
- ...(options.limit !== undefined ? { limit: String(options.limit) } : {}),
352
- ...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
353
- ...(options.search?.trim() ? { search: options.search.trim() } : {}),
354
- ...(Object.prototype.hasOwnProperty.call(options, "parentSessionId") &&
355
- options.parentSessionId !== undefined
356
- ? {
357
- parentSessionId:
358
- options.parentSessionId === null ? "null" : String(options.parentSessionId),
359
- }
360
- : {}),
361
- },
362
- );
399
+ const search = options.search?.trim();
400
+ let response: SessionListResponse | Session[];
401
+ try {
402
+ response = await this.requestJson<SessionListResponse | Session[]>(
403
+ "GET",
404
+ `/v1/workspaces/${workspaceId}/sessions`,
405
+ undefined,
406
+ {
407
+ view: "page",
408
+ ...sessionListQuery(options),
409
+ ...(options.cursor !== undefined ? { cursor: options.cursor } : {}),
410
+ ...(search ? { search } : {}),
411
+ ...(options.pinsOnly ? { pinsOnly: "true" } : {}),
412
+ },
413
+ );
414
+ } catch (error) {
415
+ if (error instanceof OpenGeniApiError && error.status === 410) {
416
+ throw new OpenGeniSessionListCursorError(error.status, error.body, {
417
+ ...(error.code ? { code: error.code } : {}),
418
+ retryable: error.retryable,
419
+ ...(error.correlationId ? { correlationId: error.correlationId } : {}),
420
+ outcomeUnknown: error.outcomeUnknown,
421
+ displayMessage: "The session list changed — refresh and try again.",
422
+ });
423
+ }
424
+ throw error;
425
+ }
426
+ if (Array.isArray(response)) {
427
+ // Rolling/same-major compatibility: an older API ignores `view=page` and
428
+ // returns the historical array. That is an honest one-page projection;
429
+ // never pretend it honored a cursor supplied directly by a caller.
430
+ if (options.cursor) {
431
+ throw new Error("The connected OpenGeni API does not support stable session-page cursors");
432
+ }
433
+ // Older APIs ignore unknown query parameters. Treating their unfiltered
434
+ // array as a successful search would be worse than an explicit rolling-
435
+ // upgrade error (and client-side filtering cannot recover matches beyond
436
+ // the old endpoint's bounded first page).
437
+ if (search) {
438
+ throw new Error("The connected OpenGeni API does not support session search");
439
+ }
440
+ if (options.pinsOnly) {
441
+ throw new Error("The connected OpenGeni API does not support pins-only session lists");
442
+ }
443
+ return { pinned: [], sessions: response, nextCursor: null };
444
+ }
445
+ return response;
363
446
  }
364
447
 
365
448
  /** Set this authenticated member's personal workspace pin for a session. */
@@ -590,6 +673,7 @@ export class OpenGeniClient {
590
673
  }
591
674
  const listOptions: SessionEventListOptions | null =
592
675
  options.resultMode === "compact" ? null : options;
676
+ const correlationId = crypto.randomUUID();
593
677
  const response = await this.fetchImpl(
594
678
  this.url(`/v1/workspaces/${workspaceId}/sessions/${sessionId}/events`, {
595
679
  ...(listOptions?.after !== undefined ? { after: String(listOptions.after) } : {}),
@@ -616,11 +700,14 @@ export class OpenGeniClient {
616
700
  }),
617
701
  {
618
702
  method: "GET",
619
- headers: { ...this.headers(), Accept: "application/json" },
703
+ headers: { ...this.headers(correlationId), Accept: "application/json" },
620
704
  },
621
705
  );
622
706
  assertApiContractResponse(response);
623
- if (!response.ok) throw new OpenGeniApiError(response.status, await safeText(response));
707
+ if (!response.ok) {
708
+ throw await apiErrorFromResponse(response, { method: "GET", correlationId });
709
+ }
710
+ await assertJsonResponse(response, { method: "GET", correlationId });
624
711
  const body = await response.json();
625
712
  if (options.resultMode === "compact") {
626
713
  return body as SessionEventCompactResult;
@@ -820,14 +907,15 @@ export class OpenGeniClient {
820
907
  const url = this.url(`/v1/workspaces/${workspaceId}/sessions/${sessionId}/events/stream`, {
821
908
  after: String(options.after ?? 0),
822
909
  });
910
+ const correlationId = crypto.randomUUID();
823
911
  const response = await this.fetchImpl(url, {
824
912
  method: "GET",
825
- headers: { ...this.headers(), Accept: "text/event-stream" },
913
+ headers: { ...this.headers(correlationId), Accept: "text/event-stream" },
826
914
  ...(options.signal ? { signal: options.signal } : {}),
827
915
  });
828
916
  assertApiContractResponse(response);
829
917
  if (!response.ok) {
830
- throw new OpenGeniApiError(response.status, await safeText(response));
918
+ throw await apiErrorFromResponse(response, { method: "GET", correlationId });
831
919
  }
832
920
  if (!response.body) {
833
921
  throw new OpenGeniApiError(response.status, "SSE response did not include a readable body");
@@ -973,6 +1061,7 @@ export class OpenGeniClient {
973
1061
  workspaceId: string,
974
1062
  options: { after?: number; limit?: number } = {},
975
1063
  ): Promise<WorkspaceControlEventPage> {
1064
+ const correlationId = crypto.randomUUID();
976
1065
  const response = await this.fetchImpl(
977
1066
  this.url(`/v1/workspaces/${workspaceId}/control-events`, {
978
1067
  ...(options.after !== undefined ? { after: String(options.after) } : {}),
@@ -980,13 +1069,14 @@ export class OpenGeniClient {
980
1069
  }),
981
1070
  {
982
1071
  method: "GET",
983
- headers: { ...this.headers(), Accept: "application/json" },
1072
+ headers: { ...this.headers(correlationId), Accept: "application/json" },
984
1073
  },
985
1074
  );
986
1075
  assertApiContractResponse(response);
987
1076
  if (!response.ok) {
988
- throw new OpenGeniApiError(response.status, await safeText(response));
1077
+ throw await apiErrorFromResponse(response, { method: "GET", correlationId });
989
1078
  }
1079
+ await assertJsonResponse(response, { method: "GET", correlationId });
990
1080
  const events = (await response.json()) as WorkspaceControlEvent[];
991
1081
  const bytesHeader = response.headers.get("X-OpenGeni-Page-Bytes");
992
1082
  const nextHeader = response.headers.get("X-OpenGeni-Next-After");
@@ -1027,18 +1117,21 @@ export class OpenGeniClient {
1027
1117
  workspaceId: string,
1028
1118
  options: { after?: number; signal?: AbortSignal } = {},
1029
1119
  ): Promise<ReadableStream<Uint8Array>> {
1120
+ const correlationId = crypto.randomUUID();
1030
1121
  const response = await this.fetchImpl(
1031
1122
  this.url(`/v1/workspaces/${workspaceId}/control-events/stream`, {
1032
1123
  after: String(options.after ?? 0),
1033
1124
  }),
1034
1125
  {
1035
1126
  method: "GET",
1036
- headers: { ...this.headers(), Accept: "text/event-stream" },
1127
+ headers: { ...this.headers(correlationId), Accept: "text/event-stream" },
1037
1128
  ...(options.signal ? { signal: options.signal } : {}),
1038
1129
  },
1039
1130
  );
1040
1131
  assertApiContractResponse(response);
1041
- if (!response.ok) throw new OpenGeniApiError(response.status, await safeText(response));
1132
+ if (!response.ok) {
1133
+ throw await apiErrorFromResponse(response, { method: "GET", correlationId });
1134
+ }
1042
1135
  if (!response.body) {
1043
1136
  throw new OpenGeniApiError(response.status, "SSE response did not include a readable body");
1044
1137
  }
@@ -1919,21 +2012,27 @@ export class OpenGeniClient {
1919
2012
  * -> complete. Returns the ready `FileAsset`.
1920
2013
  */
1921
2014
  async uploadFile(workspaceId: string, input: UploadFileInput): Promise<FileAsset> {
1922
- // Copy Uint8Array views into a Blob so byte offsets/shared buffers can't
1923
- // leak surrounding bytes into the PUT body.
2015
+ // Snapshot mutable inputs before hashing so the digest always describes the
2016
+ // exact bytes later sent to object storage. Copy Uint8Array views into a
2017
+ // Blob so byte offsets/shared buffers can't leak surrounding bytes.
1924
2018
  const body: Blob | ArrayBuffer | string =
1925
- input.data instanceof Uint8Array ? new Blob([input.data.slice()]) : input.data;
2019
+ input.data instanceof Uint8Array
2020
+ ? new Blob([input.data.slice()])
2021
+ : input.data instanceof ArrayBuffer
2022
+ ? input.data.slice(0)
2023
+ : input.data;
1926
2024
  const sizeBytes =
1927
2025
  typeof body === "string"
1928
2026
  ? new TextEncoder().encode(body).byteLength
1929
2027
  : body instanceof Blob
1930
2028
  ? body.size
1931
2029
  : body.byteLength;
2030
+ const sha256 = input.sha256 ?? (await sha256ForUpload(body));
1932
2031
  const upload = await this.beginFileUpload(workspaceId, {
1933
2032
  filename: input.filename,
1934
2033
  contentType: input.contentType,
1935
2034
  sizeBytes,
1936
- ...(input.sha256 !== undefined ? { sha256: input.sha256 } : {}),
2035
+ sha256,
1937
2036
  });
1938
2037
  const putResponse = await this.fetchImpl(upload.putUrl, {
1939
2038
  method: "PUT",
@@ -1947,7 +2046,7 @@ export class OpenGeniClient {
1947
2046
  body,
1948
2047
  });
1949
2048
  if (!putResponse.ok) {
1950
- throw new OpenGeniApiError(putResponse.status, await safeText(putResponse));
2049
+ throw await apiErrorFromResponse(putResponse, { method: "PUT" });
1951
2050
  }
1952
2051
  return await this.completeFileUpload(workspaceId, upload.uploadId);
1953
2052
  }
@@ -1982,12 +2081,13 @@ export class OpenGeniClient {
1982
2081
  if (options.range && (options.range.length > 128 || /[^\x20-\x7e]/.test(options.range))) {
1983
2082
  throw new RangeError("retained artifact range must be at most 128 printable ASCII bytes");
1984
2083
  }
2084
+ const correlationId = crypto.randomUUID();
1985
2085
  const response = await this.fetchImpl(
1986
2086
  this.url(`/v1/workspaces/${workspaceId}/artifacts/${artifactId}/content`),
1987
2087
  {
1988
2088
  method: "GET",
1989
2089
  headers: {
1990
- ...this.headers(),
2090
+ ...this.headers(correlationId),
1991
2091
  Accept: "application/octet-stream",
1992
2092
  ...(options.range ? { Range: options.range } : {}),
1993
2093
  },
@@ -2001,7 +2101,7 @@ export class OpenGeniClient {
2001
2101
  throw error;
2002
2102
  }
2003
2103
  if (!response.ok) {
2004
- throw new OpenGeniApiError(response.status, await safeBoundedText(response));
2104
+ throw await apiErrorFromResponse(response, { method: "GET", correlationId });
2005
2105
  }
2006
2106
  if (response.status !== 200 && response.status !== 206) {
2007
2107
  await cancelResponseBody(response, "unexpected retained artifact response status");
@@ -2091,6 +2191,39 @@ export class OpenGeniClient {
2091
2191
  );
2092
2192
  }
2093
2193
 
2194
+ /**
2195
+ * Drop raw text or an already-uploaded file into the workspace's Default
2196
+ * base. When curation is enabled, it may name, summarize, categorize, and
2197
+ * (confidence permitting) file the document into the best-matching base;
2198
+ * provider=none leaves caller metadata and Default placement unchanged.
2199
+ */
2200
+ async createKnowledgeDrop(
2201
+ workspaceId: string,
2202
+ request: CreateKnowledgeDropRequest,
2203
+ ): Promise<Document> {
2204
+ return await this.requestJson<Document>(
2205
+ "POST",
2206
+ `/v1/workspaces/${workspaceId}/knowledge/drops`,
2207
+ request,
2208
+ );
2209
+ }
2210
+
2211
+ /**
2212
+ * Move a document (and its indexed chunks) to another base. With no
2213
+ * targetBaseId, applies the document's stored curation suggestion.
2214
+ */
2215
+ async moveDocument(
2216
+ workspaceId: string,
2217
+ documentId: string,
2218
+ request: MoveDocumentRequest = {},
2219
+ ): Promise<Document> {
2220
+ return await this.requestJson<Document>(
2221
+ "POST",
2222
+ `/v1/workspaces/${workspaceId}/documents/${documentId}/move`,
2223
+ request,
2224
+ );
2225
+ }
2226
+
2094
2227
  /** Retry indexing for a failed document. */
2095
2228
  async reindexDocument(
2096
2229
  workspaceId: string,
@@ -2393,15 +2526,12 @@ export class OpenGeniClient {
2393
2526
 
2394
2527
  // --- GitHub ----------------------------------------------------------------------------------
2395
2528
 
2396
- /** GitHub App configuration status; install/link URLs are null while new binding is disabled. */
2529
+ /** GitHub App server configuration plus truthful workspace binding status. */
2397
2530
  async getGitHubApp(workspaceId: string): Promise<GitHubAppInfo> {
2398
2531
  return await this.requestJson<GitHubAppInfo>("GET", `/v1/workspaces/${workspaceId}/github/app`);
2399
2532
  }
2400
2533
 
2401
- /**
2402
- * Compatibility URL for previously issued state. New installation binding is
2403
- * disabled, so the endpoint validates state and terminates with HTTP 410.
2404
- */
2534
+ /** Build the GitHub owner-consent entry URL for fresh workspace-bound state. */
2405
2535
  githubConnectUrl(workspaceId: string, state: string): string {
2406
2536
  return this.url(`/v1/workspaces/${workspaceId}/github/connect`, { state });
2407
2537
  }
@@ -2508,13 +2638,14 @@ export class OpenGeniClient {
2508
2638
 
2509
2639
  // --- Internals -------------------------------------------------------------
2510
2640
 
2511
- private headers(): Record<string, string> {
2641
+ private headers(correlationId?: string): Record<string, string> {
2512
2642
  const extra =
2513
2643
  typeof this.options.headers === "function" ? this.options.headers() : this.options.headers;
2514
2644
  return {
2515
2645
  ...(this.options.apiKey ? { Authorization: `Bearer ${this.options.apiKey}` } : {}),
2516
2646
  ...extra,
2517
2647
  [OPENGENI_API_CONTRACT_HEADER]: OPENGENI_API_CONTRACT_REVISION,
2648
+ ...(correlationId ? { [OPENGENI_CORRELATION_HEADER]: correlationId } : {}),
2518
2649
  };
2519
2650
  }
2520
2651
 
@@ -2678,37 +2809,63 @@ export class OpenGeniClient {
2678
2809
  query: Record<string, string> = {},
2679
2810
  options: OpenGeniRequestOptions = {},
2680
2811
  ): Promise<T> {
2681
- const response = await this.fetchImpl(this.url(path, query), {
2682
- method,
2683
- headers: {
2684
- ...this.headers(),
2685
- Accept: "application/json",
2686
- ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
2687
- },
2688
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2689
- ...(options.signal ? { signal: options.signal } : {}),
2690
- });
2812
+ const correlationId = crypto.randomUUID();
2813
+ let response: Response;
2814
+ try {
2815
+ response = await this.fetchImpl(this.url(path, query), {
2816
+ method,
2817
+ headers: {
2818
+ ...this.headers(correlationId),
2819
+ Accept: "application/json",
2820
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
2821
+ },
2822
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2823
+ ...(options.signal ? { signal: options.signal } : {}),
2824
+ });
2825
+ } catch (error) {
2826
+ if (isMutationMethod(method)) {
2827
+ throw mutationTransportError(correlationId);
2828
+ }
2829
+ throw error;
2830
+ }
2691
2831
  assertApiContractResponse(response);
2692
2832
  if (!response.ok) {
2693
- throw new OpenGeniApiError(response.status, await safeText(response));
2833
+ throw await apiErrorFromResponse(response, { method, correlationId });
2834
+ }
2835
+ await assertJsonResponse(response, { method, correlationId });
2836
+ try {
2837
+ return (await response.json()) as T;
2838
+ } catch (error) {
2839
+ if (isMutationMethod(method)) {
2840
+ throw mutationTransportError(correlationId);
2841
+ }
2842
+ throw error;
2694
2843
  }
2695
- return (await response.json()) as T;
2696
2844
  }
2697
2845
 
2698
2846
  /** Like `requestJson` for endpoints that respond with no body (204). */
2699
2847
  private async requestVoid(method: string, path: string, body?: unknown): Promise<void> {
2700
- const response = await this.fetchImpl(this.url(path), {
2701
- method,
2702
- headers: {
2703
- ...this.headers(),
2704
- Accept: "application/json",
2705
- ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
2706
- },
2707
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2708
- });
2848
+ const correlationId = crypto.randomUUID();
2849
+ let response: Response;
2850
+ try {
2851
+ response = await this.fetchImpl(this.url(path), {
2852
+ method,
2853
+ headers: {
2854
+ ...this.headers(correlationId),
2855
+ Accept: "application/json",
2856
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
2857
+ },
2858
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
2859
+ });
2860
+ } catch (error) {
2861
+ if (isMutationMethod(method)) {
2862
+ throw mutationTransportError(correlationId);
2863
+ }
2864
+ throw error;
2865
+ }
2709
2866
  assertApiContractResponse(response);
2710
2867
  if (!response.ok) {
2711
- throw new OpenGeniApiError(response.status, await safeText(response));
2868
+ throw await apiErrorFromResponse(response, { method, correlationId });
2712
2869
  }
2713
2870
  }
2714
2871
  }
@@ -2720,22 +2877,86 @@ function assertApiContractResponse(response: Response): void {
2720
2877
  }
2721
2878
  }
2722
2879
 
2723
- async function safeText(response: Response): Promise<string> {
2724
- try {
2725
- return await response.text();
2726
- } catch {
2727
- return "";
2728
- }
2880
+ const API_ERROR_MAX_BYTES = 16 * 1024;
2881
+
2882
+ type ApiErrorRequestContext = {
2883
+ method: string;
2884
+ correlationId?: string | undefined;
2885
+ };
2886
+
2887
+ async function apiErrorFromResponse(
2888
+ response: Response,
2889
+ context: ApiErrorRequestContext,
2890
+ ): Promise<OpenGeniApiError> {
2891
+ return new OpenGeniApiError(response.status, await readBoundedJsonErrorBody(response), {
2892
+ correlationId: response.headers.get(OPENGENI_CORRELATION_HEADER) ?? context.correlationId,
2893
+ mutation: isMutationMethod(context.method),
2894
+ });
2895
+ }
2896
+
2897
+ async function assertJsonResponse(
2898
+ response: Response,
2899
+ context: ApiErrorRequestContext,
2900
+ ): Promise<void> {
2901
+ if (isJsonContentType(response.headers.get("content-type"))) return;
2902
+ await cancelResponseBody(response, "unexpected non-JSON API response");
2903
+ throw new OpenGeniApiError(502, "", {
2904
+ code: "upstream_unavailable",
2905
+ retryable: true,
2906
+ correlationId: response.headers.get(OPENGENI_CORRELATION_HEADER) ?? context.correlationId,
2907
+ outcomeUnknown: isMutationMethod(context.method),
2908
+ displayMessage: "OpenGeni is temporarily unavailable — retry.",
2909
+ });
2729
2910
  }
2730
2911
 
2731
- async function safeBoundedText(response: Response): Promise<string> {
2912
+ async function readBoundedJsonErrorBody(response: Response): Promise<string> {
2913
+ if (!isJsonContentType(response.headers.get("content-type"))) {
2914
+ await cancelResponseBody(response, "discarding API error body");
2915
+ return "";
2916
+ }
2917
+ if (Number(response.headers.get("content-length")) > API_ERROR_MAX_BYTES) {
2918
+ await cancelResponseBody(response, "discarding API error body");
2919
+ return "";
2920
+ }
2732
2921
  try {
2733
- return new TextDecoder().decode(await readBoundedResponseBytes(response, 64 * 1024, null));
2922
+ return new TextDecoder().decode(
2923
+ await readBoundedResponseBytes(response, API_ERROR_MAX_BYTES, null),
2924
+ );
2734
2925
  } catch {
2735
2926
  return "";
2736
2927
  }
2737
2928
  }
2738
2929
 
2930
+ function isJsonContentType(value: string | null): boolean {
2931
+ return /^(application\/json|[^;]+\+json)\s*(;|$)/i.test(value ?? "");
2932
+ }
2933
+
2934
+ function isMutationMethod(method: string): boolean {
2935
+ return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
2936
+ }
2937
+
2938
+ function mutationTransportError(correlationId: string): OpenGeniApiError {
2939
+ return new OpenGeniApiError(0, "", {
2940
+ code: "network_error",
2941
+ retryable: true,
2942
+ correlationId,
2943
+ outcomeUnknown: true,
2944
+ mutation: true,
2945
+ displayMessage: "OpenGeni could not confirm the request — reconcile before retrying.",
2946
+ });
2947
+ }
2948
+
2949
+ async function sha256ForUpload(body: Blob | ArrayBuffer | string): Promise<string> {
2950
+ const bytes =
2951
+ typeof body === "string"
2952
+ ? new TextEncoder().encode(body)
2953
+ : body instanceof Blob
2954
+ ? new Uint8Array(await body.arrayBuffer())
2955
+ : new Uint8Array(body);
2956
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
2957
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
2958
+ }
2959
+
2739
2960
  async function cancelResponseBody(response: Response, reason: string): Promise<void> {
2740
2961
  await response.body?.cancel(reason).catch(() => undefined);
2741
2962
  }