@opengeni/sdk 4.4.1 → 4.5.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.
Files changed (57) hide show
  1. package/README.md +27 -0
  2. package/dist/adapters/next.js +2 -2
  3. package/dist/artifacts.js +4 -4
  4. package/dist/browser.d.ts +2 -0
  5. package/dist/browser.js +5 -3
  6. package/dist/chat/index.js +5 -5
  7. package/dist/{chunk-3BWTS2MJ.js → chunk-ARDVIZIJ.js} +2 -2
  8. package/dist/{chunk-PVWOBYAZ.js → chunk-BXMNDWAN.js} +256 -78
  9. package/dist/chunk-BXMNDWAN.js.map +1 -0
  10. package/dist/{chunk-IVHLADZ7.js → chunk-GRUMGFK6.js} +2 -2
  11. package/dist/{chunk-2RVPHF37.js → chunk-JBAGMSN2.js} +66 -10
  12. package/dist/chunk-JBAGMSN2.js.map +1 -0
  13. package/dist/{chunk-35S72HAE.js → chunk-U4JUWOZV.js} +5 -5
  14. package/dist/{chunk-BLID3DCD.js → chunk-V4AIZAFF.js} +1 -1
  15. package/dist/chunk-V4AIZAFF.js.map +1 -0
  16. package/dist/{chunk-ALXDFL6C.js → chunk-VSOXNL3T.js} +2 -2
  17. package/dist/{chunk-CT6P2W2C.js → chunk-WI3WUT5H.js} +2 -2
  18. package/dist/client.d.ts +24 -1
  19. package/dist/codex-realtime-controller.js +1 -1
  20. package/dist/core.d.ts +2 -0
  21. package/dist/core.js +5 -3
  22. package/dist/document-authority.js +3 -3
  23. package/dist/editable-artifacts.js +1 -1
  24. package/dist/index.d.ts +4 -2
  25. package/dist/index.js +35 -33
  26. package/dist/index.js.map +1 -1
  27. package/dist/read-retry.d.ts +61 -0
  28. package/dist/realtime.js +5 -5
  29. package/dist/session-proxy.d.ts +6 -1
  30. package/dist/session-proxy.js +2 -2
  31. package/dist/site-bridge-host.js +3 -3
  32. package/dist/site-browser-runtime.gen.d.ts +1 -1
  33. package/dist/site-document.js +1 -1
  34. package/dist/site-document.js.map +1 -1
  35. package/dist/site.js +3 -3
  36. package/dist/types.d.ts +40 -0
  37. package/dist/workspace-artifacts.d.ts +5 -0
  38. package/dist/workspace-live-stream.d.ts +7 -0
  39. package/package.json +3 -3
  40. package/src/browser.ts +2 -0
  41. package/src/client.ts +191 -73
  42. package/src/core.ts +2 -0
  43. package/src/index.ts +4 -0
  44. package/src/read-retry.ts +207 -0
  45. package/src/session-proxy.ts +95 -4
  46. package/src/site-browser-runtime.gen.ts +1 -1
  47. package/src/types.ts +38 -0
  48. package/src/workspace-artifacts.ts +6 -0
  49. package/src/workspace-live-stream.ts +12 -1
  50. package/dist/chunk-2RVPHF37.js.map +0 -1
  51. package/dist/chunk-BLID3DCD.js.map +0 -1
  52. package/dist/chunk-PVWOBYAZ.js.map +0 -1
  53. /package/dist/{chunk-3BWTS2MJ.js.map → chunk-ARDVIZIJ.js.map} +0 -0
  54. /package/dist/{chunk-IVHLADZ7.js.map → chunk-GRUMGFK6.js.map} +0 -0
  55. /package/dist/{chunk-35S72HAE.js.map → chunk-U4JUWOZV.js.map} +0 -0
  56. /package/dist/{chunk-ALXDFL6C.js.map → chunk-VSOXNL3T.js.map} +0 -0
  57. /package/dist/{chunk-CT6P2W2C.js.map → chunk-WI3WUT5H.js.map} +0 -0
package/dist/site.js CHANGED
@@ -24,13 +24,13 @@ import {
24
24
  sanitizeOpenGeniSiteToolInvokeRequest,
25
25
  serveSiteHttp,
26
26
  siteSessionPath
27
- } from "./chunk-ALXDFL6C.js";
27
+ } from "./chunk-VSOXNL3T.js";
28
28
  import "./chunk-OV3POE62.js";
29
29
  import "./chunk-ORYDCRFM.js";
30
- import "./chunk-PVWOBYAZ.js";
30
+ import "./chunk-BXMNDWAN.js";
31
31
  import "./chunk-X5ITQVXE.js";
32
32
  import "./chunk-72YZCZWF.js";
33
- import "./chunk-BLID3DCD.js";
33
+ import "./chunk-V4AIZAFF.js";
34
34
  import "./chunk-POYRW6KT.js";
35
35
  import "./chunk-ODKRQUWR.js";
36
36
  export {
package/dist/types.d.ts CHANGED
@@ -1649,6 +1649,32 @@ export type SessionListTotals = {
1649
1649
  waiting?: number;
1650
1650
  }>;
1651
1651
  };
1652
+ /**
1653
+ * One Site row in one project (`siteConversations: "group"`): a Site with
1654
+ * the viewer's conversations, created by a chat they list, or opened by them.
1655
+ * Counts are complete for the viewer, independent of page size.
1656
+ */
1657
+ export type SiteConversationGroup = {
1658
+ siteId: string;
1659
+ channelId: string | null;
1660
+ /** The Site's current title when the viewer can read it, else its creation label. */
1661
+ title: string;
1662
+ /** Root conversations in the group; 0 for a Site with none yet. */
1663
+ conversations: number;
1664
+ /** Latest activity: its conversations, its own changes, or the viewer opening it. */
1665
+ updatedAt: string;
1666
+ /** Creation time of the group's newest conversation, else of the Site. */
1667
+ createdAt: string;
1668
+ total: number;
1669
+ attention: number;
1670
+ attentionSince: string | null;
1671
+ failed: number;
1672
+ active: number;
1673
+ queued: number;
1674
+ waiting: number;
1675
+ unread: number;
1676
+ activeWork: number;
1677
+ };
1652
1678
  export type SessionListResponse = {
1653
1679
  totals?: SessionListTotals;
1654
1680
  needsYouOnly?: true;
@@ -1669,6 +1695,12 @@ export type SessionListResponse = {
1669
1695
  originSiteId?: string;
1670
1696
  /** Server-resolved Site access filter, when requested. */
1671
1697
  siteAccessId?: string;
1698
+ /** Receipt for the requested Site conversation mode. */
1699
+ siteConversations?: "include" | "group";
1700
+ /** Complete Site rows for a `group` root page, newest activity first. */
1701
+ siteGroups?: SiteConversationGroup[];
1702
+ /** True when Site rows beyond the server bound were omitted. */
1703
+ siteGroupsTruncated?: true;
1672
1704
  sessions: Session[];
1673
1705
  nextCursor: string | null;
1674
1706
  };
@@ -4157,6 +4189,14 @@ export type ClientConfig = {
4157
4189
  claudeSubscriptionEnabled?: boolean | undefined;
4158
4190
  /** Whether people can connect their own computers; absent on older servers. */
4159
4191
  connectedMachines?: boolean | undefined;
4192
+ /**
4193
+ * The Chrome extension people add to connect their own Chrome, and its
4194
+ * Chrome Web Store page; absent on older servers (Opengeni Browser).
4195
+ */
4196
+ browserExtension?: {
4197
+ name: string;
4198
+ installUrl: string;
4199
+ } | undefined;
4160
4200
  defaultModel: string;
4161
4201
  allowedModels: string[];
4162
4202
  models: ClientModel[];
@@ -26,6 +26,11 @@ export type WorkspaceArtifactVersion = {
26
26
  createdBySubjectId: string;
27
27
  createdAt: string;
28
28
  };
29
+ /** This person's latest recorded open of a Site, which orders its sidebar row. */
30
+ export type SiteOpenedResponse = {
31
+ /** When the open was recorded (ISO 8601). */
32
+ openedAt: string;
33
+ };
29
34
  /** Who can see a Site: everyone in the workspace, or only its owner ("Only me"). */
30
35
  export type WorkspaceArtifactVisibility = "private" | "workspace";
31
36
  export type WorkspaceArtifact = {
@@ -7,6 +7,13 @@ export type WorkspaceSessionListEvent = {
7
7
  workspaceId: string;
8
8
  available: boolean;
9
9
  sessionIds: string[] | null;
10
+ /**
11
+ * Changed Site conversations visible to this viewer. Ordinary lists omit
12
+ * them; only a surface that shows Site conversations needs these. A hint
13
+ * whose `sessionIds` is empty and that carries only these changes nothing
14
+ * for ordinary lists.
15
+ */
16
+ siteSessionIds?: string[];
10
17
  };
11
18
  export type WorkspaceLiveEvent = WorkspaceControlEvent | WorkspaceInteractionRevisionEvent | WorkspaceSessionListEvent;
12
19
  export type WorkspaceLiveStreamOptions = Omit<StreamSessionEventsOptions, "after"> & {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/sdk",
3
- "version": "4.4.1",
3
+ "version": "4.5.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": {
@@ -218,8 +218,8 @@
218
218
  "prepublishOnly": "bash ../../scripts/prepublish-guard"
219
219
  },
220
220
  "dependencies": {
221
- "@opengeni/connect": "4.4.1",
222
- "@opengeni/contracts": "4.4.1"
221
+ "@opengeni/connect": "4.5.0",
222
+ "@opengeni/contracts": "4.5.0"
223
223
  },
224
224
  "engines": {
225
225
  "node": ">=18"
package/src/browser.ts CHANGED
@@ -45,6 +45,8 @@ export {
45
45
  isRetryableStreamError,
46
46
  } from "./errors";
47
47
  export type { OpenGeniSecureContextRequiredReason } from "./errors";
48
+ export { isTransientRequestError } from "./read-retry";
49
+ export type { OpenGeniReadRetryOptions } from "./read-retry";
48
50
  export { resolveWorkspaceVoiceInputEnabled } from "./transcription";
49
51
  export { OPENGENI_API_CONTRACT_HEADER, OPENGENI_API_CONTRACT_REVISION } from "./types";
50
52
 
package/src/client.ts CHANGED
@@ -104,6 +104,7 @@ import type {
104
104
  } from "./knowledge";
105
105
  import type {
106
106
  DuplicateWorkspaceArtifactRequest,
107
+ SiteOpenedResponse,
107
108
  RollbackWorkspaceArtifactRequest,
108
109
  SetWorkspaceArtifactStatusRequest,
109
110
  UpdateWorkspaceArtifactVisibilityRequest,
@@ -150,6 +151,19 @@ import {
150
151
  } from "./stream";
151
152
  import type { SessionModelContextResponse } from "./model-context";
152
153
  import { withDeprecationNotices, type OpenGeniDeprecationHandler } from "./deprecation";
154
+ import {
155
+ abortableReadRetryDelay,
156
+ isSafeReadMethod,
157
+ isTransientGatewayStatus,
158
+ invalidApiResponseError,
159
+ isTransientRequestError,
160
+ markTransportFailure,
161
+ parseRetryAfterMs,
162
+ readRetryDelayMs,
163
+ resolveReadRetryPolicy,
164
+ type OpenGeniReadRetryOptions,
165
+ type ReadRetryPolicy,
166
+ } from "./read-retry";
153
167
  import type {
154
168
  SkillRecord,
155
169
  SkillSummary,
@@ -825,6 +839,13 @@ export type SessionListPageOptions = {
825
839
  mineOnly?: boolean;
826
840
  /** Created through this Site. In a Site-bound client, "current" resolves to its own Site. */
827
841
  originSiteId?: string;
842
+ /**
843
+ * Conversations that run inside a Site are left out of ordinary lists.
844
+ * `include` lists them as ordinary rows; `group` (a root page) keeps them
845
+ * out of the rows and returns one `siteGroups` entry per Site and project.
846
+ * Older servers omit the `siteConversations` receipt and the Site rows.
847
+ */
848
+ siteConversations?: "include" | "group";
828
849
  limit?: number;
829
850
  parentSessionId?: string | null;
830
851
  /** Only sessions carrying this exact opaque end-user label. */
@@ -915,6 +936,15 @@ export type WorkspaceControlEventPage = {
915
936
  nextAfter: number | null;
916
937
  };
917
938
 
939
+ /** What one safe-read attempt observed, for the retry decision. */
940
+ type ReadAttemptOutcome = {
941
+ /** `fetch` or the response body rejected (reset connection, HTTP/2 error, ...). */
942
+ transportFailure: boolean;
943
+ /** A real 502, 503 or 504 response. */
944
+ gatewayFailure: boolean;
945
+ retryAfterMs: number | null;
946
+ };
947
+
918
948
  export type OpenGeniClientOptions = {
919
949
  /** Explicit compatibility for an API predating targeted tool calls. No automatic downgrade. */
920
950
  toolGatewayMode?: "target" | "catalog";
@@ -954,6 +984,14 @@ export type OpenGeniClientOptions = {
954
984
  * `"compatible"` everywhere else.
955
985
  */
956
986
  apiContract?: "strict" | "compatible" | undefined;
987
+ /**
988
+ * Bounded quiet retry for safe JSON reads (`GET`/`HEAD`) that fail
989
+ * transiently: a transport error or a 502, 503 or 504 (honoring
990
+ * `Retry-After`). This rides out API rolling deploys and short network
991
+ * blips. Mutations are never retried. On by default (4 attempts within
992
+ * 5 seconds); pass `false` to turn it off or options to tune it.
993
+ */
994
+ readRetry?: OpenGeniReadRetryOptions | false | undefined;
957
995
  };
958
996
 
959
997
  function defaultApiContractMode(options: OpenGeniClientOptions): "strict" | "compatible" {
@@ -1190,6 +1228,7 @@ export class OpenGeniClient {
1190
1228
  private readonly fetchImpl: FetchLike;
1191
1229
  private readonly sessionCommandTimeoutMs: number;
1192
1230
  private readonly apiContractStrict: boolean;
1231
+ private readonly readRetryPolicy: ReadRetryPolicy | null;
1193
1232
  private readonly active = new Map<string, SingleFlightReadEntry>();
1194
1233
  private readonly generations = new Map<string, number>();
1195
1234
  private readonly queued = new Map<string, SingleFlightReadEntry>();
@@ -1212,6 +1251,7 @@ export class OpenGeniClient {
1212
1251
  }
1213
1252
  this.sessionCommandTimeoutMs = sessionCommandTimeoutMs;
1214
1253
  this.apiContractStrict = defaultApiContractMode(options) === "strict";
1254
+ this.readRetryPolicy = resolveReadRetryPolicy(options.readRetry);
1215
1255
  this.interaction = new OpenGeniInteractionClient(this);
1216
1256
  this.tools = createLazyToolsFacade(this);
1217
1257
  }
@@ -1326,7 +1366,7 @@ export class OpenGeniClient {
1326
1366
  options.signal ? { signal: options.signal } : {},
1327
1367
  );
1328
1368
  if (!isTranscriptionRecordingListResponse(body)) {
1329
- throw new OpenGeniApiError(502, "Invalid transcription recording list response.", {
1369
+ throw invalidApiResponseError("Invalid transcription recording list response.", {
1330
1370
  code: "invalid_response",
1331
1371
  });
1332
1372
  }
@@ -1998,6 +2038,7 @@ export class OpenGeniClient {
1998
2038
  if (options.mineOnly) query.mineOnly = "true";
1999
2039
  if (options.includePinned === false) query.includePinned = "false";
2000
2040
  if (options.archivedOnly) query.archivedOnly = "true";
2041
+ if (options.siteConversations) query.siteConversations = options.siteConversations;
2001
2042
  result = await this.requestJson<SessionListResponse | SessionListEntryResponse | Session[]>(
2002
2043
  "GET",
2003
2044
  path,
@@ -2078,6 +2119,17 @@ export class OpenGeniClient {
2078
2119
  return response;
2079
2120
  }
2080
2121
 
2122
+ /**
2123
+ * Record that this person opened a Site, which orders its sidebar row like
2124
+ * session activity. Personal; needs `artifacts:read`.
2125
+ */
2126
+ async recordSiteOpened(workspaceId: string, siteId: string): Promise<SiteOpenedResponse> {
2127
+ return this.requestJson(
2128
+ "POST",
2129
+ `/v1/workspaces/${encodeURIComponent(workspaceId)}/published-artifacts/${encodeURIComponent(siteId)}/opened`,
2130
+ );
2131
+ }
2132
+
2081
2133
  /** Console/owner-authorized agent only. Never available through the Site frame bridge. */
2082
2134
  async listSiteSessionAccess(
2083
2135
  workspaceId: string,
@@ -4772,7 +4824,7 @@ export class OpenGeniClient {
4772
4824
  header.length > 64 * 1024
4773
4825
  ) {
4774
4826
  await cancelResponseBody(response, "browser frame metadata is invalid");
4775
- throw new OpenGeniApiError(502, "browser frame metadata is invalid");
4827
+ throw invalidApiResponseError("browser frame metadata is invalid");
4776
4828
  }
4777
4829
  let metadata: BrowserFrameMetadata;
4778
4830
  try {
@@ -4781,7 +4833,7 @@ export class OpenGeniClient {
4781
4833
  );
4782
4834
  } catch {
4783
4835
  await cancelResponseBody(response, "browser frame metadata is invalid");
4784
- throw new OpenGeniApiError(502, "browser frame metadata is invalid");
4836
+ throw invalidApiResponseError("browser frame metadata is invalid");
4785
4837
  }
4786
4838
  const data = await readBoundedResponseBytes(response, 24 * 1024 * 1024, null);
4787
4839
  if (
@@ -4789,7 +4841,7 @@ export class OpenGeniClient {
4789
4841
  metadata.targetId !== targetId ||
4790
4842
  metadata.mediaType !== mediaType
4791
4843
  ) {
4792
- throw new OpenGeniApiError(502, "browser frame evidence does not match its request");
4844
+ throw invalidApiResponseError("browser frame evidence does not match its request");
4793
4845
  }
4794
4846
  return { ...metadata, data };
4795
4847
  }
@@ -4838,7 +4890,7 @@ export class OpenGeniClient {
4838
4890
  options,
4839
4891
  );
4840
4892
  if (state.browserSessionId !== browserSessionId || state.targetId !== targetId) {
4841
- throw new OpenGeniApiError(502, "browser target state belongs to another binding");
4893
+ throw invalidApiResponseError("browser target state belongs to another binding");
4842
4894
  }
4843
4895
  return state;
4844
4896
  }
@@ -4865,7 +4917,7 @@ export class OpenGeniClient {
4865
4917
  result.documentGeneration !== request.expectedDocumentGeneration ||
4866
4918
  result.frameId !== request.expectedFrameId
4867
4919
  ) {
4868
- throw new OpenGeniApiError(502, "browser DOM read belongs to another binding");
4920
+ throw invalidApiResponseError("browser DOM read belongs to another binding");
4869
4921
  }
4870
4922
  return result;
4871
4923
  }
@@ -5193,19 +5245,19 @@ export class OpenGeniClient {
5193
5245
  const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
5194
5246
  if (mediaType !== "image/jpeg" && mediaType !== "image/png") {
5195
5247
  await cancelResponseBody(response, "computer frame media type is invalid");
5196
- throw new OpenGeniApiError(502, "computer frame media type is invalid");
5248
+ throw invalidApiResponseError("computer frame media type is invalid");
5197
5249
  }
5198
5250
  const metadataHeader = response.headers.get("x-opengeni-computer-frame");
5199
5251
  if (!metadataHeader || metadataHeader.length > 32 * 1024) {
5200
5252
  await cancelResponseBody(response, "computer frame metadata is invalid");
5201
- throw new OpenGeniApiError(502, "computer frame metadata is invalid");
5253
+ throw invalidApiResponseError("computer frame metadata is invalid");
5202
5254
  }
5203
5255
  let metadata: ReturnType<typeof decodeComputerFrameMetadataHeader>;
5204
5256
  try {
5205
5257
  metadata = decodeComputerFrameMetadataHeader(metadataHeader);
5206
5258
  } catch {
5207
5259
  await cancelResponseBody(response, "computer frame metadata is invalid");
5208
- throw new OpenGeniApiError(502, "computer frame metadata is invalid");
5260
+ throw invalidApiResponseError("computer frame metadata is invalid");
5209
5261
  }
5210
5262
  const bytes = await readBoundedResponseBytes(response, 256 * 1024, null);
5211
5263
  const mismatchReason = computerFrameEvidenceMismatchReason(metadata, {
@@ -5215,7 +5267,7 @@ export class OpenGeniClient {
5215
5267
  sha256: await sha256Hex(bytes),
5216
5268
  });
5217
5269
  if (mismatchReason) {
5218
- throw new OpenGeniApiError(502, "computer frame evidence does not match its request");
5270
+ throw invalidApiResponseError("computer frame evidence does not match its request");
5219
5271
  }
5220
5272
  return { ...metadata, data: bytes };
5221
5273
  }
@@ -7801,7 +7853,7 @@ export class OpenGeniClient {
7801
7853
  metadata.originalBytes <= 0 ||
7802
7854
  metadata.originalBytes > COMPUTER_SCREENSHOT_MAX_BYTES
7803
7855
  ) {
7804
- throw new OpenGeniApiError(502, "retained screenshot metadata is invalid");
7856
+ throw invalidApiResponseError("retained screenshot metadata is invalid");
7805
7857
  }
7806
7858
  const bytes = await this.downloadRetainedArtifactBytes(metadata, options);
7807
7859
  return { metadata, bytes };
@@ -7818,7 +7870,7 @@ export class OpenGeniClient {
7818
7870
  const bytes = new Uint8Array(artifact.originalBytes);
7819
7871
  const pageBytes = Math.min(artifact.retrieval.maxRangeBytes, RETAINED_OUTPUT_MAX_PAGE_BYTES);
7820
7872
  if (!Number.isSafeInteger(pageBytes) || pageBytes <= 0) {
7821
- throw new OpenGeniApiError(502, "retained artifact range metadata is invalid");
7873
+ throw invalidApiResponseError("retained artifact range metadata is invalid");
7822
7874
  }
7823
7875
  for (let start = 0; start < bytes.byteLength; start += pageBytes) {
7824
7876
  options.signal?.throwIfAborted();
@@ -7841,7 +7893,7 @@ export class OpenGeniClient {
7841
7893
  }
7842
7894
  }
7843
7895
  }
7844
- if (!page) throw new OpenGeniApiError(502, "retained artifact range retry exhausted");
7896
+ if (!page) throw invalidApiResponseError("retained artifact range retry exhausted");
7845
7897
  const expectedLength = end - start + 1;
7846
7898
  if (
7847
7899
  page.status !== 206 ||
@@ -7849,12 +7901,12 @@ export class OpenGeniClient {
7849
7901
  page.contentLength !== expectedLength ||
7850
7902
  page.contentRange !== `bytes ${start}-${end}/${artifact.originalBytes}`
7851
7903
  ) {
7852
- throw new OpenGeniApiError(502, "retained artifact range response is invalid");
7904
+ throw invalidApiResponseError("retained artifact range response is invalid");
7853
7905
  }
7854
7906
  bytes.set(page.bytes, start);
7855
7907
  }
7856
7908
  if ((await sha256Hex(bytes)) !== artifact.sha256) {
7857
- throw new OpenGeniApiError(502, "retained artifact checksum mismatch");
7909
+ throw invalidApiResponseError("retained artifact checksum mismatch");
7858
7910
  }
7859
7911
  return bytes;
7860
7912
  }
@@ -7894,7 +7946,7 @@ export class OpenGeniClient {
7894
7946
  }
7895
7947
  if (response.headers.get("accept-ranges") !== "bytes") {
7896
7948
  await cancelResponseBody(response, "retained artifact response omitted byte-range support");
7897
- throw new OpenGeniApiError(502, "retained artifact response omitted byte-range support");
7949
+ throw invalidApiResponseError("retained artifact response omitted byte-range support");
7898
7950
  }
7899
7951
  let declaredLength: number | null;
7900
7952
  try {
@@ -11363,64 +11415,130 @@ export class OpenGeniClient {
11363
11415
  const correlationId = crypto.randomUUID();
11364
11416
  const abort = requestAbortSignal(options);
11365
11417
  try {
11366
- if (abort.signal?.aborted) {
11367
- throw abort.signal.reason ?? new DOMException("Request aborted", "AbortError");
11368
- }
11369
- const headers = this.headers(correlationId);
11370
- let response: FetchResponse;
11371
- try {
11372
- response = await awaitWithAbort(
11373
- this.fetchImpl(this.url(path, query), {
11374
- method,
11375
- headers: {
11376
- ...headers,
11377
- Accept: "application/json",
11378
- ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
11379
- },
11380
- ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
11381
- ...(abort.signal ? { signal: abort.signal } : {}),
11382
- }),
11418
+ const retryPolicy = isSafeReadMethod(method) ? this.readRetryPolicy : null;
11419
+ if (!retryPolicy) {
11420
+ return await this.requestJsonAttempt<T>(
11421
+ method,
11422
+ path,
11423
+ body,
11424
+ query,
11425
+ options,
11383
11426
  abort.signal,
11427
+ correlationId,
11428
+ null,
11384
11429
  );
11385
- } catch (error) {
11386
- if (options.signal?.aborted) throw error;
11387
- if (isMutationMethod(method)) {
11388
- throw mutationTransportError(correlationId);
11389
- }
11390
- throw error;
11391
11430
  }
11392
- assertApiContractResponse(response, this.apiContractStrict);
11393
- try {
11394
- if (!response.ok) {
11395
- throw await awaitWithAbort(
11396
- apiErrorFromResponse(response, { method, correlationId }),
11431
+ // Safe reads only: a transient failure is retried quietly with jittered
11432
+ // backoff (or the server's Retry-After) inside a small total budget.
11433
+ const startedAt = Date.now();
11434
+ for (let retry = 1; ; retry += 1) {
11435
+ const attempt: ReadAttemptOutcome = {
11436
+ transportFailure: false,
11437
+ gatewayFailure: false,
11438
+ retryAfterMs: null,
11439
+ };
11440
+ try {
11441
+ return await this.requestJsonAttempt<T>(
11442
+ method,
11443
+ path,
11444
+ body,
11445
+ query,
11446
+ options,
11397
11447
  abort.signal,
11448
+ correlationId,
11449
+ attempt,
11398
11450
  );
11399
- }
11400
- if (options.responseType === "void") {
11401
- await awaitWithAbort(
11402
- cancelResponseBody(response, "discarding void API response"),
11403
- abort.signal,
11451
+ } catch (error) {
11452
+ // Only the transport itself or a real 502/503/504 response is
11453
+ // transient; a malformed success response is not retried.
11454
+ const transient =
11455
+ attempt.transportFailure || (attempt.gatewayFailure && isTransientRequestError(error));
11456
+ if (abort.signal?.aborted || !transient) throw error;
11457
+ const delay = readRetryDelayMs(
11458
+ retryPolicy,
11459
+ retry,
11460
+ Date.now() - startedAt,
11461
+ attempt.retryAfterMs,
11404
11462
  );
11405
- return undefined as T;
11463
+ if (delay === null) throw error;
11464
+ await abortableReadRetryDelay(delay, abort.signal);
11406
11465
  }
11407
- await awaitWithAbort(assertJsonResponse(response, { method, correlationId }), abort.signal);
11408
- const value = await awaitWithAbort(response.json(), abort.signal);
11409
- recordObservedSessionSequence(value, response.headers.get(SESSION_SEQUENCE_HEADER));
11410
- return value as T;
11411
- } catch (error) {
11412
- if (options.signal?.aborted) throw error;
11413
- if (error instanceof OpenGeniApiError) throw error;
11414
- if (isMutationMethod(method)) {
11415
- throw mutationTransportError(correlationId);
11416
- }
11417
- throw error;
11418
11466
  }
11419
11467
  } finally {
11420
11468
  abort.dispose();
11421
11469
  }
11422
11470
  }
11423
11471
 
11472
+ private async requestJsonAttempt<T>(
11473
+ method: string,
11474
+ path: string,
11475
+ body: unknown,
11476
+ query: Record<string, string>,
11477
+ options: OpenGeniRequestOptions,
11478
+ signal: AbortSignal | undefined,
11479
+ correlationId: string,
11480
+ attempt: ReadAttemptOutcome | null,
11481
+ ): Promise<T> {
11482
+ if (signal?.aborted) {
11483
+ throw signal.reason ?? new DOMException("Request aborted", "AbortError");
11484
+ }
11485
+ const headers = this.headers(correlationId);
11486
+ let response: FetchResponse;
11487
+ try {
11488
+ response = await awaitWithAbort(
11489
+ this.fetchImpl(this.url(path, query), {
11490
+ method,
11491
+ headers: {
11492
+ ...headers,
11493
+ Accept: "application/json",
11494
+ ...(body !== undefined ? { "Content-Type": "application/json" } : {}),
11495
+ },
11496
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
11497
+ ...(signal ? { signal } : {}),
11498
+ }),
11499
+ signal,
11500
+ );
11501
+ } catch (error) {
11502
+ if (options.signal?.aborted) throw error;
11503
+ if (isMutationMethod(method)) {
11504
+ throw mutationTransportError(correlationId);
11505
+ }
11506
+ if (error instanceof TypeError && attempt) attempt.transportFailure = true;
11507
+ markTransportFailure(error);
11508
+ throw error;
11509
+ }
11510
+ assertApiContractResponse(response, this.apiContractStrict);
11511
+ try {
11512
+ if (!response.ok) {
11513
+ if (attempt && isTransientGatewayStatus(response.status)) {
11514
+ attempt.gatewayFailure = true;
11515
+ attempt.retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
11516
+ }
11517
+ throw await awaitWithAbort(
11518
+ apiErrorFromResponse(response, { method, correlationId }),
11519
+ signal,
11520
+ );
11521
+ }
11522
+ if (options.responseType === "void") {
11523
+ await awaitWithAbort(cancelResponseBody(response, "discarding void API response"), signal);
11524
+ return undefined as T;
11525
+ }
11526
+ await awaitWithAbort(assertJsonResponse(response, { method, correlationId }), signal);
11527
+ const value = await awaitWithAbort(response.json(), signal);
11528
+ recordObservedSessionSequence(value, response.headers.get(SESSION_SEQUENCE_HEADER));
11529
+ return value as T;
11530
+ } catch (error) {
11531
+ if (options.signal?.aborted) throw error;
11532
+ if (error instanceof OpenGeniApiError) throw error;
11533
+ if (isMutationMethod(method)) {
11534
+ throw mutationTransportError(correlationId);
11535
+ }
11536
+ if (error instanceof TypeError && attempt) attempt.transportFailure = true;
11537
+ markTransportFailure(error);
11538
+ throw error;
11539
+ }
11540
+ }
11541
+
11424
11542
  /** Authenticated, contract-checked response for bounded streaming downloads. */
11425
11543
  protected async requestResponse(
11426
11544
  method: string,
@@ -11565,7 +11683,7 @@ function isTranscriptionRecordingResponse(value: unknown): value is Transcriptio
11565
11683
 
11566
11684
  function transcriptionRecordingResponse(value: unknown): TranscriptionRecordingResponse {
11567
11685
  if (isTranscriptionRecordingResponse(value)) return value;
11568
- throw new OpenGeniApiError(502, "Invalid transcription recording response.", {
11686
+ throw invalidApiResponseError("Invalid transcription recording response.", {
11569
11687
  code: "invalid_response",
11570
11688
  });
11571
11689
  }
@@ -11705,7 +11823,7 @@ async function assertJsonResponse(
11705
11823
  ): Promise<void> {
11706
11824
  if (isJsonContentType(response.headers.get("content-type"))) return;
11707
11825
  await cancelResponseBody(response, "unexpected non-JSON API response");
11708
- throw new OpenGeniApiError(502, "", {
11826
+ throw invalidApiResponseError("", {
11709
11827
  code: "upstream_unavailable",
11710
11828
  retryable: true,
11711
11829
  correlationId: response.headers.get(OPENGENI_CORRELATION_HEADER) ?? context.correlationId,
@@ -11826,7 +11944,7 @@ function assertRetainedWorkspaceArtifactReceipt(
11826
11944
  !parseRetainedGeneratedImageReference(artifact, workspaceId) &&
11827
11945
  !parseRetainedWorkspaceFileReference(artifact, workspaceId)
11828
11946
  ) {
11829
- throw new OpenGeniApiError(502, "retained workspace artifact receipt is invalid");
11947
+ throw invalidApiResponseError("retained workspace artifact receipt is invalid");
11830
11948
  }
11831
11949
  }
11832
11950
 
@@ -11837,16 +11955,16 @@ function assertSafeArtifactDownloadUrl(value: FileDownloadUrlResponse): void {
11837
11955
  typeof value.expiresAt !== "string" ||
11838
11956
  !Number.isFinite(Date.parse(value.expiresAt))
11839
11957
  ) {
11840
- throw new OpenGeniApiError(502, "retained artifact download URL is invalid");
11958
+ throw invalidApiResponseError("retained artifact download URL is invalid");
11841
11959
  }
11842
11960
  let parsed: URL;
11843
11961
  try {
11844
11962
  parsed = new URL(value.url);
11845
11963
  } catch {
11846
- throw new OpenGeniApiError(502, "retained artifact download URL is invalid");
11964
+ throw invalidApiResponseError("retained artifact download URL is invalid");
11847
11965
  }
11848
11966
  if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) {
11849
- throw new OpenGeniApiError(502, "retained artifact download URL is unsafe");
11967
+ throw invalidApiResponseError("retained artifact download URL is unsafe");
11850
11968
  }
11851
11969
  }
11852
11970
 
@@ -11865,7 +11983,7 @@ function assertSafeVideoArtifactPlaybackSource(
11865
11983
  value.sizeBytes > GENERATED_VIDEO_MAX_BYTES ||
11866
11984
  !/^[0-9a-f]{64}$/.test(value.sha256)
11867
11985
  ) {
11868
- throw new OpenGeniApiError(502, "generated-video playback source is invalid");
11986
+ throw invalidApiResponseError("generated-video playback source is invalid");
11869
11987
  }
11870
11988
  assertSafeArtifactDownloadUrl({ url: value.url, expiresAt: value.expiresAt });
11871
11989
  }
@@ -11873,11 +11991,11 @@ function assertSafeVideoArtifactPlaybackSource(
11873
11991
  function parseBoundedContentLength(value: string | null): number | null {
11874
11992
  if (value === null) return null;
11875
11993
  if (!/^\d+$/.test(value)) {
11876
- throw new OpenGeniApiError(502, "invalid retained artifact content-length");
11994
+ throw invalidApiResponseError("invalid retained artifact content-length");
11877
11995
  }
11878
11996
  const length = Number(value);
11879
11997
  if (!Number.isSafeInteger(length) || length > RETAINED_OUTPUT_MAX_PAGE_BYTES) {
11880
- throw new OpenGeniApiError(502, "retained artifact response exceeds the SDK byte limit");
11998
+ throw invalidApiResponseError("retained artifact response exceeds the SDK byte limit");
11881
11999
  }
11882
12000
  return length;
11883
12001
  }
@@ -11889,7 +12007,7 @@ async function readBoundedResponseBytes(
11889
12007
  ): Promise<Uint8Array> {
11890
12008
  if (!response.body) {
11891
12009
  if (expectedBytes !== null && expectedBytes !== 0) {
11892
- throw new OpenGeniApiError(502, "retained artifact response length mismatch");
12010
+ throw invalidApiResponseError("retained artifact response length mismatch");
11893
12011
  }
11894
12012
  return new Uint8Array();
11895
12013
  }
@@ -11905,7 +12023,7 @@ async function readBoundedResponseBytes(
11905
12023
  await reader
11906
12024
  .cancel("retained artifact response exceeded the SDK byte limit")
11907
12025
  .catch(() => undefined);
11908
- throw new OpenGeniApiError(502, "retained artifact response exceeds the SDK byte limit");
12026
+ throw invalidApiResponseError("retained artifact response exceeds the SDK byte limit");
11909
12027
  }
11910
12028
  chunks.push(value);
11911
12029
  }
@@ -11913,7 +12031,7 @@ async function readBoundedResponseBytes(
11913
12031
  reader.releaseLock();
11914
12032
  }
11915
12033
  if (expectedBytes !== null && totalBytes !== expectedBytes) {
11916
- throw new OpenGeniApiError(502, "retained artifact response length mismatch");
12034
+ throw invalidApiResponseError("retained artifact response length mismatch");
11917
12035
  }
11918
12036
  const bytes = new Uint8Array(totalBytes);
11919
12037
  let offset = 0;
package/src/core.ts CHANGED
@@ -35,6 +35,8 @@ export {
35
35
  isRetryableStreamError,
36
36
  } from "./errors";
37
37
  export type { OpenGeniSecureContextRequiredReason } from "./errors";
38
+ export { isTransientRequestError } from "./read-retry";
39
+ export type { OpenGeniReadRetryOptions } from "./read-retry";
38
40
  export { parseDeprecationNotice } from "./deprecation";
39
41
  export type { OpenGeniDeprecationHandler, OpenGeniDeprecationNotice } from "./deprecation";
40
42
  export { resolveWorkspaceVoiceInputEnabled } from "./transcription";