@opengeni/sdk 1.3.0 → 1.4.2

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 (59) hide show
  1. package/README.md +25 -3
  2. package/dist/adapters/next.js +2 -2
  3. package/dist/artifacts.js +4 -4
  4. package/dist/azure-live-transport.d.ts +32 -0
  5. package/dist/browser.js +3 -3
  6. package/dist/chat/index.js +5 -5
  7. package/dist/{chunk-IB6SUE3R.js → chunk-DTIMLEPB.js} +1 -1
  8. package/dist/chunk-DTIMLEPB.js.map +1 -0
  9. package/dist/{chunk-VYCTL62C.js → chunk-GZQSWAKD.js} +1 -1
  10. package/dist/chunk-GZQSWAKD.js.map +1 -0
  11. package/dist/{chunk-2UXZUCEZ.js → chunk-JOI7XCG6.js} +110 -9
  12. package/dist/chunk-JOI7XCG6.js.map +1 -0
  13. package/dist/{chunk-CREM7YRK.js → chunk-TRUFUTDI.js} +2 -2
  14. package/dist/{chunk-CNSPJYF6.js → chunk-TX6ED3AP.js} +210 -6
  15. package/dist/chunk-TX6ED3AP.js.map +1 -0
  16. package/dist/{chunk-ZBICMU4Z.js → chunk-XIQIV4VL.js} +2 -2
  17. package/dist/{chunk-G5NZGPTB.js → chunk-XPWH3HKG.js} +2 -2
  18. package/dist/{chunk-YVH2PH4X.js → chunk-YWLJGEUB.js} +2 -2
  19. package/dist/{chunk-RSKO3CMZ.js → chunk-ZOSRUR2Q.js} +2 -2
  20. package/dist/codex-realtime-controller.d.ts +13 -0
  21. package/dist/codex-realtime-controller.js +3 -1
  22. package/dist/codex-realtime-v3-wire.d.ts +3 -2
  23. package/dist/codex-realtime.d.ts +4 -0
  24. package/dist/core.js +4 -4
  25. package/dist/document-authority.js +3 -3
  26. package/dist/editable-artifacts.js +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +55 -18
  29. package/dist/index.js.map +1 -1
  30. package/dist/realtime.d.ts +1 -1
  31. package/dist/realtime.js +248 -2
  32. package/dist/realtime.js.map +1 -1
  33. package/dist/session-proxy-client.d.ts +7 -3
  34. package/dist/session-proxy.d.ts +38 -7
  35. package/dist/session-proxy.js +2 -2
  36. package/dist/site.js +3 -3
  37. package/dist/transcription.d.ts +1 -1
  38. package/dist/types.d.ts +47 -8
  39. package/package.json +3 -3
  40. package/src/azure-live-transport.ts +286 -0
  41. package/src/codex-realtime-controller.ts +148 -4
  42. package/src/codex-realtime-v3-wire.ts +4 -3
  43. package/src/codex-realtime-v3.ts +11 -2
  44. package/src/codex-realtime.ts +9 -1
  45. package/src/index.ts +3 -0
  46. package/src/realtime.ts +15 -6
  47. package/src/session-proxy-client.ts +7 -3
  48. package/src/session-proxy.ts +321 -11
  49. package/src/transcription.ts +3 -0
  50. package/src/types.ts +49 -5
  51. package/dist/chunk-2UXZUCEZ.js.map +0 -1
  52. package/dist/chunk-CNSPJYF6.js.map +0 -1
  53. package/dist/chunk-IB6SUE3R.js.map +0 -1
  54. package/dist/chunk-VYCTL62C.js.map +0 -1
  55. /package/dist/{chunk-CREM7YRK.js.map → chunk-TRUFUTDI.js.map} +0 -0
  56. /package/dist/{chunk-ZBICMU4Z.js.map → chunk-XIQIV4VL.js.map} +0 -0
  57. /package/dist/{chunk-G5NZGPTB.js.map → chunk-XPWH3HKG.js.map} +0 -0
  58. /package/dist/{chunk-YVH2PH4X.js.map → chunk-YWLJGEUB.js.map} +0 -0
  59. /package/dist/{chunk-RSKO3CMZ.js.map → chunk-ZOSRUR2Q.js.map} +0 -0
package/src/realtime.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createAzureLiveTransportStarter } from "./azure-live-transport";
1
2
  /**
2
3
  * Public, provider-neutral realtime browser SDK.
3
4
  *
@@ -34,7 +35,7 @@ export type SessionRealtimeClientLike = CodexRealtimeControllerClient & {
34
35
  ): Promise<WorkspaceRealtimeModelCatalogResponse>;
35
36
  };
36
37
 
37
- export type SessionRealtimeTransportKind = "codex" | "gateway" | "xai-subscription";
38
+ export type SessionRealtimeTransportKind = "codex" | "gateway" | "xai-subscription" | "azure-live";
38
39
 
39
40
  /** Provider-neutral names for the existing, battle-tested controller projection. */
40
41
  export type SessionRealtimeController = CodexRealtimeController;
@@ -57,6 +58,7 @@ export type CreateSessionRealtimeControllerOptions = Omit<
57
58
  export function sessionRealtimeTransportKind(
58
59
  model: SessionRealtimeModel,
59
60
  ): SessionRealtimeTransportKind {
61
+ if (model === "opengeni-azure/gpt-live-1") return "azure-live";
60
62
  if (model === "gpt-live-1-boulder-alpha") return "codex";
61
63
  if (model === "supergrok/grok-voice-think-fast-2.0") return "xai-subscription";
62
64
  return "gateway";
@@ -74,11 +76,18 @@ export function createSessionRealtimeController(
74
76
  return createCodexRealtimeController({
75
77
  ...options,
76
78
  ownerStorageNamespace: sessionRealtimeOwnerStorageNamespace(options.model),
77
- ...(transport === "gateway"
78
- ? { startTransport: createGatewayRealtimeTransportStarter() }
79
- : transport === "xai-subscription"
80
- ? { startTransport: createXaiSubscriptionRealtimeTransportStarter() }
81
- : {}),
79
+ ...(transport === "azure-live"
80
+ ? {
81
+ startTransport: createAzureLiveTransportStarter({
82
+ remoteAudio: options.remoteAudio,
83
+ createPeerConnection: options.createPeerConnection,
84
+ }),
85
+ }
86
+ : transport === "gateway"
87
+ ? { startTransport: createGatewayRealtimeTransportStarter() }
88
+ : transport === "xai-subscription"
89
+ ? { startTransport: createXaiSubscriptionRealtimeTransportStarter() }
90
+ : {}),
82
91
  });
83
92
  }
84
93
 
@@ -31,15 +31,19 @@ export function getSessionProxyWorkspaceGrant(
31
31
  );
32
32
  }
33
33
 
34
- /** Exact association read. The API reauthorizes the source session on every call. */
34
+ /**
35
+ * Exact association read. The API reauthorizes the source session on every
36
+ * call. `retained` proves a generated image or video, or a published sandbox
37
+ * file, originated in that session.
38
+ */
35
39
  export function getSessionProxyArtifactAssociation(
36
40
  client: Pick<OpenGeniEmbeddingClient, "requestJson">,
37
41
  workspaceId: string,
38
42
  sessionId: string,
39
- kind: "editable" | "site",
43
+ kind: "editable" | "site" | "retained",
40
44
  artifactId: string,
41
45
  options: OpenGeniRequestOptions = {},
42
- ): Promise<{ sessionId: string; artifactId: string; kind: "editable" | "site" }> {
46
+ ): Promise<{ sessionId: string; artifactId: string; kind: "editable" | "site" | "retained" }> {
43
47
  return client.requestJson(
44
48
  "GET",
45
49
  `/v1/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}/artifact-associations/${encodeURIComponent(artifactId)}`,
@@ -10,6 +10,7 @@ import type {
10
10
  FileResourceRef,
11
11
  LatencyMode,
12
12
  ReasoningEffort,
13
+ RetainedArtifactContent,
13
14
  SessionMcpCredentialUpdateInput,
14
15
  } from "./types";
15
16
  import { mintToolToken, resolveToolTokenSecret } from "./tool-auth";
@@ -109,7 +110,12 @@ export type SessionProxyCreateInput = {
109
110
  export type SessionProxyMessageInput = {
110
111
  /** Absent for `create`. */
111
112
  sessionId?: string | undefined;
112
- delivery: "create" | "send" | "steer" | "submit";
113
+ /**
114
+ * `realtime` is live voice: once when a call starts (refuse it, or rotate
115
+ * MCP credentials), and before each batch of finalized transcripts and spoken
116
+ * requests is saved (refuse it, or add `modelContext`).
117
+ */
118
+ delivery: "create" | "send" | "steer" | "submit" | "realtime";
113
119
  };
114
120
 
115
121
  /** Server-side additions the host attaches to one forwarded user message or response. */
@@ -117,7 +123,9 @@ export type SessionProxyMessageExtras = {
117
123
  /**
118
124
  * Model-visible context for this message (current page, time zone, today's
119
125
  * date). Placed before any context the browser sent. Not secret. Ignored for
120
- * approval decisions and human-input responses, which are not new messages.
126
+ * approval decisions and human-input responses, which are not new messages,
127
+ * and when a live voice call starts. Keep it stable for the same message:
128
+ * a retried voice entry must carry the same context to be accepted.
121
129
  */
122
130
  modelContext?: string | undefined;
123
131
  /**
@@ -192,9 +200,10 @@ export type SessionProxyHandlerOptions = {
192
200
  | undefined;
193
201
  /**
194
202
  * Called before every forwarded user message (send, steer, composer submit,
195
- * and browser-started create), approval decision, and human-input response.
196
- * Return server-owned `modelContext` (messages only) and MCP credential
197
- * rotations, or a `Response` to reject the action.
203
+ * and browser-started create), approval decision, human-input response, and
204
+ * live voice start and transcript save (`delivery: "realtime"`).
205
+ * Return server-owned `modelContext` (messages and voice transcripts only)
206
+ * and MCP credential rotations, or a `Response` to reject the action.
198
207
  */
199
208
  beforeForwardMessage?:
200
209
  | ((
@@ -218,7 +227,15 @@ export type SessionProxyHandlerOptions = {
218
227
  basePath?: string | undefined;
219
228
  /** Maximum JSON request body. Defaults to 1 MiB. */
220
229
  maxBodyBytes?: number | undefined;
221
- /** Expose composer file attachments (upload begin/complete, download URL). Defaults to true. */
230
+ /**
231
+ * Expose composer file attachments (upload begin/complete, download URL) and
232
+ * the media agents produce in a session: generated images, browser and
233
+ * computer screenshots, published sandbox files, and generated video
234
+ * playback. Workspace-level artifact reads must name their session in the
235
+ * `x-opengeni-session-id` header (`SessionConversation` does this); the proxy
236
+ * runs `authorizeSession` and only forwards an artifact OpenGeni proves that
237
+ * session produced. Defaults to true.
238
+ */
222
239
  files?: boolean | undefined;
223
240
  /**
224
241
  * Forward composer voice input (`POST .../transcriptions`, one recording per
@@ -228,6 +245,18 @@ export type SessionProxyHandlerOptions = {
228
245
  * hide the microphone. Defaults to true.
229
246
  */
230
247
  voiceInput?: boolean | undefined;
248
+ /**
249
+ * Forward live speech-to-speech voice for existing chats as the resolved
250
+ * user: the voice model catalog and the call lifecycle routes under
251
+ * `.../sessions/{id}/realtime`. Opengeni still requires that user's
252
+ * `sessions:control` permission, binds the call to that user and browser,
253
+ * runs spoken requests as ordinary steers of the chat, and meters
254
+ * deployment-funded voice against the organization's credits. The stock
255
+ * composer shows the voice button only when a voice model is available.
256
+ * `false` reports `realtimeVoice: false` in the client config so stock UIs
257
+ * hide it, and refuses the routes. Defaults to true.
258
+ */
259
+ realtimeVoice?: boolean | undefined;
231
260
  /**
232
261
  * Let the browser read a file from the session's sandbox (`POST .../fs/read`)
233
262
  * so `sandbox:` links in agent replies can be downloaded. Only `path`,
@@ -282,10 +311,20 @@ export type SessionProxyHandlerOptions = {
282
311
  * settings decide which models end users see.
283
312
  */
284
313
  modelSelection?: boolean | undefined;
285
- /** SSE heartbeat interval. Defaults to 15 seconds. */
314
+ /**
315
+ * SSE heartbeat interval. Defaults to 5 seconds, under Bun.serve's default
316
+ * 10-second `idleTimeout`, which otherwise closes a quiet event stream.
317
+ */
286
318
  heartbeatMs?: number | undefined;
287
319
  };
288
320
 
321
+ /**
322
+ * Default SSE heartbeat. Bun.serve closes a connection that sends nothing for
323
+ * 10 seconds by default, so a quiet stream (a long tool call, an idle chat)
324
+ * would drop and reconnect every 10 seconds through a Bun or Hono-on-Bun host.
325
+ */
326
+ const SESSION_PROXY_DEFAULT_HEARTBEAT_MS = 5_000;
327
+
289
328
  const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
290
329
  const SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
291
330
  const QUEUE_OPERATIONS: ReadonlySet<string> = new Set(["move", "edit", "steer", "delete"]);
@@ -345,6 +384,7 @@ export function createSessionProxyHandler(
345
384
  const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
346
385
  const filesEnabled = options.files ?? true;
347
386
  const voiceInputEnabled = options.voiceInput ?? true;
387
+ const realtimeVoiceEnabled = options.realtimeVoice ?? true;
348
388
  const sandboxFilesEnabled = options.sandboxFiles === true;
349
389
  const artifactsEnabled = options.artifacts !== undefined && options.artifacts !== false;
350
390
  const editableLiveUrl = artifactsEnabled
@@ -354,7 +394,7 @@ export function createSessionProxyHandler(
354
394
  )
355
395
  : null;
356
396
  const modelSelection = options.modelSelection ?? true;
357
- const heartbeatMs = options.heartbeatMs ?? 15_000;
397
+ const heartbeatMs = options.heartbeatMs ?? SESSION_PROXY_DEFAULT_HEARTBEAT_MS;
358
398
  const sessionList = options.sessionList ?? "mine";
359
399
  const archiveEnabled = options.archive ?? true;
360
400
  const chats = options.chats ?? "private";
@@ -378,10 +418,10 @@ export function createSessionProxyHandler(
378
418
  return async (request) => {
379
419
  try {
380
420
  const method = request.method;
381
- if (!["GET", "POST", "PUT", "PATCH"].includes(method)) {
421
+ if (!PROXY_METHODS.includes(method)) {
382
422
  return new Response(null, {
383
423
  status: 405,
384
- headers: { Allow: "GET, POST, PUT, PATCH" },
424
+ headers: { Allow: PROXY_METHODS.join(", ") },
385
425
  });
386
426
  }
387
427
  const resolved = await options.resolve(request);
@@ -511,6 +551,39 @@ export function createSessionProxyHandler(
511
551
  ],
512
552
  };
513
553
  };
554
+ /**
555
+ * Best-effort standalone rotation before a voice call. Opengeni refuses
556
+ * it while a turn is running; that turn's message already refreshed them.
557
+ */
558
+ const refreshRealtimeCredentials = async (
559
+ sessionId: string,
560
+ updates: SessionMcpCredentialUpdateInput[],
561
+ ): Promise<void> => {
562
+ if (updates.length === 0) return;
563
+ try {
564
+ const servers = (await client.getSession(workspaceId, sessionId, call)).mcpServers ?? [];
565
+ const rotations = updates.flatMap((update) => {
566
+ const server = servers.find((candidate) => candidate.id === update.id);
567
+ return server
568
+ ? [
569
+ {
570
+ id: update.id,
571
+ expectedCredentialVersion: server.credentialVersion,
572
+ expectedServerUrl: server.url,
573
+ headers: update.headers,
574
+ },
575
+ ]
576
+ : [];
577
+ });
578
+ if (rotations.length === 0) return;
579
+ await client.rotateSessionMcpCredentials(workspaceId, sessionId, {
580
+ operationKey: crypto.randomUUID(),
581
+ updates: rotations,
582
+ });
583
+ } catch (error) {
584
+ if (!(error instanceof OpenGeniApiError)) throw error;
585
+ }
586
+ };
514
587
  /** Browser input sanitized, then server-owned extras merged in. */
515
588
  const forwardMessage = async (
516
589
  value: unknown,
@@ -602,7 +675,13 @@ export function createSessionProxyHandler(
602
675
  // reads it here instead of knowing any Opengeni id.
603
676
  workspaceId,
604
677
  sandboxFiles: sandboxFilesEnabled,
605
- ...(artifacts ? { artifacts } : {}),
678
+ // Stock UIs show Site previews and the artifact viewer only when
679
+ // this proxy serves them; `false` keeps them from failing on click.
680
+ artifacts: artifacts ?? false,
681
+ // Whether the stock "New chat" and "Archive" actions can succeed.
682
+ sessionCreation: options.createSession !== undefined,
683
+ archive: archiveEnabled,
684
+ ...(realtimeVoiceEnabled ? {} : { realtimeVoice: false }),
606
685
  // Explicit true also tells stock UIs to offer end users the model picker.
607
686
  ...(modelSelection
608
687
  ? options.modelSelection === true
@@ -636,6 +715,10 @@ export function createSessionProxyHandler(
636
715
  if (area === "model-catalog" && tail.length === 0 && method === "GET") {
637
716
  return await read(`${base}/model-catalog`);
638
717
  }
718
+ if (area === "realtime-model-catalog" && tail.length === 0 && method === "GET") {
719
+ if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
720
+ return await read(`${base}/realtime-model-catalog`);
721
+ }
639
722
  if (area === "live-events" && tail.length === 1 && tail[0] === "stream" && method === "GET") {
640
723
  // Surface authorization failures as HTTP status before streaming.
641
724
  await client.requestJson("GET", base, undefined, {}, call);
@@ -676,6 +759,53 @@ export function createSessionProxyHandler(
676
759
  return errorJson(404, "route_not_allowed", "Not found.");
677
760
  }
678
761
 
762
+ if (area === "artifacts") {
763
+ // Retained media a session produced: only an artifact OpenGeni proves
764
+ // that session produced, never an arbitrary workspace artifact.
765
+ const [artifactId, ...artifactOp] = tail as [string | undefined, ...string[]];
766
+ const route = `${method} ${artifactOp.join("/")}`;
767
+ if (
768
+ !filesEnabled ||
769
+ !artifactId ||
770
+ !SEGMENT.test(artifactId) ||
771
+ (route !== "GET content" && route !== "POST playback-source")
772
+ ) {
773
+ return errorJson(404, "route_not_allowed", "Not found.");
774
+ }
775
+ const sessionId = request.headers.get(SESSION_SCOPE_HEADER)?.trim() ?? "";
776
+ if (!SEGMENT.test(sessionId)) {
777
+ reject(400, "session_scope_required", "Artifact reads must name their session.");
778
+ }
779
+ if (options.authorizeSession && !(await options.authorizeSession(sessionId, context))) {
780
+ return errorJson(404, "session_not_found", "Session not found.");
781
+ }
782
+ await getSessionProxyArtifactAssociation(
783
+ client,
784
+ workspaceId,
785
+ sessionId,
786
+ "retained",
787
+ artifactId,
788
+ call,
789
+ );
790
+ if (route === "POST playback-source") {
791
+ return json(
792
+ await client.requestJson(
793
+ "POST",
794
+ `${base}/artifacts/${artifactId}/playback-source`,
795
+ undefined,
796
+ {},
797
+ call,
798
+ ),
799
+ );
800
+ }
801
+ return retainedContent(
802
+ await client.getRetainedArtifactContent(workspaceId, artifactId, {
803
+ ...retainedRange(request),
804
+ signal: request.signal,
805
+ }),
806
+ );
807
+ }
808
+
679
809
  if (area === "transcriptions" && tail.length === 0 && method === "POST") {
680
810
  if (!voiceInputEnabled) return errorJson(404, "route_not_allowed", "Not found.");
681
811
  return json(await client.transcribeAudio(workspaceId, await transcriptionInput(request)));
@@ -818,6 +948,34 @@ export function createSessionProxyHandler(
818
948
  ? payload
819
949
  : json(await client.requestJson("POST", path, payload));
820
950
 
951
+ if (op[0] === "realtime") {
952
+ if (!realtimeVoiceEnabled) return errorJson(404, "route_not_allowed", "Not found.");
953
+ const realtime = realtimeRoute(method, op.slice(1));
954
+ if (!realtime) return errorJson(404, "route_not_allowed", "Not found.");
955
+ if (!body) reject(400, "invalid_body", "A JSON object body is required.");
956
+ const path = `${session}/${op.join("/")}`;
957
+ if (realtime === "begin") {
958
+ // The host may refuse a call; MCP credentials (including the tool
959
+ // server's per-user token) are refreshed before voice can delegate.
960
+ const extras = await messageExtras({
961
+ sessionId,
962
+ delivery: "realtime",
963
+ });
964
+ if (extras instanceof Response) return extras;
965
+ await refreshRealtimeCredentials(sessionId, extras?.mcpCredentialUpdates ?? []);
966
+ }
967
+ let payload: Record<string, unknown> = body;
968
+ if (realtime === "sync" && options.beforeForwardMessage && hasRealtimeMessages(body)) {
969
+ const extras = await options.beforeForwardMessage(
970
+ { sessionId, delivery: "realtime" },
971
+ context,
972
+ );
973
+ if (extras instanceof Response) return extras;
974
+ payload = withRealtimeContext(body, extras?.modelContext);
975
+ }
976
+ return json(await client.requestJson(method, path, payload));
977
+ }
978
+
821
979
  switch (route) {
822
980
  case "GET ":
823
981
  return await read(session);
@@ -899,6 +1057,28 @@ export function createSessionProxyHandler(
899
1057
  }
900
1058
  case "GET human-input-requests":
901
1059
  return await read(`${session}/human-input-requests`);
1060
+ case "GET goal":
1061
+ return await read(`${session}/goal`);
1062
+ case "PATCH goal": {
1063
+ // Pause and resume only: the objective, limits and completion stay
1064
+ // with the agent and the product's own server. A browser rationale is
1065
+ // dropped, so end-user text never reaches the goal record.
1066
+ const status = body?.status;
1067
+ if (
1068
+ (status !== "paused" && status !== "active") ||
1069
+ Object.keys(body ?? {}).some((key) => key !== "status" && key !== "rationale")
1070
+ ) {
1071
+ reject(
1072
+ 403,
1073
+ "goal_update_not_allowed",
1074
+ "Only { status: paused | active } is available.",
1075
+ );
1076
+ }
1077
+ return json(await client.requestJson("PATCH", `${session}/goal`, { status }));
1078
+ }
1079
+ case "DELETE goal":
1080
+ await client.deleteGoal(workspaceId, sessionId);
1081
+ return new Response(null, { status: 204 });
902
1082
  case "POST fs/read":
903
1083
  case "POST fs/read-workspace": {
904
1084
  if (!sandboxFilesEnabled) return errorJson(404, "route_not_allowed", "Not found.");
@@ -909,6 +1089,21 @@ export function createSessionProxyHandler(
909
1089
  );
910
1090
  }
911
1091
  }
1092
+ if (op[0] === "artifacts" && (op.length === 2 || op[2] === "content") && method === "GET") {
1093
+ // Retained screenshots: the API matches the artifact to this session;
1094
+ // the proxy first proves the user can read the session itself.
1095
+ if (!filesEnabled || op.length > 3) {
1096
+ return errorJson(404, "route_not_allowed", "Not found.");
1097
+ }
1098
+ await client.getSession(workspaceId, sessionId, call);
1099
+ if (op.length === 2) return await read(`${session}/artifacts/${op[1]}`);
1100
+ return retainedContent(
1101
+ await client.getSessionRetainedArtifactContent(workspaceId, sessionId, op[1]!, {
1102
+ ...retainedRange(request),
1103
+ signal: request.signal,
1104
+ }),
1105
+ );
1106
+ }
912
1107
  if (op.length === 2 && op[0] === "human-input-requests" && method === "GET") {
913
1108
  return await read(`${session}/human-input-requests/${op[1]}`);
914
1109
  }
@@ -927,8 +1122,75 @@ export function createSessionProxyHandler(
927
1122
  };
928
1123
  }
929
1124
 
1125
+ /**
1126
+ * Methods any forwarded route uses. Module-level so the public API inventory
1127
+ * attributes a verb only to the routes that actually forward it.
1128
+ */
1129
+ const PROXY_METHODS: readonly string[] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
1130
+
930
1131
  const UPLOAD_FIELDS = ["scope", "filename", "contentType", "sizeBytes", "sha256"] as const;
931
1132
 
1133
+ type RealtimeRoute = "begin" | "connect" | "heartbeat" | "end" | "activate" | "sync";
1134
+ const REALTIME_CONNECT_ROUTES: ReadonlySet<string> = new Set(["webrtc", "gateway", "supergrok"]);
1135
+ /** Ledger entries that become (or join) messages and so accept `modelContext`. */
1136
+ const REALTIME_MESSAGE_KINDS: ReadonlySet<string> = new Set([
1137
+ "delegation_call",
1138
+ "user_transcript",
1139
+ "assistant_transcript",
1140
+ ]);
1141
+
1142
+ /**
1143
+ * The live voice routes a browser call uses, after `realtime/`: begin, provider
1144
+ * connect, heartbeat, end, connection activation, and transcript sync. Bodies
1145
+ * pass to Opengeni, which validates them and binds the call to the acting user.
1146
+ */
1147
+ function realtimeRoute(method: string, rest: string[]): RealtimeRoute | null {
1148
+ if (rest.length === 0) return method === "POST" ? "begin" : null;
1149
+ if (rest.length === 1) {
1150
+ if (REALTIME_CONNECT_ROUTES.has(rest[0]!)) return method === "POST" ? "connect" : null;
1151
+ return method === "DELETE" ? "end" : null;
1152
+ }
1153
+ if (rest.length === 2 && rest[1] === "heartbeat") return method === "PATCH" ? "heartbeat" : null;
1154
+ if (rest.length === 2 && rest[1] === "sync") return method === "POST" ? "sync" : null;
1155
+ if (rest.length === 4 && rest[1] === "connections" && rest[3] === "activate") {
1156
+ return method === "POST" ? "activate" : null;
1157
+ }
1158
+ return null;
1159
+ }
1160
+
1161
+ function hasRealtimeMessages(body: Record<string, unknown>): boolean {
1162
+ return (
1163
+ Array.isArray(body.entries) &&
1164
+ body.entries.some(
1165
+ (entry) =>
1166
+ entry !== null &&
1167
+ typeof entry === "object" &&
1168
+ REALTIME_MESSAGE_KINDS.has((entry as { kind?: unknown }).kind as string),
1169
+ )
1170
+ );
1171
+ }
1172
+
1173
+ /** Server context goes before the browser's on each message-bearing entry. */
1174
+ function withRealtimeContext(
1175
+ body: Record<string, unknown>,
1176
+ serverContext: string | undefined,
1177
+ ): Record<string, unknown> {
1178
+ if (!serverContext?.trim() || !Array.isArray(body.entries)) return body;
1179
+ return {
1180
+ ...body,
1181
+ entries: body.entries.map((entry: unknown) => {
1182
+ if (entry === null || typeof entry !== "object") return entry;
1183
+ const record = entry as Record<string, unknown>;
1184
+ if (!REALTIME_MESSAGE_KINDS.has(record.kind as string)) return entry;
1185
+ const modelContext = joinContext(
1186
+ serverContext,
1187
+ typeof record.modelContext === "string" ? record.modelContext : undefined,
1188
+ );
1189
+ return modelContext ? { ...record, modelContext } : entry;
1190
+ }),
1191
+ };
1192
+ }
1193
+
932
1194
  type NormalizedToolServer = SessionProxyToolServer & {
933
1195
  url: string;
934
1196
  id: string;
@@ -1188,6 +1450,35 @@ async function legacyViewerGrant(
1188
1450
  return grant;
1189
1451
  }
1190
1452
 
1453
+ /** The browser's byte range, passed through unchanged when well formed. */
1454
+ function retainedRange(request: Request): { range?: string } {
1455
+ const range = request.headers.get("range");
1456
+ if (range === null) return {};
1457
+ if (range.length > 128 || /[^\x20-\x7e]/.test(range)) {
1458
+ reject(400, "invalid_range", "Range must be at most 128 printable ASCII bytes.");
1459
+ }
1460
+ return { range };
1461
+ }
1462
+
1463
+ /** One bounded retained-artifact page, with the range facts the browser SDK verifies. */
1464
+ function retainedContent(content: RetainedArtifactContent): Response {
1465
+ return new Response(content.bytes as Uint8Array<ArrayBuffer>, {
1466
+ status: content.status,
1467
+ headers: {
1468
+ "Content-Type": content.contentType,
1469
+ "Content-Length": String(content.contentLength),
1470
+ ...(content.contentRange ? { "Content-Range": content.contentRange } : {}),
1471
+ "Accept-Ranges": "bytes",
1472
+ "Cache-Control": "private, no-store",
1473
+ // Bytes for the conversation's own reader; never rendered on this origin.
1474
+ "Content-Security-Policy": "sandbox",
1475
+ "Content-Disposition": "attachment",
1476
+ "Cross-Origin-Resource-Policy": "same-origin",
1477
+ "X-Content-Type-Options": "nosniff",
1478
+ },
1479
+ });
1480
+ }
1481
+
1191
1482
  /** Sandbox link download: one path, no route/target override. */
1192
1483
  function sandboxRead(body: Record<string, unknown> | undefined): Record<string, unknown> {
1193
1484
  const path = body?.path;
@@ -1591,6 +1882,24 @@ function workspaceLiveStream(
1591
1882
  });
1592
1883
  }
1593
1884
 
1885
+ let warnedRejectedApiKey = false;
1886
+
1887
+ /**
1888
+ * The proxy always calls Opengeni with the server's own key, so an upstream
1889
+ * 401 means that key is wrong, expired or revoked, never that the end user is
1890
+ * signed out. Tell the developer once, in the server log, what to fix.
1891
+ */
1892
+ function warnRejectedApiKey(error: OpenGeniApiError): void {
1893
+ if (warnedRejectedApiKey) return;
1894
+ warnedRejectedApiKey = true;
1895
+ console.warn(
1896
+ `[@opengeni/sdk] Opengeni rejected the session proxy's API key (401${
1897
+ error.correlationId ? `, reference ${error.correlationId}` : ""
1898
+ }). It may be expired, revoked or mistyped: create a new key in Opengeni under ` +
1899
+ "Organization settings > Developer and update OPENGENI_API_KEY on the server.",
1900
+ );
1901
+ }
1902
+
1594
1903
  /** Preserve OpenGeni's error envelope so the browser SDK keeps codes, retryability, and outcome facts. */
1595
1904
  function errorResponse(error: unknown): Response {
1596
1905
  if (error instanceof ProxyRejection) return errorJson(error.status, error.code, error.message);
@@ -1608,6 +1917,7 @@ function errorResponse(error: unknown): Response {
1608
1917
  );
1609
1918
  }
1610
1919
  if (error instanceof OpenGeniApiError) {
1920
+ if (error.status === 401) warnRejectedApiKey(error);
1611
1921
  const status = error.status >= 400 && error.status <= 599 ? error.status : 502;
1612
1922
  // A decoded upstream envelope is forwarded verbatim; the SDK only retains decodable bodies.
1613
1923
  if (error.body) {
@@ -106,6 +106,9 @@ export type TranscriptionLifecycleStatus =
106
106
  | "error";
107
107
 
108
108
  export type TranscriptionErrorCode =
109
+ | "insufficient_credits"
110
+ | "allowance_exhausted"
111
+ | "monthly_model_cost_limit"
109
112
  | "permission_denied"
110
113
  | "not_supported"
111
114
  | "network"