@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
@@ -0,0 +1,286 @@
1
+ import { startCodexRealtimeWebrtc, type StartCodexRealtimeWebrtcOptions } from "./codex-realtime";
2
+ import type { RealtimeControllerTransportStarter } from "./codex-realtime-controller";
3
+
4
+ type Envelope = Record<string, unknown>;
5
+ const MAX_FRAGMENT_BYTES = 64_000;
6
+
7
+ /** The most recent speech wins when the user talks for a long time between delegations. */
8
+ function takeUtf8Tail(text: string, maxBytes = MAX_FRAGMENT_BYTES): string {
9
+ const encoder = new TextEncoder();
10
+ if (encoder.encode(text).byteLength <= maxBytes) return text;
11
+ let tail = text.slice(-maxBytes);
12
+ while (encoder.encode(tail).byteLength > maxBytes) tail = tail.slice(1);
13
+ return tail;
14
+ }
15
+
16
+ /** Azure Live has timed transcript fragments, not provider-finalized turns. */
17
+ export class AzureLiveDataChannel extends EventTarget {
18
+ private pending: {
19
+ role: "user" | "assistant";
20
+ text: string;
21
+ startMs: number;
22
+ endMs: number;
23
+ } | null = null;
24
+ private timer: ReturnType<typeof setTimeout> | null = null;
25
+ private ordinal = 0;
26
+ private readonly prefix = crypto.randomUUID();
27
+ private closed = false;
28
+ private closeSent = false;
29
+ private providerClosed = false;
30
+ private readonly delegations = new Set<string>();
31
+ // Azure delegations carry no content, so the user's words since the previous
32
+ // delegation become the delegation input.
33
+ private userSinceDelegation = "";
34
+ private draining: Promise<void> | null = null;
35
+ private finishDrain: ((error?: Error) => void) | null = null;
36
+ private readonly onOpen = () => this.dispatchEvent(new Event("open"));
37
+ private readonly onClose = () => {
38
+ this.providerClosed = true;
39
+ this.flush();
40
+ this.finishDrain?.();
41
+ this.dispatchEvent(new Event("close"));
42
+ };
43
+ private readonly onError = () => this.dispatchEvent(new Event("error"));
44
+ private readonly onMessage = (event: MessageEvent) => {
45
+ if (typeof event.data !== "string" || event.data.length > 1_048_576) return;
46
+ let value: unknown;
47
+ try {
48
+ value = JSON.parse(event.data);
49
+ } catch {
50
+ return;
51
+ }
52
+ if (!value || typeof value !== "object") return;
53
+ this.receive(value as Envelope);
54
+ };
55
+ constructor(
56
+ private readonly raw: RTCDataChannel,
57
+ private readonly drainTimeoutMs = 5_000,
58
+ ) {
59
+ super();
60
+ raw.addEventListener("open", this.onOpen);
61
+ raw.addEventListener("close", this.onClose);
62
+ raw.addEventListener("error", this.onError);
63
+ raw.addEventListener("message", this.onMessage);
64
+ }
65
+ get readyState(): RTCDataChannelState {
66
+ return this.closed || this.providerClosed
67
+ ? "closed"
68
+ : this.closeSent
69
+ ? "closing"
70
+ : this.raw.readyState;
71
+ }
72
+ asRtcDataChannel(): RTCDataChannel {
73
+ return this as unknown as RTCDataChannel;
74
+ }
75
+ private emit(value: Envelope): void {
76
+ if (!this.closed)
77
+ this.dispatchEvent(new MessageEvent("message", { data: JSON.stringify(value) }));
78
+ }
79
+ private flush(): void {
80
+ if (this.timer) clearTimeout(this.timer);
81
+ this.timer = null;
82
+ const pending = this.pending;
83
+ this.pending = null;
84
+ if (!pending?.text) return;
85
+ this.emit({
86
+ type: "transcript.segment",
87
+ turn: {
88
+ id: `${this.prefix}:${++this.ordinal}`,
89
+ role: pending.role,
90
+ transcript: pending.text,
91
+ },
92
+ start_ms: pending.startMs,
93
+ end_ms: pending.endMs,
94
+ });
95
+ }
96
+ private receive(value: Envelope): void {
97
+ if (this.closed) return;
98
+ if (
99
+ value.type === "session.input_transcript.delta" ||
100
+ value.type === "session.output_transcript.delta"
101
+ ) {
102
+ if (
103
+ typeof value.delta !== "string" ||
104
+ typeof value.start_ms !== "number" ||
105
+ typeof value.end_ms !== "number"
106
+ )
107
+ return;
108
+ const role = value.type === "session.input_transcript.delta" ? "user" : "assistant";
109
+ if (role === "user")
110
+ this.userSinceDelegation = takeUtf8Tail(this.userSinceDelegation + value.delta);
111
+ if (
112
+ this.pending &&
113
+ (this.pending.role !== role ||
114
+ new TextEncoder().encode(this.pending.text + value.delta).byteLength > MAX_FRAGMENT_BYTES)
115
+ )
116
+ this.flush();
117
+ if (new TextEncoder().encode(value.delta).byteLength > MAX_FRAGMENT_BYTES) {
118
+ this.emit({
119
+ type: "error",
120
+ error: { message: "Voice transcript fragment exceeded the supported size" },
121
+ });
122
+ return;
123
+ }
124
+ this.pending = {
125
+ role,
126
+ text: (this.pending?.text ?? "") + value.delta,
127
+ startMs: this.pending?.startMs ?? value.start_ms,
128
+ endMs: value.end_ms,
129
+ };
130
+ // Application grouping only: never present silence as a provider turn boundary.
131
+ if (!this.timer) this.timer = setTimeout(() => this.flush(), 1_000);
132
+ return;
133
+ }
134
+ if (value.type === "session.delegation.created") {
135
+ const delegation = value.delegation as Envelope | undefined;
136
+ if (delegation?.target !== "client" || typeof delegation.id !== "string") return;
137
+ this.delegations.add(delegation.id);
138
+ this.flush();
139
+ const spoken = this.userSinceDelegation.trim();
140
+ this.userSinceDelegation = "";
141
+ this.emit({
142
+ type: "delegation.created",
143
+ item: {
144
+ id: delegation.id,
145
+ type: "delegation",
146
+ target: "client",
147
+ content: spoken ? [{ type: "input_text", text: spoken }] : [],
148
+ },
149
+ offset_ms: value.offset_ms,
150
+ });
151
+ return;
152
+ }
153
+ if (value.type === "session.closed") {
154
+ this.providerClosed = true;
155
+ this.flush();
156
+ this.finishDrain?.();
157
+ return;
158
+ }
159
+ if (
160
+ value.type === "session.started" ||
161
+ value.type === "session.updated" ||
162
+ value.type === "error"
163
+ )
164
+ this.emit(value);
165
+ }
166
+ send(payload: string): void {
167
+ if (this.readyState !== "open") throw Error("Hosted voice channel is not open");
168
+ const value = JSON.parse(payload) as Envelope;
169
+ if (value.type !== "session.context.append" && value.type !== "delegation.context.append")
170
+ throw Error("Unsupported hosted voice context event");
171
+ const content = Array.isArray(value.content)
172
+ ? value.content.map((part) => (typeof part?.text === "string" ? part.text : "")).join("")
173
+ : "";
174
+ // Existing bridge chunks are <=500 UTF-8 bytes, below Live's 500-token bound.
175
+ this.raw.send(
176
+ JSON.stringify({
177
+ type:
178
+ value.channel === "speakable" ? "session.commentary.append" : "session.thinking.append",
179
+ content,
180
+ // Replayed tasks from an earlier provider session are general context.
181
+ delegation_id:
182
+ value.type === "delegation.context.append" &&
183
+ typeof value.delegation_item_id === "string" &&
184
+ this.delegations.has(value.delegation_item_id)
185
+ ? value.delegation_item_id
186
+ : null,
187
+ }),
188
+ );
189
+ }
190
+ drain(): Promise<void> {
191
+ if (this.draining) return this.draining;
192
+ if (this.providerClosed || this.raw.readyState === "closed") {
193
+ this.flush();
194
+ return Promise.resolve();
195
+ }
196
+ if (this.closed || this.raw.readyState !== "open") {
197
+ return Promise.reject(new Error("Hosted voice close is not confirmed"));
198
+ }
199
+ const pending = new Promise<void>((resolve, reject) => {
200
+ const timeout = setTimeout(() => {
201
+ this.flush();
202
+ this.finishDrain?.(
203
+ new Error("Hosted voice is still closing; retry to preserve final speech"),
204
+ );
205
+ }, this.drainTimeoutMs);
206
+ this.finishDrain = (error) => {
207
+ clearTimeout(timeout);
208
+ this.finishDrain = null;
209
+ if (error) reject(error);
210
+ else resolve();
211
+ };
212
+ if (!this.closeSent) {
213
+ this.closeSent = true;
214
+ try {
215
+ this.raw.send(JSON.stringify({ type: "session.close" }));
216
+ } catch {
217
+ this.finishDrain(new Error("Hosted voice close could not be sent"));
218
+ }
219
+ }
220
+ });
221
+ this.draining = pending;
222
+ // An uncertain close remains retryable; never resend the provider mutation.
223
+ void pending.then(
224
+ () => {
225
+ if (this.draining === pending) this.draining = null;
226
+ },
227
+ () => {
228
+ if (this.draining === pending) this.draining = null;
229
+ },
230
+ );
231
+ return pending;
232
+ }
233
+ close(): void {
234
+ if (this.closed) return;
235
+ if (this.timer) clearTimeout(this.timer);
236
+ this.timer = null;
237
+ this.pending = null;
238
+ this.finishDrain?.(new Error("Hosted voice channel was disposed before close confirmation"));
239
+ this.closed = true;
240
+ this.raw.removeEventListener("open", this.onOpen);
241
+ this.raw.removeEventListener("close", this.onClose);
242
+ this.raw.removeEventListener("error", this.onError);
243
+ this.raw.removeEventListener("message", this.onMessage);
244
+ this.dispatchEvent(new Event("close"));
245
+ }
246
+ }
247
+
248
+ export function createAzureLiveTransportStarter(
249
+ options: Pick<StartCodexRealtimeWebrtcOptions, "remoteAudio" | "createPeerConnection"> = {},
250
+ ): RealtimeControllerTransportStarter {
251
+ return async (input) => {
252
+ let channel: AzureLiveDataChannel | undefined;
253
+ try {
254
+ const transport = await startCodexRealtimeWebrtc({
255
+ ...input,
256
+ ...options,
257
+ activateRemoteAudio: false,
258
+ expectedModel: "opengeni-azure/gpt-live-1",
259
+ onEventsCreated(raw) {
260
+ channel = new AzureLiveDataChannel(raw);
261
+ input.onEventsCreated(channel.asRtcDataChannel());
262
+ },
263
+ negotiate: (request, requestOptions) =>
264
+ input.client.negotiateCodexRealtimeWebrtc(
265
+ input.workspaceId,
266
+ input.sessionId,
267
+ request,
268
+ requestOptions,
269
+ ),
270
+ });
271
+ const events = channel!;
272
+ return {
273
+ ...transport,
274
+ events: events.asRtcDataChannel(),
275
+ drain: () => events.drain(),
276
+ stop() {
277
+ events.close();
278
+ transport.stop();
279
+ },
280
+ };
281
+ } catch (error) {
282
+ channel?.close();
283
+ throw error;
284
+ }
285
+ };
286
+ }
@@ -120,8 +120,39 @@ export type CodexRealtimeControllerSnapshot = {
120
120
  reconnectAttempt: number;
121
121
  diagnostic: CodexRealtimeDiagnostic | null;
122
122
  error: string | null;
123
+ /**
124
+ * Set when Opengeni refused or ended deployment-funded voice for credits or
125
+ * availability. Codes match voice input: `insufficient_credits`,
126
+ * `allowance_exhausted`, `monthly_model_cost_limit`, plus
127
+ * `realtime_voice_unavailable`. Cleared on the next start.
128
+ */
129
+ refusal?: CodexRealtimeRefusal | null;
123
130
  };
124
131
 
132
+ export type CodexRealtimeRefusal = { code: string; message: string };
133
+
134
+ const REALTIME_REFUSAL_CODES = new Set([
135
+ "insufficient_credits",
136
+ "allowance_exhausted",
137
+ "monthly_model_cost_limit",
138
+ "realtime_voice_unavailable",
139
+ ]);
140
+
141
+ /** A definitive credit/availability refusal from Opengeni, with its plain message. */
142
+ export function codexRealtimeRefusal(error: unknown): CodexRealtimeRefusal | null {
143
+ if (!(error instanceof OpenGeniApiError) || !error.code) return null;
144
+ if (!REALTIME_REFUSAL_CODES.has(error.code)) return null;
145
+ const message = apiErrorMessage(error);
146
+ return {
147
+ code: error.code,
148
+ message:
149
+ message ??
150
+ (error.code === "insufficient_credits"
151
+ ? "Live voice needs Opengeni credits. Add credits to continue."
152
+ : "Live voice is unavailable right now."),
153
+ };
154
+ }
155
+
125
156
  export type CodexRealtimeControllerClient = {
126
157
  beginSessionRealtime(
127
158
  workspaceId: string,
@@ -178,6 +209,7 @@ export type CodexRealtimeOwnerStorage = Pick<Storage, "getItem" | "setItem" | "r
178
209
 
179
210
  /** Canonical browser-owner storage namespace for a public realtime model. */
180
211
  export function sessionRealtimeOwnerStorageNamespace(model: SessionRealtimeModel): string {
212
+ if (model === "opengeni-azure/gpt-live-1") return "azure-live-owner";
181
213
  if (model === "gpt-live-1-boulder-alpha") return "codex-realtime-owner";
182
214
  if (model === "supergrok/grok-voice-think-fast-2.0") return "xai-realtime-owner";
183
215
  return "gateway-realtime-owner";
@@ -355,6 +387,7 @@ export function createCodexRealtimeController(
355
387
  reconnectAttempt: 0,
356
388
  diagnostic: null,
357
389
  error: null,
390
+ refusal: null,
358
391
  };
359
392
  let active: ConnectionRuntime | null = null;
360
393
  let pendingAbort: AbortController | null = null;
@@ -371,6 +404,9 @@ export function createCodexRealtimeController(
371
404
  let generation = 0;
372
405
  let reconnectAttempt = 0;
373
406
  let recoveryTerminal = false;
407
+ // Whether this mode ever reached a live provider connection. A definitive
408
+ // failure before that ends the mode instead of leaving an empty call open.
409
+ let connectedInMode = false;
374
410
  let mutationTail = Promise.resolve();
375
411
  let connectionTask: Promise<void> | null = null;
376
412
  const acceptedDelegationItemIds = new Set(
@@ -699,6 +735,12 @@ export function createCodexRealtimeController(
699
735
  if (closed || stopping || isAbortError(error)) return;
700
736
  const message = safeError(error);
701
737
  if (error instanceof CodexRealtimeMicrophoneError) {
738
+ if (error.code !== "track_ended" && !connectedInMode) {
739
+ // No conversation exists yet: end the call and say how to fix the
740
+ // microphone instead of holding an empty "reconnecting" call open.
741
+ await endAfterFailure(microphoneFailureMessage(error), null);
742
+ return;
743
+ }
702
744
  if (error.code === "track_ended" && state.mode?.state === "active") {
703
745
  reconnectAttempt += 1;
704
746
  publish({
@@ -720,6 +762,11 @@ export function createCodexRealtimeController(
720
762
  transitionEnded("Realtime owner no longer exists");
721
763
  return;
722
764
  }
765
+ const refusal = codexRealtimeRefusal(error);
766
+ if (refusal) {
767
+ await endForRefusal(refusal);
768
+ return;
769
+ }
723
770
  if (error instanceof OpenGeniApiError && error.status === 409 && error.retryable && owner) {
724
771
  try {
725
772
  const reconciled = await begin(owner, true, false);
@@ -737,6 +784,9 @@ export function createCodexRealtimeController(
737
784
  return;
738
785
  }
739
786
  }
787
+ } else if (error instanceof OpenGeniApiError && !error.retryable && !connectedInMode) {
788
+ await endAfterFailure(message, null);
789
+ return;
740
790
  } else if (error instanceof OpenGeniApiError && !error.retryable) {
741
791
  stopTimers();
742
792
  if (!active) releaseMicrophone();
@@ -883,6 +933,15 @@ export function createCodexRealtimeController(
883
933
  });
884
934
  let connected: CodexRealtimeWebrtcSession;
885
935
  try {
936
+ // Persist a fragment provider's tail before the replacement broker reads
937
+ // its startup history, while the old connection still owns the ledger.
938
+ if (active?.transport.drain) {
939
+ await active.transport.drain();
940
+ await active.bridge.flush();
941
+ }
942
+ if (closed || stopping || abort.signal.aborted || pendingGeneration !== targetGeneration) {
943
+ throw abort.signal.reason ?? new DOMException("Aborted", "AbortError");
944
+ }
886
945
  const media = await ensureMicrophone(replaceMicrophone, abort.signal);
887
946
  const operationId = randomUUID();
888
947
  const commonTransportInput = {
@@ -1016,6 +1075,7 @@ export function createCodexRealtimeController(
1016
1075
  onFatal: (fatal) => onBridgeFatal(targetGeneration, bridge, fatal),
1017
1076
  });
1018
1077
  active = { generation: targetGeneration, transport: connected, bridge };
1078
+ connectedInMode = true;
1019
1079
  connected.setOutputMuted(state.outputMuted);
1020
1080
  recoveryTerminal = false;
1021
1081
  pendingAbort = null;
@@ -1098,6 +1158,7 @@ export function createCodexRealtimeController(
1098
1158
  };
1099
1159
 
1100
1160
  const heartbeat = async (): Promise<void> => {
1161
+ let stopInstruction: CodexRealtimeRefusal | null = null;
1101
1162
  await exclusive(async () => {
1102
1163
  const current = state.mode;
1103
1164
  if (!owner || !current || current.state !== "active") {
@@ -1121,6 +1182,43 @@ export function createCodexRealtimeController(
1121
1182
  return;
1122
1183
  }
1123
1184
  publish({ mode: result.mode, realtimeId: result.mode.id, error: null });
1185
+ stopInstruction = result.stop ?? null;
1186
+ });
1187
+ // The server stopped extending the lease (for example, out of credits):
1188
+ // drain and end gracefully inside the remaining lease.
1189
+ const instruction = stopInstruction as CodexRealtimeRefusal | null;
1190
+ stopInstruction = null;
1191
+ if (instruction && !stopping && !closed) await endForRefusal(instruction);
1192
+ };
1193
+
1194
+ /**
1195
+ * End the call because Opengeni refused it, keep final speech, and leave a
1196
+ * terminal, non-retrying state that explains why.
1197
+ */
1198
+ const endForRefusal = async (refusal: CodexRealtimeRefusal): Promise<void> =>
1199
+ await endAfterFailure(refusal.message, refusal);
1200
+
1201
+ const endAfterFailure = async (
1202
+ message: string,
1203
+ refusal: CodexRealtimeRefusal | null,
1204
+ ): Promise<void> => {
1205
+ try {
1206
+ await controller.stop();
1207
+ } catch {
1208
+ // The lease is no longer extended; a failed graceful end lapses on its own.
1209
+ stopping = false;
1210
+ closeBrowserResources();
1211
+ clearOwner();
1212
+ }
1213
+ recoveryTerminal = false;
1214
+ publish({
1215
+ status: "error",
1216
+ realtimeId: null,
1217
+ mode: null,
1218
+ bridge: null,
1219
+ diagnostic: diagnostic("terminal_stop", message, false),
1220
+ error: message,
1221
+ refusal,
1124
1222
  });
1125
1223
  };
1126
1224
 
@@ -1176,6 +1274,7 @@ export function createCodexRealtimeController(
1176
1274
  closed = false;
1177
1275
  stopping = false;
1178
1276
  recoveryTerminal = false;
1277
+ connectedInMode = false;
1179
1278
  const record: OwnerRecord = {
1180
1279
  version: OWNER_RECORD_VERSION,
1181
1280
  workspaceId: options.workspaceId,
@@ -1186,11 +1285,12 @@ export function createCodexRealtimeController(
1186
1285
  };
1187
1286
  owner = record;
1188
1287
  storage?.setItem(storageKey, JSON.stringify(record));
1189
- publish({ status: "starting", error: null, diagnostic: null });
1288
+ publish({ status: "starting", error: null, diagnostic: null, refusal: null });
1190
1289
  try {
1191
1290
  await begin(record, false);
1192
1291
  } catch (error) {
1193
1292
  closeBrowserResources(false);
1293
+ const refusal = codexRealtimeRefusal(error);
1194
1294
  const failedMode = state.mode as SessionRealtimeMode | null;
1195
1295
  if (failedMode?.state === "active") {
1196
1296
  await handleConnectionFailure(error, "reconnect", false);
@@ -1200,9 +1300,13 @@ export function createCodexRealtimeController(
1200
1300
  status: "error",
1201
1301
  realtimeId: null,
1202
1302
  mode: null,
1203
- error: safeError(error),
1303
+ error: refusal?.message ?? safeError(error),
1304
+ ...(refusal
1305
+ ? { refusal, diagnostic: diagnostic("terminal_stop", refusal.message, false) }
1306
+ : {}),
1204
1307
  });
1205
1308
  }
1309
+ if (refusal) return;
1206
1310
  throw error;
1207
1311
  }
1208
1312
  },
@@ -1211,6 +1315,8 @@ export function createCodexRealtimeController(
1211
1315
  if (recoveryTerminal && state.mode?.state === "active") return;
1212
1316
  const record = readOwnerRecord(storage, storageKey, options);
1213
1317
  if (!record) {
1318
+ // Keep a terminal failure readable after the server records the end.
1319
+ if (state.status === "error" && !state.mode) return;
1214
1320
  transitionEnded();
1215
1321
  return;
1216
1322
  }
@@ -1234,7 +1340,10 @@ export function createCodexRealtimeController(
1234
1340
  if (lifecycle.state === "ended") {
1235
1341
  const record = readOwnerRecord(storage, storageKey, options);
1236
1342
  if (record && record.operationId !== lifecycle.operationId) {
1237
- if (connectionTask || state.status === "active") return;
1343
+ // An earlier call's end is not news while this browser is starting
1344
+ // its own call (for example, waiting on the microphone prompt):
1345
+ // re-beginning would advance the lease and fail the pending start.
1346
+ if (connectionTask || state.status === "active" || state.status === "starting") return;
1238
1347
  closed = false;
1239
1348
  stopping = false;
1240
1349
  owner = record;
@@ -1250,6 +1359,9 @@ export function createCodexRealtimeController(
1250
1359
  await connectionTask;
1251
1360
  return;
1252
1361
  }
1362
+ // The server's end of a call that failed to start must not wipe the
1363
+ // failure the user still needs to read.
1364
+ if (state.status === "error" && !state.mode) return;
1253
1365
  if (state.realtimeId === null || lifecycle.realtimeId === state.realtimeId) {
1254
1366
  transitionEnded(`Realtime ended: ${lifecycle.reason}`);
1255
1367
  }
@@ -1337,6 +1449,7 @@ export function createCodexRealtimeController(
1337
1449
  connectionTask = null;
1338
1450
  void retiredTask?.catch(() => undefined);
1339
1451
  try {
1452
+ await active?.transport.drain?.();
1340
1453
  await active?.bridge.sealAndFlush();
1341
1454
  closeBrowserResources();
1342
1455
  let current = state.mode;
@@ -1708,6 +1821,37 @@ function isAbortError(error: unknown): boolean {
1708
1821
  return error instanceof Error && error.name === "AbortError";
1709
1822
  }
1710
1823
 
1824
+ function microphoneFailureMessage(error: CodexRealtimeMicrophoneError): string {
1825
+ switch (error.code) {
1826
+ case "permission_denied":
1827
+ return "Microphone access is blocked. Allow it in site settings, then try again.";
1828
+ case "device_not_found":
1829
+ return "No microphone was found. Connect one, then try again.";
1830
+ case "device_unavailable":
1831
+ return "Your microphone is busy or unavailable. Close other apps using it, then try again.";
1832
+ default:
1833
+ return /timed out/i.test(error.message)
1834
+ ? "Microphone access wasn't granted in time. Allow it when your browser asks, then try again."
1835
+ : "The microphone could not start. Try again.";
1836
+ }
1837
+ }
1838
+
1839
+ /** The server's own message, without the transport prefix or reference id. */
1840
+ function apiErrorMessage(error: OpenGeniApiError): string | null {
1841
+ try {
1842
+ const body = JSON.parse(error.body) as Record<string, unknown>;
1843
+ const nested =
1844
+ body.error && typeof body.error === "object" ? (body.error as Record<string, unknown>) : body;
1845
+ return typeof nested.message === "string" && nested.message ? nested.message : null;
1846
+ } catch {
1847
+ return null;
1848
+ }
1849
+ }
1850
+
1711
1851
  function safeError(error: unknown): string {
1712
- return error instanceof Error ? error.message : "Codex realtime browser controller failed";
1852
+ if (error instanceof OpenGeniApiError) {
1853
+ const message = apiErrorMessage(error);
1854
+ if (message) return /[.!?]$/.test(message) ? message : `${message}.`;
1855
+ }
1856
+ return error instanceof Error ? error.message : "Live voice failed in this browser.";
1713
1857
  }
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * These shapes are pinned to openai/codex@fa1d4c40. V3 delegates work with
5
5
  * `delegation.created`; it does not expose the ordinary Realtime V2
6
- * `function_call` protocol.
6
+ * `function_call` protocol. `transcript.segment` is an application adapter
7
+ * extension for providers that emit fragments without semantic turn boundaries.
7
8
  */
8
9
 
9
10
  export const CODEX_REALTIME_INITIAL_ITEMS_MAX_COUNT = 128;
@@ -40,7 +41,7 @@ export type CodexRealtimeV3Event =
40
41
  endMs: number | null;
41
42
  })
42
43
  | (ProviderEventIdentity & {
43
- type: "turn.done";
44
+ type: "turn.done" | "transcript.segment";
44
45
  turnId: string;
45
46
  role: "user" | "assistant";
46
47
  transcript: string;
@@ -173,7 +174,7 @@ export function parseCodexRealtimeV3Event(payload: string): CodexRealtimeV3Parse
173
174
  };
174
175
  }
175
176
 
176
- if (type === "turn.done") {
177
+ if (type === "turn.done" || type === "transcript.segment") {
177
178
  const turn = record(event.turn);
178
179
  const turnIdField = boundedStringField(turn, "id", CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES);
179
180
  const transcriptField = boundedStringField(
@@ -324,6 +324,12 @@ export function createCodexRealtimeV3Bridge(
324
324
  clientAckThroughSequence = Math.max(clientAckThroughSequence ?? 0, entry.sequence);
325
325
  }
326
326
  }
327
+ // Persist final inbound fragments even after the provider starts closing.
328
+ // Pending outbound context remains replayable on the next connection.
329
+ if (options.events.readyState !== "open") {
330
+ publish();
331
+ continue;
332
+ }
327
333
  for (let index = 0; index < result.outbound.length;) {
328
334
  const entry = result.outbound[index]!;
329
335
  if (sentSequences.has(entry.sequence)) {
@@ -497,7 +503,7 @@ export function createCodexRealtimeV3Bridge(
497
503
  }
498
504
  } else if (event.type === "output_audio.delta") {
499
505
  speaking = true;
500
- } else if (event.type === "turn.done") {
506
+ } else if (event.type === "turn.done" || event.type === "transcript.segment") {
501
507
  speaking = false;
502
508
  if (event.transcript.length > 0 && !finalizedTurnIds.has(event.turnId)) {
503
509
  const coveredByDelegationItemId =
@@ -570,7 +576,7 @@ export function createCodexRealtimeV3Bridge(
570
576
 
571
577
  function finalTranscript(
572
578
  randomUUID: () => string,
573
- event: Extract<CodexRealtimeV3Event, { type: "turn.done" }>,
579
+ event: Extract<CodexRealtimeV3Event, { type: "turn.done" | "transcript.segment" }>,
574
580
  coveredByDelegationItemId: string | null,
575
581
  modelContext: string | undefined,
576
582
  ): SessionRealtimeInboundEntry {
@@ -581,6 +587,9 @@ function finalTranscript(
581
587
  text: event.transcript,
582
588
  payload: {
583
589
  turnId: event.turnId,
590
+ ...(event.type === "transcript.segment"
591
+ ? { transcriptSource: "provider_fragments", grouping: "application_segment" }
592
+ : {}),
584
593
  ...(coveredByDelegationItemId ? { coveredByDelegationItemId } : {}),
585
594
  },
586
595
  ...(modelContext ? { modelContext } : {}),
@@ -37,6 +37,8 @@ export type AcquireCodexRealtimeMicrophoneOptions = {
37
37
 
38
38
  export type StartCodexRealtimeWebrtcOptions = {
39
39
  negotiate: CodexRealtimeNegotiator;
40
+ /** Exact configured provider model expected from the server broker. */
41
+ expectedModel?: CodexRealtimeWebrtcResponse["model"];
40
42
  realtimeId: string;
41
43
  operationId: string;
42
44
  browserInstanceId: string;
@@ -77,6 +79,8 @@ export type CodexRealtimeWebrtcSession = {
77
79
  setOutputMuted(muted: boolean): void;
78
80
  activateRemoteAudio(): void;
79
81
  retryAudibleOutput(): Promise<boolean>;
82
+ /** Gracefully finish provider output before sealing the durable ledger. */
83
+ drain?(): Promise<void>;
80
84
  /** Idempotently close media, data channel, and peer transport. */
81
85
  stop(): void;
82
86
  };
@@ -260,7 +264,11 @@ export async function startCodexRealtimeWebrtc(
260
264
  { signal: options.signal },
261
265
  );
262
266
  throwIfAborted(options.signal);
263
- if (answer.version !== "v3" || answer.model !== "gpt-live-1-boulder-alpha" || !answer.sdp) {
267
+ if (
268
+ answer.version !== "v3" ||
269
+ answer.model !== (options.expectedModel ?? "gpt-live-1-boulder-alpha") ||
270
+ !answer.sdp
271
+ ) {
264
272
  throw new Error("Opengeni returned an incompatible Codex realtime answer");
265
273
  }
266
274
  await peerConnection.setRemoteDescription({
package/src/index.ts CHANGED
@@ -356,9 +356,11 @@ export type {
356
356
  CodexRealtimeControllerStatus,
357
357
  CodexRealtimeMicrophoneState,
358
358
  CodexRealtimeOwnerStorage,
359
+ CodexRealtimeRefusal,
359
360
  CreateCodexRealtimeControllerOptions,
360
361
  RealtimeControllerTransportStarter,
361
362
  } from "./codex-realtime-controller";
363
+ export { codexRealtimeRefusal } from "./codex-realtime-controller";
362
364
  export {
363
365
  createGatewayRealtimeTransportStarter,
364
366
  createXaiSubscriptionRealtimeTransportStarter,
@@ -749,6 +751,7 @@ export type {
749
751
  SessionRealtimeMode,
750
752
  SessionRealtimeModel,
751
753
  SessionRealtimeMutationResponse,
754
+ SessionRealtimeStopInstruction,
752
755
  SessionRealtimeState,
753
756
  WorkspaceModelCatalogModel,
754
757
  WorkspaceModelCatalogResponse,