@opengeni/codex 0.2.9 → 0.2.11
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/dist/api-client.d.ts +2 -0
- package/dist/{chunk-YGKQDUY7.js → chunk-HT463QWA.js} +10 -2
- package/dist/chunk-HT463QWA.js.map +1 -0
- package/dist/chunk-MFVGCR3N.js +236 -0
- package/dist/chunk-MFVGCR3N.js.map +1 -0
- package/dist/constants.d.ts +5 -1
- package/dist/constants.js +9 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +350 -10
- package/dist/index.js.map +1 -1
- package/dist/mcp-sanitize.d.ts +4 -3
- package/dist/opaque-artifact.d.ts +4 -0
- package/dist/realtime-v3.d.ts +86 -0
- package/dist/realtime-v3.js +25 -0
- package/dist/realtime-v3.js.map +1 -0
- package/dist/realtime.d.ts +48 -0
- package/dist/request-context.d.ts +6 -0
- package/package.json +5 -1
- package/src/api-client.ts +26 -9
- package/src/constants.ts +8 -1
- package/src/fetch.ts +4 -0
- package/src/index.ts +3 -0
- package/src/mcp-sanitize.ts +5 -4
- package/src/normalize.ts +1 -1
- package/src/opaque-artifact.ts +29 -0
- package/src/realtime-v3.ts +349 -0
- package/src/realtime.ts +356 -0
- package/src/request-context.ts +7 -0
- package/dist/chunk-YGKQDUY7.js.map +0 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex GPT-Live Frameless Bidi (V3) wire adapter.
|
|
3
|
+
*
|
|
4
|
+
* These shapes are pinned to openai/codex@fa1d4c40. V3 delegates work with
|
|
5
|
+
* `delegation.created`; it does not expose the ordinary Realtime V2
|
|
6
|
+
* `function_call` protocol.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const CODEX_REALTIME_INITIAL_ITEMS_MAX_COUNT = 128;
|
|
10
|
+
export const CODEX_REALTIME_INITIAL_ITEMS_MAX_TOKENS = 8_192;
|
|
11
|
+
export const CODEX_REALTIME_CONTEXT_APPEND_MAX_BYTES = 500;
|
|
12
|
+
export const CODEX_REALTIME_V3_MAX_EVENT_BYTES = 1_048_576;
|
|
13
|
+
export const CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES = 1_024;
|
|
14
|
+
export const CODEX_REALTIME_V3_MAX_TEXT_BYTES = 131_072;
|
|
15
|
+
|
|
16
|
+
export type CodexRealtimeInitialItem = {
|
|
17
|
+
role: "user" | "developer" | "assistant";
|
|
18
|
+
text: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type ProviderEventIdentity = {
|
|
22
|
+
providerEventId: string | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type CodexRealtimeV3Event =
|
|
26
|
+
| (ProviderEventIdentity & {
|
|
27
|
+
type: "session.started" | "session.updated";
|
|
28
|
+
sessionId: string;
|
|
29
|
+
instructions: string | null;
|
|
30
|
+
})
|
|
31
|
+
| (ProviderEventIdentity & {
|
|
32
|
+
type: "input_transcript.added" | "output_transcript.added";
|
|
33
|
+
itemId: string | null;
|
|
34
|
+
text: string;
|
|
35
|
+
})
|
|
36
|
+
| (ProviderEventIdentity & {
|
|
37
|
+
type: "output_audio.delta";
|
|
38
|
+
audio: string;
|
|
39
|
+
startMs: number | null;
|
|
40
|
+
endMs: number | null;
|
|
41
|
+
})
|
|
42
|
+
| (ProviderEventIdentity & {
|
|
43
|
+
type: "turn.done";
|
|
44
|
+
turnId: string;
|
|
45
|
+
role: "user" | "assistant";
|
|
46
|
+
transcript: string;
|
|
47
|
+
})
|
|
48
|
+
| (ProviderEventIdentity & {
|
|
49
|
+
type: "delegation.created";
|
|
50
|
+
delegationItemId: string;
|
|
51
|
+
inputTranscript: string;
|
|
52
|
+
offsetMs: number | null;
|
|
53
|
+
})
|
|
54
|
+
| (ProviderEventIdentity & {
|
|
55
|
+
type: "error";
|
|
56
|
+
message: string;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export type CodexRealtimeV3ParseFailure = {
|
|
60
|
+
ok: false;
|
|
61
|
+
reason:
|
|
62
|
+
| "invalid_json"
|
|
63
|
+
| "missing_type"
|
|
64
|
+
| "unsupported_type"
|
|
65
|
+
| "invalid_shape"
|
|
66
|
+
| "oversized_event"
|
|
67
|
+
| "oversized_field";
|
|
68
|
+
eventType: string | null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type CodexRealtimeV3ParseResult =
|
|
72
|
+
| { ok: true; event: CodexRealtimeV3Event }
|
|
73
|
+
| CodexRealtimeV3ParseFailure;
|
|
74
|
+
|
|
75
|
+
export type CodexRealtimeV3ContextAppendChannel = "speakable" | "commentary";
|
|
76
|
+
|
|
77
|
+
export type CodexRealtimeV3DelegationContextAppend = {
|
|
78
|
+
type: "delegation.context.append";
|
|
79
|
+
delegation_item_id: string;
|
|
80
|
+
channel?: CodexRealtimeV3ContextAppendChannel | undefined;
|
|
81
|
+
content: Array<{ type: "input_text"; text: string }>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type CodexRealtimeV3SessionContextAppend = {
|
|
85
|
+
type: "session.context.append";
|
|
86
|
+
channel?: CodexRealtimeV3ContextAppendChannel | undefined;
|
|
87
|
+
content: Array<{ type: "input_text"; text: string }>;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function parseCodexRealtimeV3Event(payload: string): CodexRealtimeV3ParseResult {
|
|
91
|
+
if (utf8ByteLength(payload) > CODEX_REALTIME_V3_MAX_EVENT_BYTES) {
|
|
92
|
+
return failure("oversized_event", null);
|
|
93
|
+
}
|
|
94
|
+
let value: unknown;
|
|
95
|
+
try {
|
|
96
|
+
value = JSON.parse(payload);
|
|
97
|
+
} catch {
|
|
98
|
+
return failure("invalid_json", null);
|
|
99
|
+
}
|
|
100
|
+
const event = record(value);
|
|
101
|
+
const typeField = boundedStringField(event, "type", CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES);
|
|
102
|
+
if (typeField.oversized) return failure("oversized_field", null);
|
|
103
|
+
const type = typeField.value;
|
|
104
|
+
if (!type) return failure("missing_type", null);
|
|
105
|
+
const providerEventIdField = firstBoundedStringField(
|
|
106
|
+
event,
|
|
107
|
+
["event_id", "id"],
|
|
108
|
+
CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES,
|
|
109
|
+
);
|
|
110
|
+
if (providerEventIdField.oversized) return failure("oversized_field", type);
|
|
111
|
+
const providerEventId = providerEventIdField.value ?? null;
|
|
112
|
+
|
|
113
|
+
if (type === "session.started" || type === "session.updated") {
|
|
114
|
+
const session = record(event.session);
|
|
115
|
+
const sessionIdField = boundedStringField(
|
|
116
|
+
session,
|
|
117
|
+
"id",
|
|
118
|
+
CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES,
|
|
119
|
+
);
|
|
120
|
+
const instructionsField = boundedStringField(
|
|
121
|
+
session,
|
|
122
|
+
"instructions",
|
|
123
|
+
CODEX_REALTIME_V3_MAX_TEXT_BYTES,
|
|
124
|
+
);
|
|
125
|
+
if (sessionIdField.oversized || instructionsField.oversized) {
|
|
126
|
+
return failure("oversized_field", type);
|
|
127
|
+
}
|
|
128
|
+
const sessionId = sessionIdField.value;
|
|
129
|
+
if (!sessionId) return failure("invalid_shape", type);
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
event: {
|
|
133
|
+
type,
|
|
134
|
+
providerEventId,
|
|
135
|
+
sessionId,
|
|
136
|
+
instructions: instructionsField.value ?? null,
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (type === "input_transcript.added" || type === "output_transcript.added") {
|
|
142
|
+
const item = record(event.item);
|
|
143
|
+
const itemIdField = boundedStringField(item, "id", CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES);
|
|
144
|
+
const textField = boundedStringField(item, "text", CODEX_REALTIME_V3_MAX_TEXT_BYTES);
|
|
145
|
+
if (itemIdField.oversized || textField.oversized) {
|
|
146
|
+
return failure("oversized_field", type);
|
|
147
|
+
}
|
|
148
|
+
const text = textField.value;
|
|
149
|
+
if (text === undefined) return failure("invalid_shape", type);
|
|
150
|
+
return {
|
|
151
|
+
ok: true,
|
|
152
|
+
event: {
|
|
153
|
+
type,
|
|
154
|
+
providerEventId,
|
|
155
|
+
itemId: itemIdField.value ?? null,
|
|
156
|
+
text,
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (type === "output_audio.delta") {
|
|
162
|
+
const audio = stringField(event, "audio");
|
|
163
|
+
if (audio === undefined) return failure("invalid_shape", type);
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
event: {
|
|
167
|
+
type,
|
|
168
|
+
providerEventId,
|
|
169
|
+
audio,
|
|
170
|
+
startMs: finiteNumberField(event, "start_ms"),
|
|
171
|
+
endMs: finiteNumberField(event, "end_ms"),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (type === "turn.done") {
|
|
177
|
+
const turn = record(event.turn);
|
|
178
|
+
const turnIdField = boundedStringField(turn, "id", CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES);
|
|
179
|
+
const transcriptField = boundedStringField(
|
|
180
|
+
turn,
|
|
181
|
+
"transcript",
|
|
182
|
+
CODEX_REALTIME_V3_MAX_TEXT_BYTES,
|
|
183
|
+
);
|
|
184
|
+
if (turnIdField.oversized || transcriptField.oversized) {
|
|
185
|
+
return failure("oversized_field", type);
|
|
186
|
+
}
|
|
187
|
+
const turnId = turnIdField.value;
|
|
188
|
+
const role = stringField(turn, "role");
|
|
189
|
+
const transcript = transcriptField.value;
|
|
190
|
+
if (!turnId || (role !== "user" && role !== "assistant") || transcript === undefined) {
|
|
191
|
+
return failure("invalid_shape", type);
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
event: { type, providerEventId, turnId, role, transcript },
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (type === "delegation.created") {
|
|
200
|
+
const item = record(event.item);
|
|
201
|
+
const delegationItemIdField = boundedStringField(
|
|
202
|
+
item,
|
|
203
|
+
"id",
|
|
204
|
+
CODEX_REALTIME_V3_MAX_IDENTIFIER_BYTES,
|
|
205
|
+
);
|
|
206
|
+
if (delegationItemIdField.oversized) return failure("oversized_field", type);
|
|
207
|
+
const delegationItemId = delegationItemIdField.value;
|
|
208
|
+
if (
|
|
209
|
+
!delegationItemId ||
|
|
210
|
+
stringField(item, "type") !== "delegation" ||
|
|
211
|
+
stringField(item, "target") !== "client" ||
|
|
212
|
+
!Array.isArray(item.content)
|
|
213
|
+
) {
|
|
214
|
+
return failure("invalid_shape", type);
|
|
215
|
+
}
|
|
216
|
+
const inputParts: string[] = [];
|
|
217
|
+
let inputBytes = 0;
|
|
218
|
+
for (const rawContent of item.content) {
|
|
219
|
+
const content = record(rawContent);
|
|
220
|
+
if (stringField(content, "type") !== "input_text") continue;
|
|
221
|
+
const textField = boundedStringField(content, "text", CODEX_REALTIME_V3_MAX_TEXT_BYTES);
|
|
222
|
+
if (textField.oversized) return failure("oversized_field", type);
|
|
223
|
+
const text = textField.value ?? "";
|
|
224
|
+
inputBytes += utf8ByteLength(text);
|
|
225
|
+
if (inputBytes > CODEX_REALTIME_V3_MAX_TEXT_BYTES) {
|
|
226
|
+
return failure("oversized_field", type);
|
|
227
|
+
}
|
|
228
|
+
inputParts.push(text);
|
|
229
|
+
}
|
|
230
|
+
const inputTranscript = inputParts.join("");
|
|
231
|
+
return {
|
|
232
|
+
ok: true,
|
|
233
|
+
event: {
|
|
234
|
+
type,
|
|
235
|
+
providerEventId,
|
|
236
|
+
delegationItemId,
|
|
237
|
+
inputTranscript,
|
|
238
|
+
offsetMs: finiteNumberField(event, "offset_ms"),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (type === "error") {
|
|
244
|
+
const nested = record(event.error);
|
|
245
|
+
const message =
|
|
246
|
+
stringField(event, "message") ??
|
|
247
|
+
stringField(nested, "message") ??
|
|
248
|
+
(event.error === undefined ? undefined : JSON.stringify(event.error));
|
|
249
|
+
if (message === undefined) return failure("invalid_shape", type);
|
|
250
|
+
if (utf8ByteLength(message) > CODEX_REALTIME_V3_MAX_TEXT_BYTES) {
|
|
251
|
+
return failure("oversized_field", type);
|
|
252
|
+
}
|
|
253
|
+
return { ok: true, event: { type, providerEventId, message } };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return failure("unsupported_type", type);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function encodeCodexRealtimeV3DelegationContextAppend(input: {
|
|
260
|
+
delegationItemId: string;
|
|
261
|
+
text: string;
|
|
262
|
+
channel?: CodexRealtimeV3ContextAppendChannel | undefined;
|
|
263
|
+
}): CodexRealtimeV3DelegationContextAppend[] {
|
|
264
|
+
return contextAppendChunks(input.text).map((text) => ({
|
|
265
|
+
type: "delegation.context.append",
|
|
266
|
+
delegation_item_id: input.delegationItemId,
|
|
267
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
268
|
+
content: [{ type: "input_text", text }],
|
|
269
|
+
}));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function encodeCodexRealtimeV3SessionContextAppend(input: {
|
|
273
|
+
text: string;
|
|
274
|
+
channel?: CodexRealtimeV3ContextAppendChannel | undefined;
|
|
275
|
+
}): CodexRealtimeV3SessionContextAppend[] {
|
|
276
|
+
return contextAppendChunks(input.text).map((text) => ({
|
|
277
|
+
type: "session.context.append",
|
|
278
|
+
...(input.channel ? { channel: input.channel } : {}),
|
|
279
|
+
content: [{ type: "input_text", text }],
|
|
280
|
+
}));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function contextAppendChunks(text: string): string[] {
|
|
284
|
+
if (utf8ByteLength(text) <= CODEX_REALTIME_CONTEXT_APPEND_MAX_BYTES) return [text];
|
|
285
|
+
const chunks: string[] = [];
|
|
286
|
+
let chunk = "";
|
|
287
|
+
let bytes = 0;
|
|
288
|
+
for (const character of text) {
|
|
289
|
+
const characterBytes = utf8ByteLength(character);
|
|
290
|
+
if (bytes + characterBytes > CODEX_REALTIME_CONTEXT_APPEND_MAX_BYTES && chunk) {
|
|
291
|
+
chunks.push(chunk);
|
|
292
|
+
chunk = "";
|
|
293
|
+
bytes = 0;
|
|
294
|
+
}
|
|
295
|
+
chunk += character;
|
|
296
|
+
bytes += characterBytes;
|
|
297
|
+
}
|
|
298
|
+
if (chunk || text.length === 0) chunks.push(chunk);
|
|
299
|
+
return chunks;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function utf8ByteLength(value: string): number {
|
|
303
|
+
return new TextEncoder().encode(value).byteLength;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function failure(
|
|
307
|
+
reason: CodexRealtimeV3ParseFailure["reason"],
|
|
308
|
+
eventType: string | null,
|
|
309
|
+
): CodexRealtimeV3ParseFailure {
|
|
310
|
+
return { ok: false, reason, eventType };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function record(value: unknown): Record<string, unknown> {
|
|
314
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
315
|
+
? (value as Record<string, unknown>)
|
|
316
|
+
: {};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function stringField(value: Record<string, unknown>, key: string): string | undefined {
|
|
320
|
+
return typeof value[key] === "string" ? value[key] : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function boundedStringField(
|
|
324
|
+
value: Record<string, unknown>,
|
|
325
|
+
key: string,
|
|
326
|
+
maxBytes: number,
|
|
327
|
+
): { value: string | undefined; oversized: boolean } {
|
|
328
|
+
const candidate = stringField(value, key);
|
|
329
|
+
return {
|
|
330
|
+
value: candidate,
|
|
331
|
+
oversized: candidate !== undefined && utf8ByteLength(candidate) > maxBytes,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function firstBoundedStringField(
|
|
336
|
+
value: Record<string, unknown>,
|
|
337
|
+
keys: readonly string[],
|
|
338
|
+
maxBytes: number,
|
|
339
|
+
): { value: string | undefined; oversized: boolean } {
|
|
340
|
+
for (const key of keys) {
|
|
341
|
+
if (typeof value[key] === "string") return boundedStringField(value, key, maxBytes);
|
|
342
|
+
}
|
|
343
|
+
return { value: undefined, oversized: false };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function finiteNumberField(value: Record<string, unknown>, key: string): number | null {
|
|
347
|
+
const candidate = value[key];
|
|
348
|
+
return typeof candidate === "number" && Number.isFinite(candidate) ? candidate : null;
|
|
349
|
+
}
|
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
import { codexSubscriptionHeaders, type CodexAuthHeaders } from "./api-client";
|
|
2
|
+
import {
|
|
3
|
+
CODEX_REALTIME_CALL_TIMEOUT_MS,
|
|
4
|
+
CODEX_REALTIME_DEFAULT_VOICE,
|
|
5
|
+
CODEX_REALTIME_MODEL,
|
|
6
|
+
CODEX_REALTIME_VERSION,
|
|
7
|
+
CODEX_RESPONSES_BASE,
|
|
8
|
+
} from "./constants";
|
|
9
|
+
import type { CodexFetch } from "./device-code";
|
|
10
|
+
import {
|
|
11
|
+
CODEX_REALTIME_INITIAL_ITEMS_MAX_COUNT,
|
|
12
|
+
CODEX_REALTIME_INITIAL_ITEMS_MAX_TOKENS,
|
|
13
|
+
type CodexRealtimeInitialItem,
|
|
14
|
+
} from "./realtime-v3";
|
|
15
|
+
|
|
16
|
+
const CODEX_REALTIME_CALL_URL = `${CODEX_RESPONSES_BASE}/realtime/calls?intent=quicksilver&architecture=avas`;
|
|
17
|
+
const MAX_REALTIME_SDP_BYTES = 1024 * 1024;
|
|
18
|
+
const REALTIME_CALL_ID =
|
|
19
|
+
/^(?:rtc_.+|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i;
|
|
20
|
+
|
|
21
|
+
export const CODEX_REALTIME_VOICES = [
|
|
22
|
+
"juniper",
|
|
23
|
+
"maple",
|
|
24
|
+
"spruce",
|
|
25
|
+
"ember",
|
|
26
|
+
"vale",
|
|
27
|
+
"breeze",
|
|
28
|
+
"arbor",
|
|
29
|
+
"sol",
|
|
30
|
+
"cove",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
export type CodexRealtimeVoice = (typeof CODEX_REALTIME_VOICES)[number];
|
|
34
|
+
|
|
35
|
+
export type CodexRealtimeCallInput = {
|
|
36
|
+
/** Browser-created WebRTC offer. It must negotiate an audio media section. */
|
|
37
|
+
sdp: string;
|
|
38
|
+
/** This transport intentionally supports only Codex's Frameless/V3 protocol. */
|
|
39
|
+
version: typeof CODEX_REALTIME_VERSION;
|
|
40
|
+
/** Server-owned session/thread binding; sent upstream but never returned. */
|
|
41
|
+
sessionId: string;
|
|
42
|
+
/** Server-projected ordinary-session history for Frameless V3 bootstrap. */
|
|
43
|
+
initialItems?: CodexRealtimeInitialItem[] | undefined;
|
|
44
|
+
instructions?: string | undefined;
|
|
45
|
+
voice?: CodexRealtimeVoice | undefined;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type CodexRealtimeCallResult = {
|
|
49
|
+
sdp: string;
|
|
50
|
+
version: typeof CODEX_REALTIME_VERSION;
|
|
51
|
+
model: typeof CODEX_REALTIME_MODEL;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type CodexRealtimeErrorCode =
|
|
55
|
+
| "invalid_request"
|
|
56
|
+
| "incompatible"
|
|
57
|
+
| "authentication"
|
|
58
|
+
| "entitlement"
|
|
59
|
+
| "rate_limited"
|
|
60
|
+
| "provider"
|
|
61
|
+
| "invalid_response"
|
|
62
|
+
| "network"
|
|
63
|
+
| "timeout"
|
|
64
|
+
| "cancelled";
|
|
65
|
+
|
|
66
|
+
/** Safe provider failure: it contains no response body, credential, or account identity. */
|
|
67
|
+
export class CodexRealtimeError extends Error {
|
|
68
|
+
constructor(
|
|
69
|
+
readonly code: CodexRealtimeErrorCode,
|
|
70
|
+
message: string,
|
|
71
|
+
readonly providerStatus: number | null = null,
|
|
72
|
+
) {
|
|
73
|
+
super(message);
|
|
74
|
+
this.name = "CodexRealtimeError";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type CodexRealtimeCallOptions = {
|
|
79
|
+
signal?: AbortSignal | undefined;
|
|
80
|
+
timeoutMs?: number | undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Create one native subscription-authenticated Codex GPT-Live V3 WebRTC call.
|
|
85
|
+
*
|
|
86
|
+
* There is deliberately no API-key or WebSocket fallback and no transport
|
|
87
|
+
* retry. A caller may refresh the same connected subscription after one 401,
|
|
88
|
+
* but this adapter always performs exactly one provider request.
|
|
89
|
+
*/
|
|
90
|
+
export async function createCodexRealtimeCall(
|
|
91
|
+
auth: CodexAuthHeaders,
|
|
92
|
+
input: CodexRealtimeCallInput,
|
|
93
|
+
fetchImpl: CodexFetch = fetch,
|
|
94
|
+
options: CodexRealtimeCallOptions = {},
|
|
95
|
+
): Promise<CodexRealtimeCallResult> {
|
|
96
|
+
validateRealtimeInput(input);
|
|
97
|
+
const timeoutMs = options.timeoutMs ?? CODEX_REALTIME_CALL_TIMEOUT_MS;
|
|
98
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
99
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime timeout must be positive");
|
|
100
|
+
}
|
|
101
|
+
if (options.signal?.aborted) {
|
|
102
|
+
throw new CodexRealtimeError("cancelled", "Codex realtime request cancelled");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
let timedOut = false;
|
|
107
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
108
|
+
let rejectCancellation: ((error: CodexRealtimeError) => void) | undefined;
|
|
109
|
+
const cancellation = new Promise<never>((_resolve, reject) => {
|
|
110
|
+
rejectCancellation = reject;
|
|
111
|
+
});
|
|
112
|
+
const onAbort = (): void => {
|
|
113
|
+
controller.abort(options.signal?.reason);
|
|
114
|
+
rejectCancellation?.(new CodexRealtimeError("cancelled", "Codex realtime request cancelled"));
|
|
115
|
+
};
|
|
116
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
117
|
+
|
|
118
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
119
|
+
timeout = setTimeout(() => {
|
|
120
|
+
timedOut = true;
|
|
121
|
+
controller.abort();
|
|
122
|
+
reject(new CodexRealtimeError("timeout", "Codex realtime request timed out"));
|
|
123
|
+
}, timeoutMs);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
const request = (async (): Promise<CodexRealtimeCallResult> => {
|
|
127
|
+
const response = await fetchImpl(CODEX_REALTIME_CALL_URL, {
|
|
128
|
+
method: "POST",
|
|
129
|
+
headers: {
|
|
130
|
+
...codexSubscriptionHeaders(auth),
|
|
131
|
+
"content-type": "application/json",
|
|
132
|
+
"openai-alpha": "quicksilver=v2",
|
|
133
|
+
"session-id": input.sessionId,
|
|
134
|
+
"thread-id": input.sessionId,
|
|
135
|
+
},
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
sdp: input.sdp,
|
|
138
|
+
session: {
|
|
139
|
+
instructions: input.instructions ?? "",
|
|
140
|
+
audio: {
|
|
141
|
+
output: { voice: input.voice ?? CODEX_REALTIME_DEFAULT_VOICE },
|
|
142
|
+
},
|
|
143
|
+
delegation: { type: "client" },
|
|
144
|
+
model: CODEX_REALTIME_MODEL,
|
|
145
|
+
...(input.initialItems?.length
|
|
146
|
+
? {
|
|
147
|
+
initial_items: input.initialItems.map((item) => ({
|
|
148
|
+
type: "message",
|
|
149
|
+
role: item.role,
|
|
150
|
+
content: [
|
|
151
|
+
{
|
|
152
|
+
type: item.role === "assistant" ? "output_text" : "input_text",
|
|
153
|
+
text: item.text,
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
})),
|
|
157
|
+
}
|
|
158
|
+
: {}),
|
|
159
|
+
},
|
|
160
|
+
}),
|
|
161
|
+
signal: controller.signal,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
if (!response.ok) {
|
|
165
|
+
await response.body?.cancel().catch(() => undefined);
|
|
166
|
+
throw providerHttpError(response.status);
|
|
167
|
+
}
|
|
168
|
+
const location = response.headers.get("location");
|
|
169
|
+
if (!location || !validRealtimeLocation(location)) {
|
|
170
|
+
await response.body?.cancel().catch(() => undefined);
|
|
171
|
+
throw new CodexRealtimeError(
|
|
172
|
+
"invalid_response",
|
|
173
|
+
"Codex realtime response did not identify a compatible call",
|
|
174
|
+
response.status,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const sdp = await readBoundedSdp(response);
|
|
178
|
+
if (!isAudioSdp(sdp)) {
|
|
179
|
+
throw new CodexRealtimeError(
|
|
180
|
+
"invalid_response",
|
|
181
|
+
"Codex realtime response was not an audio SDP answer",
|
|
182
|
+
response.status,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
sdp,
|
|
187
|
+
version: CODEX_REALTIME_VERSION,
|
|
188
|
+
model: CODEX_REALTIME_MODEL,
|
|
189
|
+
};
|
|
190
|
+
})();
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
// Promise.race attaches rejection handlers to every branch, so a custom
|
|
194
|
+
// fetch that ignores AbortSignal cannot produce a late unhandled rejection.
|
|
195
|
+
return await Promise.race([request, cancellation, deadline]);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof CodexRealtimeError) throw error;
|
|
198
|
+
if (options.signal?.aborted) {
|
|
199
|
+
throw new CodexRealtimeError("cancelled", "Codex realtime request cancelled");
|
|
200
|
+
}
|
|
201
|
+
if (timedOut || controller.signal.aborted) {
|
|
202
|
+
throw new CodexRealtimeError("timeout", "Codex realtime request timed out");
|
|
203
|
+
}
|
|
204
|
+
throw new CodexRealtimeError("network", "Codex realtime provider request failed");
|
|
205
|
+
} finally {
|
|
206
|
+
if (timeout) clearTimeout(timeout);
|
|
207
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Shared pin→active selection for worker turns and direct realtime calls. */
|
|
212
|
+
export function selectCodexCredentialId(args: {
|
|
213
|
+
sessionPinnedCredentialId: string | null;
|
|
214
|
+
activeCredentialId: string | null;
|
|
215
|
+
connectedIds: ReadonlySet<string>;
|
|
216
|
+
}): string | null {
|
|
217
|
+
if (args.sessionPinnedCredentialId && args.connectedIds.has(args.sessionPinnedCredentialId)) {
|
|
218
|
+
return args.sessionPinnedCredentialId;
|
|
219
|
+
}
|
|
220
|
+
if (args.activeCredentialId && args.connectedIds.has(args.activeCredentialId)) {
|
|
221
|
+
return args.activeCredentialId;
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function validateRealtimeInput(input: CodexRealtimeCallInput): void {
|
|
227
|
+
if (input.version !== CODEX_REALTIME_VERSION) {
|
|
228
|
+
throw new CodexRealtimeError(
|
|
229
|
+
"incompatible",
|
|
230
|
+
`Codex realtime requires ${CODEX_REALTIME_VERSION}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (!input.sessionId || input.sessionId.length > 128) {
|
|
234
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime session id is invalid");
|
|
235
|
+
}
|
|
236
|
+
if (new TextEncoder().encode(input.sdp).byteLength > MAX_REALTIME_SDP_BYTES) {
|
|
237
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime SDP offer is too large");
|
|
238
|
+
}
|
|
239
|
+
if (!isAudioSdp(input.sdp)) {
|
|
240
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime requires an audio SDP offer");
|
|
241
|
+
}
|
|
242
|
+
if (input.voice !== undefined && !CODEX_REALTIME_VOICES.includes(input.voice)) {
|
|
243
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime voice is unsupported");
|
|
244
|
+
}
|
|
245
|
+
const initialItems = input.initialItems ?? [];
|
|
246
|
+
if (initialItems.length > CODEX_REALTIME_INITIAL_ITEMS_MAX_COUNT) {
|
|
247
|
+
throw new CodexRealtimeError(
|
|
248
|
+
"invalid_request",
|
|
249
|
+
`Codex realtime history exceeds ${CODEX_REALTIME_INITIAL_ITEMS_MAX_COUNT} items`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
let estimatedTokens = 0;
|
|
253
|
+
for (const item of initialItems) {
|
|
254
|
+
if (
|
|
255
|
+
(item.role !== "user" && item.role !== "developer" && item.role !== "assistant") ||
|
|
256
|
+
typeof item.text !== "string"
|
|
257
|
+
) {
|
|
258
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime history item is invalid");
|
|
259
|
+
}
|
|
260
|
+
const itemTokens = Math.ceil(new TextEncoder().encode(item.text).byteLength / 4);
|
|
261
|
+
if (itemTokens > CODEX_REALTIME_INITIAL_ITEMS_MAX_TOKENS) {
|
|
262
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime history item is too large");
|
|
263
|
+
}
|
|
264
|
+
estimatedTokens += itemTokens;
|
|
265
|
+
}
|
|
266
|
+
if (estimatedTokens > CODEX_REALTIME_INITIAL_ITEMS_MAX_TOKENS) {
|
|
267
|
+
throw new CodexRealtimeError("invalid_request", "Codex realtime history is too large");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function providerHttpError(status: number): CodexRealtimeError {
|
|
272
|
+
if (status === 401) {
|
|
273
|
+
return new CodexRealtimeError(
|
|
274
|
+
"authentication",
|
|
275
|
+
"Codex subscription rejected authentication",
|
|
276
|
+
status,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
if (status === 403) {
|
|
280
|
+
return new CodexRealtimeError(
|
|
281
|
+
"entitlement",
|
|
282
|
+
"Codex subscription lacks realtime entitlement",
|
|
283
|
+
status,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (status === 404) {
|
|
287
|
+
return new CodexRealtimeError(
|
|
288
|
+
"incompatible",
|
|
289
|
+
"Codex subscription realtime is unavailable",
|
|
290
|
+
status,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (status === 429) {
|
|
294
|
+
return new CodexRealtimeError("rate_limited", "Codex realtime is rate limited", status);
|
|
295
|
+
}
|
|
296
|
+
return new CodexRealtimeError("provider", "Codex realtime provider request failed", status);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function validRealtimeLocation(location: string): boolean {
|
|
300
|
+
const path = location.split("?", 1)[0] ?? "";
|
|
301
|
+
const segment = path.split("/").filter(Boolean).at(-1) ?? "";
|
|
302
|
+
return REALTIME_CALL_ID.test(segment);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isAudioSdp(sdp: string): boolean {
|
|
306
|
+
return /^v=0(?:\r?\n)/.test(sdp) && /(?:^|\r?\n)m=audio\s/m.test(sdp);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function readBoundedSdp(response: Response): Promise<string> {
|
|
310
|
+
const declared = Number(response.headers.get("content-length"));
|
|
311
|
+
if (Number.isFinite(declared) && declared > MAX_REALTIME_SDP_BYTES) {
|
|
312
|
+
await response.body?.cancel().catch(() => undefined);
|
|
313
|
+
throw new CodexRealtimeError(
|
|
314
|
+
"invalid_response",
|
|
315
|
+
"Codex realtime SDP answer is too large",
|
|
316
|
+
response.status,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
if (!response.body) return "";
|
|
320
|
+
const reader = response.body.getReader();
|
|
321
|
+
const chunks: Uint8Array[] = [];
|
|
322
|
+
let total = 0;
|
|
323
|
+
try {
|
|
324
|
+
while (true) {
|
|
325
|
+
const next = await reader.read();
|
|
326
|
+
if (next.done) break;
|
|
327
|
+
total += next.value.byteLength;
|
|
328
|
+
if (total > MAX_REALTIME_SDP_BYTES) {
|
|
329
|
+
await reader.cancel();
|
|
330
|
+
throw new CodexRealtimeError(
|
|
331
|
+
"invalid_response",
|
|
332
|
+
"Codex realtime SDP answer is too large",
|
|
333
|
+
response.status,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
chunks.push(next.value);
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
reader.releaseLock();
|
|
340
|
+
}
|
|
341
|
+
const bytes = new Uint8Array(total);
|
|
342
|
+
let offset = 0;
|
|
343
|
+
for (const chunk of chunks) {
|
|
344
|
+
bytes.set(chunk, offset);
|
|
345
|
+
offset += chunk.byteLength;
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
349
|
+
} catch {
|
|
350
|
+
throw new CodexRealtimeError(
|
|
351
|
+
"invalid_response",
|
|
352
|
+
"Codex realtime SDP answer was not valid UTF-8",
|
|
353
|
+
response.status,
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
}
|