@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
package/src/codex-realtime-v3.ts
CHANGED
|
@@ -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 } : {}),
|
package/src/codex-realtime.ts
CHANGED
|
@@ -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 (
|
|
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,
|