@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.
- package/README.md +25 -3
- package/dist/adapters/next.js +2 -2
- package/dist/artifacts.js +4 -4
- package/dist/azure-live-transport.d.ts +32 -0
- package/dist/browser.js +3 -3
- package/dist/chat/index.js +5 -5
- package/dist/{chunk-IB6SUE3R.js → chunk-DTIMLEPB.js} +1 -1
- package/dist/chunk-DTIMLEPB.js.map +1 -0
- package/dist/{chunk-VYCTL62C.js → chunk-GZQSWAKD.js} +1 -1
- package/dist/chunk-GZQSWAKD.js.map +1 -0
- package/dist/{chunk-2UXZUCEZ.js → chunk-JOI7XCG6.js} +110 -9
- package/dist/chunk-JOI7XCG6.js.map +1 -0
- package/dist/{chunk-CREM7YRK.js → chunk-TRUFUTDI.js} +2 -2
- package/dist/{chunk-CNSPJYF6.js → chunk-TX6ED3AP.js} +210 -6
- package/dist/chunk-TX6ED3AP.js.map +1 -0
- package/dist/{chunk-ZBICMU4Z.js → chunk-XIQIV4VL.js} +2 -2
- package/dist/{chunk-G5NZGPTB.js → chunk-XPWH3HKG.js} +2 -2
- package/dist/{chunk-YVH2PH4X.js → chunk-YWLJGEUB.js} +2 -2
- package/dist/{chunk-RSKO3CMZ.js → chunk-ZOSRUR2Q.js} +2 -2
- package/dist/codex-realtime-controller.d.ts +13 -0
- package/dist/codex-realtime-controller.js +3 -1
- package/dist/codex-realtime-v3-wire.d.ts +3 -2
- package/dist/codex-realtime.d.ts +4 -0
- package/dist/core.js +4 -4
- package/dist/document-authority.js +3 -3
- package/dist/editable-artifacts.js +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +55 -18
- package/dist/index.js.map +1 -1
- package/dist/realtime.d.ts +1 -1
- package/dist/realtime.js +248 -2
- package/dist/realtime.js.map +1 -1
- package/dist/session-proxy-client.d.ts +7 -3
- package/dist/session-proxy.d.ts +38 -7
- package/dist/session-proxy.js +2 -2
- package/dist/site.js +3 -3
- package/dist/transcription.d.ts +1 -1
- package/dist/types.d.ts +47 -8
- package/package.json +3 -3
- package/src/azure-live-transport.ts +286 -0
- package/src/codex-realtime-controller.ts +148 -4
- package/src/codex-realtime-v3-wire.ts +4 -3
- package/src/codex-realtime-v3.ts +11 -2
- package/src/codex-realtime.ts +9 -1
- package/src/index.ts +3 -0
- package/src/realtime.ts +15 -6
- package/src/session-proxy-client.ts +7 -3
- package/src/session-proxy.ts +321 -11
- package/src/transcription.ts +3 -0
- package/src/types.ts +49 -5
- package/dist/chunk-2UXZUCEZ.js.map +0 -1
- package/dist/chunk-CNSPJYF6.js.map +0 -1
- package/dist/chunk-IB6SUE3R.js.map +0 -1
- package/dist/chunk-VYCTL62C.js.map +0 -1
- /package/dist/{chunk-CREM7YRK.js.map → chunk-TRUFUTDI.js.map} +0 -0
- /package/dist/{chunk-ZBICMU4Z.js.map → chunk-XIQIV4VL.js.map} +0 -0
- /package/dist/{chunk-G5NZGPTB.js.map → chunk-XPWH3HKG.js.map} +0 -0
- /package/dist/{chunk-YVH2PH4X.js.map → chunk-YWLJGEUB.js.map} +0 -0
- /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 === "
|
|
78
|
-
? {
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
/**
|
|
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)}`,
|
package/src/session-proxy.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 ??
|
|
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 (!
|
|
421
|
+
if (!PROXY_METHODS.includes(method)) {
|
|
382
422
|
return new Response(null, {
|
|
383
423
|
status: 405,
|
|
384
|
-
headers: { Allow: "
|
|
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
|
-
|
|
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) {
|
package/src/transcription.ts
CHANGED
|
@@ -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"
|