pi-openai-codex-compat 0.0.1-alpha.0

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 (38) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/LICENSE +20 -0
  3. package/LICENSES/Apache-2.0.txt +201 -0
  4. package/LICENSES/pi-ai-MIT.txt +21 -0
  5. package/README.md +331 -0
  6. package/THIRD_PARTY_NOTICES.md +21 -0
  7. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +436 -0
  8. package/extensions/openai-codex-compat/apply-patch-engine.ts +1004 -0
  9. package/extensions/openai-codex-compat/apply-patch-render.ts +133 -0
  10. package/extensions/openai-codex-compat/apply-patch.ts +142 -0
  11. package/extensions/openai-codex-compat/codex-protocol.ts +598 -0
  12. package/extensions/openai-codex-compat/codex-provider.ts +740 -0
  13. package/extensions/openai-codex-compat/codex-stream.ts +444 -0
  14. package/extensions/openai-codex-compat/codex-tool-surface.ts +186 -0
  15. package/extensions/openai-codex-compat/codex-transport.ts +855 -0
  16. package/extensions/openai-codex-compat/compaction-checkpoint.ts +304 -0
  17. package/extensions/openai-codex-compat/config.ts +268 -0
  18. package/extensions/openai-codex-compat/footer.ts +99 -0
  19. package/extensions/openai-codex-compat/image-generation-render.ts +166 -0
  20. package/extensions/openai-codex-compat/image-generation.ts +355 -0
  21. package/extensions/openai-codex-compat/index.ts +65 -0
  22. package/extensions/openai-codex-compat/model-policy.ts +67 -0
  23. package/extensions/openai-codex-compat/namespaced-tools.ts +43 -0
  24. package/extensions/openai-codex-compat/native-history.ts +78 -0
  25. package/extensions/openai-codex-compat/remote-compaction.ts +198 -0
  26. package/extensions/openai-codex-compat/request-options.ts +121 -0
  27. package/extensions/openai-codex-compat/responses-replay.ts +33 -0
  28. package/extensions/openai-codex-compat/settings-pane.ts +298 -0
  29. package/extensions/openai-codex-compat/tool-runtime.ts +32 -0
  30. package/extensions/openai-codex-compat/tools.ts +70 -0
  31. package/extensions/openai-codex-compat/vendor/pi-ai/README.md +15 -0
  32. package/extensions/openai-codex-compat/vendor/pi-ai/openai-responses-serialization.ts +660 -0
  33. package/extensions/openai-codex-compat/web-run-description.txt +105 -0
  34. package/extensions/openai-codex-compat/web-run-output.ts +172 -0
  35. package/extensions/openai-codex-compat/web-run-render.ts +681 -0
  36. package/extensions/openai-codex-compat/web-run-schema.ts +301 -0
  37. package/extensions/openai-codex-compat/web-run.ts +164 -0
  38. package/package.json +63 -0
@@ -0,0 +1,855 @@
1
+ import type * as NodeOs from "node:os";
2
+ import type * as NodeZlib from "node:zlib";
3
+ import {
4
+ registerSessionResourceCleanup,
5
+ type Model,
6
+ type OpenAICodexResponsesOptions,
7
+ type ProviderEnv,
8
+ type ProviderHeaders,
9
+ } from "@earendil-works/pi-ai";
10
+ import { isObject, type JsonRecord } from "./codex-protocol.ts";
11
+ import { normalizeReplayItem, replayItemsEqual, stableResponsesJson } from "./responses-replay.ts";
12
+
13
+ /**
14
+ * Focused adaptation of @earendil-works/pi-ai@0.83.0
15
+ * src/api/openai-codex-responses.ts transport behavior.
16
+ */
17
+
18
+ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
19
+ const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
20
+ const DEFAULT_MAX_RETRIES = 0;
21
+ const BASE_DELAY_MS = 1_000;
22
+ const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
23
+ const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
24
+ const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1_000;
25
+ const SESSION_WEBSOCKET_MAX_AGE_MS = 55 * 60 * 1_000;
26
+
27
+ type ProcessWithBuiltinModules = typeof process & {
28
+ getBuiltinModule?: {
29
+ (id: "node:os"): typeof NodeOs;
30
+ (id: "node:zlib"): typeof NodeZlib;
31
+ };
32
+ };
33
+
34
+ type CodexTransportOptions = OpenAICodexResponsesOptions & {
35
+ env?: ProviderEnv;
36
+ };
37
+
38
+ export type CodexJsonRequestOptions = {
39
+ apiKey: string;
40
+ headers?: ProviderHeaders;
41
+ extraHeaders?: Record<string, string>;
42
+ signal?: AbortSignal;
43
+ fetch?: typeof fetch;
44
+ };
45
+
46
+ type WebSocketEventType = "open" | "message" | "error" | "close";
47
+ type WebSocketListener = (event: unknown) => void;
48
+
49
+ interface WebSocketLike {
50
+ readonly readyState?: number;
51
+ close(code?: number, reason?: string): void;
52
+ send(data: string): void;
53
+ addEventListener(type: WebSocketEventType, listener: WebSocketListener): void;
54
+ removeEventListener(type: WebSocketEventType, listener: WebSocketListener): void;
55
+ }
56
+
57
+ type WebSocketConstructor = new (
58
+ url: string,
59
+ protocols?: string | string[] | { headers?: Record<string, string> },
60
+ ) => WebSocketLike;
61
+
62
+ type CachedWebSocket = {
63
+ socket: WebSocketLike;
64
+ busy: boolean;
65
+ createdAt: number;
66
+ idleTimer?: ReturnType<typeof setTimeout>;
67
+ continuation?: {
68
+ lastRequestBody: JsonRecord;
69
+ lastResponseId: string;
70
+ lastResponseItems: JsonRecord[];
71
+ };
72
+ };
73
+
74
+ const websocketSessions = new Map<string, CachedWebSocket>();
75
+ const websocketFallbackSessions = new Set<string>();
76
+
77
+ class CodexResponseError extends Error {}
78
+
79
+ function nodeOs(): typeof NodeOs | undefined {
80
+ const currentProcess = process as ProcessWithBuiltinModules;
81
+ return currentProcess.getBuiltinModule?.("node:os");
82
+ }
83
+
84
+ function nodeZlib(): typeof NodeZlib | undefined {
85
+ const currentProcess = process as ProcessWithBuiltinModules;
86
+ return currentProcess.getBuiltinModule?.("node:zlib");
87
+ }
88
+
89
+ function explain(error: unknown): string {
90
+ return error instanceof Error ? error.message : String(error);
91
+ }
92
+
93
+ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
94
+ return new Promise((resolve, reject) => {
95
+ if (signal?.aborted) {
96
+ reject(new Error("Request was aborted"));
97
+ return;
98
+ }
99
+ const timer = setTimeout(() => {
100
+ signal?.removeEventListener("abort", onAbort);
101
+ resolve();
102
+ }, milliseconds);
103
+ const onAbort = () => {
104
+ clearTimeout(timer);
105
+ reject(new Error("Request was aborted"));
106
+ };
107
+ signal?.addEventListener("abort", onAbort, { once: true });
108
+ });
109
+ }
110
+
111
+ function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
112
+ signal?: AbortSignal;
113
+ cleanup(): void;
114
+ } {
115
+ const active = signals.filter((signal): signal is AbortSignal => signal !== undefined);
116
+ if (active.length === 0) return { cleanup() {} };
117
+ if (active.length === 1) {
118
+ const signal = active[0];
119
+ return signal ? { signal, cleanup() {} } : { cleanup() {} };
120
+ }
121
+
122
+ const controller = new AbortController();
123
+ const listeners: Array<{ signal: AbortSignal; listener: () => void }> = [];
124
+ for (const signal of active) {
125
+ if (signal.aborted) {
126
+ controller.abort(signal.reason);
127
+ break;
128
+ }
129
+ const listener = () => controller.abort(signal.reason);
130
+ signal.addEventListener("abort", listener, { once: true });
131
+ listeners.push({ signal, listener });
132
+ }
133
+ return {
134
+ signal: controller.signal,
135
+ cleanup() {
136
+ for (const entry of listeners) {
137
+ entry.signal.removeEventListener("abort", entry.listener);
138
+ }
139
+ },
140
+ };
141
+ }
142
+
143
+ function headersToRecord(headers: Headers): Record<string, string> {
144
+ return Object.fromEntries(headers.entries());
145
+ }
146
+
147
+ function extractAccountId(token: string): string {
148
+ try {
149
+ const parts = token.split(".");
150
+ if (parts.length !== 3) throw new Error("Invalid token");
151
+ const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonRecord;
152
+ const authentication = payload["https://api.openai.com/auth"];
153
+ if (!isObject(authentication) || typeof authentication["chatgpt_account_id"] !== "string") {
154
+ throw new Error("No account ID");
155
+ }
156
+ return authentication["chatgpt_account_id"];
157
+ } catch {
158
+ throw new Error("Failed to extract accountId from token");
159
+ }
160
+ }
161
+
162
+ function resolveCodexUrl(baseUrl?: string): string {
163
+ const raw = baseUrl?.trim() || DEFAULT_CODEX_BASE_URL;
164
+ const normalized = raw.replace(/\/+$/, "");
165
+ if (normalized.endsWith("/codex/responses")) return normalized;
166
+ if (normalized.endsWith("/codex")) return `${normalized}/responses`;
167
+ return `${normalized}/codex/responses`;
168
+ }
169
+
170
+ export function resolveCodexApiUrl(baseUrl: string | undefined, path: string): string {
171
+ const normalizedPath = path.replace(/^\/+/, "");
172
+ const responsesUrl = resolveCodexUrl(baseUrl);
173
+ return `${responsesUrl.slice(0, -"/responses".length)}/${normalizedPath}`;
174
+ }
175
+
176
+ function resolveCodexWebSocketUrl(baseUrl?: string): string {
177
+ const url = new URL(resolveCodexUrl(baseUrl));
178
+ if (url.protocol === "https:") url.protocol = "wss:";
179
+ if (url.protocol === "http:") url.protocol = "ws:";
180
+ return url.toString();
181
+ }
182
+
183
+ function baseHeaders(
184
+ modelHeaders: Record<string, string> | undefined,
185
+ additionalHeaders: ProviderHeaders | undefined,
186
+ accountId: string,
187
+ token: string,
188
+ ): Headers {
189
+ const headers = new Headers(modelHeaders);
190
+ for (const [name, value] of Object.entries(additionalHeaders ?? {})) {
191
+ if (value === null) headers.delete(name);
192
+ else headers.set(name, value);
193
+ }
194
+ headers.set("Authorization", `Bearer ${token}`);
195
+ headers.set("chatgpt-account-id", accountId);
196
+ headers.set("originator", "pi");
197
+ const os = nodeOs();
198
+ headers.set(
199
+ "User-Agent",
200
+ os ? `pi (${os.platform()} ${os.release()}; ${os.arch()})` : "pi (browser)",
201
+ );
202
+ return headers;
203
+ }
204
+
205
+ function sseHeaders(
206
+ modelHeaders: Record<string, string> | undefined,
207
+ additionalHeaders: ProviderHeaders | undefined,
208
+ accountId: string,
209
+ token: string,
210
+ sessionId: string | undefined,
211
+ ): Headers {
212
+ const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
213
+ headers.set("OpenAI-Beta", "responses=experimental");
214
+ headers.set("accept", "text/event-stream");
215
+ headers.set("content-type", "application/json");
216
+ if (sessionId) {
217
+ headers.set("session-id", sessionId);
218
+ headers.set("x-client-request-id", sessionId);
219
+ }
220
+ return headers;
221
+ }
222
+
223
+ function jsonHeaders(
224
+ modelHeaders: Record<string, string> | undefined,
225
+ additionalHeaders: ProviderHeaders | undefined,
226
+ extraHeaders: Record<string, string> | undefined,
227
+ accountId: string,
228
+ token: string,
229
+ ): Headers {
230
+ const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
231
+ for (const [name, value] of Object.entries(extraHeaders ?? {})) {
232
+ headers.set(name, value);
233
+ }
234
+ headers.set("accept", "application/json");
235
+ headers.set("content-type", "application/json");
236
+ return headers;
237
+ }
238
+
239
+ function websocketHeaders(
240
+ modelHeaders: Record<string, string> | undefined,
241
+ additionalHeaders: ProviderHeaders | undefined,
242
+ accountId: string,
243
+ token: string,
244
+ requestId: string,
245
+ ): Headers {
246
+ const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
247
+ headers.delete("accept");
248
+ headers.delete("content-type");
249
+ headers.delete("OpenAI-Beta");
250
+ headers.delete("openai-beta");
251
+ headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
252
+ headers.set("x-client-request-id", requestId);
253
+ headers.set("session-id", requestId);
254
+ return headers;
255
+ }
256
+
257
+ function compressBody(body: string): Uint8Array | undefined {
258
+ const zlib = nodeZlib();
259
+ if (!zlib || typeof zlib.zstdCompressSync !== "function") return undefined;
260
+ try {
261
+ const compressed = zlib.zstdCompressSync(body, {
262
+ params: { [zlib.constants.ZSTD_c_compressionLevel]: REQUEST_COMPRESSION_ZSTD_LEVEL },
263
+ });
264
+ return new Uint8Array(compressed.buffer, compressed.byteOffset, compressed.byteLength);
265
+ } catch {
266
+ return undefined;
267
+ }
268
+ }
269
+
270
+ function isRetryable(status: number, text: string): boolean {
271
+ if (
272
+ status === 429 &&
273
+ /usage limit|insufficient_quota|out of budget|quota exceeded|billing/i.test(text)
274
+ ) {
275
+ return false;
276
+ }
277
+ return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
278
+ }
279
+
280
+ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerator<JsonRecord> {
281
+ if (!response.body) throw new Error("No response body");
282
+ const reader = response.body.getReader();
283
+ const decoder = new TextDecoder();
284
+ let buffer = "";
285
+ const onAbort = () => void reader.cancel().catch(() => {});
286
+ signal?.addEventListener("abort", onAbort, { once: true });
287
+
288
+ try {
289
+ while (true) {
290
+ if (signal?.aborted) throw new Error("Request was aborted");
291
+ const { done, value } = await reader.read();
292
+ if (done) break;
293
+ buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
294
+ let boundary = buffer.indexOf("\n\n");
295
+ while (boundary !== -1) {
296
+ const block = buffer.slice(0, boundary);
297
+ buffer = buffer.slice(boundary + 2);
298
+ const data = block
299
+ .split("\n")
300
+ .filter((line) => line.startsWith("data:"))
301
+ .map((line) => line.slice(5).trim())
302
+ .join("\n")
303
+ .trim();
304
+ if (data && data !== "[DONE]") {
305
+ const parsed = JSON.parse(data) as unknown;
306
+ if (!isObject(parsed)) throw new Error("Invalid Codex SSE event");
307
+ yield parsed;
308
+ }
309
+ boundary = buffer.indexOf("\n\n");
310
+ }
311
+ }
312
+ } finally {
313
+ signal?.removeEventListener("abort", onAbort);
314
+ await reader.cancel().catch(() => {});
315
+ reader.releaseLock();
316
+ }
317
+ }
318
+
319
+ function websocketConstructor(): WebSocketConstructor | undefined {
320
+ const candidate = (globalThis as { WebSocket?: unknown }).WebSocket;
321
+ return typeof candidate === "function" ? (candidate as WebSocketConstructor) : undefined;
322
+ }
323
+
324
+ function closeSocket(socket: WebSocketLike, reason = "done"): void {
325
+ try {
326
+ socket.close(1_000, reason);
327
+ } catch {}
328
+ }
329
+
330
+ function socketReusable(socket: WebSocketLike): boolean {
331
+ return socket.readyState === undefined || socket.readyState === 1;
332
+ }
333
+
334
+ function scheduleSocketExpiry(sessionId: string, entry: CachedWebSocket): void {
335
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
336
+ entry.idleTimer = setTimeout(() => {
337
+ if (entry.busy) return;
338
+ closeSocket(entry.socket, "idle_timeout");
339
+ websocketSessions.delete(sessionId);
340
+ }, SESSION_WEBSOCKET_CACHE_TTL_MS);
341
+ }
342
+
343
+ async function connectWebSocket(
344
+ url: string,
345
+ headers: Headers,
346
+ signal: AbortSignal | undefined,
347
+ timeoutMs: number,
348
+ ): Promise<WebSocketLike> {
349
+ const WebSocketClass = websocketConstructor();
350
+ if (!WebSocketClass) throw new Error("WebSocket transport is unavailable");
351
+ const requestHeaders = headersToRecord(headers);
352
+ delete requestHeaders["OpenAI-Beta"];
353
+
354
+ return new Promise((resolve, reject) => {
355
+ let settled = false;
356
+ let socket: WebSocketLike;
357
+ let timer: ReturnType<typeof setTimeout> | undefined;
358
+
359
+ const cleanup = () => {
360
+ if (timer) clearTimeout(timer);
361
+ socket.removeEventListener("open", onOpen);
362
+ socket.removeEventListener("error", onError);
363
+ socket.removeEventListener("close", onClose);
364
+ signal?.removeEventListener("abort", onAbort);
365
+ };
366
+ const fail = (error: Error) => {
367
+ if (settled) return;
368
+ settled = true;
369
+ cleanup();
370
+ closeSocket(socket, "connect_failure");
371
+ reject(error);
372
+ };
373
+ const onOpen = () => {
374
+ if (settled) return;
375
+ settled = true;
376
+ cleanup();
377
+ resolve(socket);
378
+ };
379
+ const onError = (event: unknown) => fail(new Error(`WebSocket error: ${explain(event)}`));
380
+ const onClose = (event: unknown) =>
381
+ fail(new Error(`WebSocket closed during connect: ${explain(event)}`));
382
+ const onAbort = () => fail(new Error("Request was aborted"));
383
+
384
+ try {
385
+ socket = new WebSocketClass(url, { headers: requestHeaders });
386
+ } catch (error) {
387
+ reject(error);
388
+ return;
389
+ }
390
+ socket.addEventListener("open", onOpen);
391
+ socket.addEventListener("error", onError);
392
+ socket.addEventListener("close", onClose);
393
+ signal?.addEventListener("abort", onAbort, { once: true });
394
+ timer = setTimeout(
395
+ () => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)),
396
+ timeoutMs,
397
+ );
398
+ if (signal?.aborted) onAbort();
399
+ });
400
+ }
401
+
402
+ async function acquireWebSocket(
403
+ url: string,
404
+ headers: Headers,
405
+ sessionId: string | undefined,
406
+ signal: AbortSignal | undefined,
407
+ timeoutMs: number,
408
+ ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocket; release(keep: boolean): void }> {
409
+ if (sessionId) {
410
+ const cached = websocketSessions.get(sessionId);
411
+ if (cached?.idleTimer) {
412
+ clearTimeout(cached.idleTimer);
413
+ delete cached.idleTimer;
414
+ }
415
+ const expired = cached && Date.now() - cached.createdAt >= SESSION_WEBSOCKET_MAX_AGE_MS;
416
+ if (cached && !cached.busy && !expired && socketReusable(cached.socket)) {
417
+ cached.busy = true;
418
+ return {
419
+ socket: cached.socket,
420
+ entry: cached,
421
+ release(keep) {
422
+ if (!keep || !socketReusable(cached.socket)) {
423
+ closeSocket(cached.socket);
424
+ websocketSessions.delete(sessionId);
425
+ return;
426
+ }
427
+ cached.busy = false;
428
+ scheduleSocketExpiry(sessionId, cached);
429
+ },
430
+ };
431
+ }
432
+ if (cached && !cached.busy) {
433
+ closeSocket(cached.socket);
434
+ websocketSessions.delete(sessionId);
435
+ }
436
+ }
437
+
438
+ const socket = await connectWebSocket(url, headers, signal, timeoutMs);
439
+ if (!sessionId) {
440
+ return { socket, release: () => closeSocket(socket) };
441
+ }
442
+ const entry: CachedWebSocket = { socket, busy: true, createdAt: Date.now() };
443
+ websocketSessions.set(sessionId, entry);
444
+ return {
445
+ socket,
446
+ entry,
447
+ release(keep) {
448
+ if (!keep || !socketReusable(socket)) {
449
+ closeSocket(socket);
450
+ websocketSessions.delete(sessionId);
451
+ return;
452
+ }
453
+ entry.busy = false;
454
+ scheduleSocketExpiry(sessionId, entry);
455
+ },
456
+ };
457
+ }
458
+
459
+ function requestWithoutHistory(body: JsonRecord): JsonRecord {
460
+ const result = structuredClone(body);
461
+ delete result.input;
462
+ delete result.previous_response_id;
463
+ return result;
464
+ }
465
+
466
+ function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord {
467
+ const continuation = entry.continuation;
468
+ if (!continuation) return body;
469
+ if (
470
+ stableResponsesJson(requestWithoutHistory(body)) !==
471
+ stableResponsesJson(requestWithoutHistory(continuation.lastRequestBody))
472
+ ) {
473
+ delete entry.continuation;
474
+ return body;
475
+ }
476
+
477
+ const currentInput = Array.isArray(body.input) ? body.input.filter(isObject) : [];
478
+ const previousInput = Array.isArray(continuation.lastRequestBody.input)
479
+ ? continuation.lastRequestBody.input.filter(isObject)
480
+ : [];
481
+ const baseline = [...previousInput, ...continuation.lastResponseItems];
482
+ if (
483
+ currentInput.length < baseline.length ||
484
+ !replayItemsEqual(currentInput.slice(0, baseline.length), baseline)
485
+ ) {
486
+ delete entry.continuation;
487
+ return body;
488
+ }
489
+
490
+ return {
491
+ ...body,
492
+ previous_response_id: continuation.lastResponseId,
493
+ input: currentInput.slice(baseline.length),
494
+ };
495
+ }
496
+
497
+ async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
498
+ if (typeof data === "string") return data;
499
+ if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
500
+ if (ArrayBuffer.isView(data)) {
501
+ return new TextDecoder().decode(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
502
+ }
503
+ if (isObject(data) && typeof data["arrayBuffer"] === "function") {
504
+ const arrayBuffer = await (data["arrayBuffer"] as () => Promise<ArrayBuffer>)();
505
+ return new TextDecoder().decode(new Uint8Array(arrayBuffer));
506
+ }
507
+ return undefined;
508
+ }
509
+
510
+ async function* parseWebSocket(
511
+ socket: WebSocketLike,
512
+ signal: AbortSignal | undefined,
513
+ timeoutMs: number | undefined,
514
+ ): AsyncGenerator<JsonRecord> {
515
+ const queue: JsonRecord[] = [];
516
+ let wake: (() => void) | undefined;
517
+ let done = false;
518
+ let failure: Error | undefined;
519
+ let terminal = false;
520
+
521
+ const notify = () => {
522
+ const current = wake;
523
+ wake = undefined;
524
+ current?.();
525
+ };
526
+ const onMessage = (event: unknown) => {
527
+ void (async () => {
528
+ try {
529
+ if (!isObject(event)) return;
530
+ const text = await decodeWebSocketData(event["data"]);
531
+ if (!text) return;
532
+ const parsed = JSON.parse(text) as unknown;
533
+ if (!isObject(parsed)) throw new Error("Invalid WebSocket event");
534
+ const type = parsed.type;
535
+ if (
536
+ type === "response.completed" ||
537
+ type === "response.done" ||
538
+ type === "response.incomplete"
539
+ ) {
540
+ terminal = true;
541
+ done = true;
542
+ }
543
+ queue.push(parsed);
544
+ notify();
545
+ } catch (error) {
546
+ failure = error instanceof Error ? error : new Error(String(error));
547
+ done = true;
548
+ notify();
549
+ }
550
+ })();
551
+ };
552
+ const onError = (event: unknown) => {
553
+ failure = new Error(`WebSocket error: ${explain(event)}`);
554
+ done = true;
555
+ notify();
556
+ };
557
+ const onClose = (event: unknown) => {
558
+ if (!terminal) failure = new Error(`WebSocket closed: ${explain(event)}`);
559
+ done = true;
560
+ notify();
561
+ };
562
+ const onAbort = () => {
563
+ failure = new Error("Request was aborted");
564
+ done = true;
565
+ notify();
566
+ };
567
+
568
+ socket.addEventListener("message", onMessage);
569
+ socket.addEventListener("error", onError);
570
+ socket.addEventListener("close", onClose);
571
+ signal?.addEventListener("abort", onAbort, { once: true });
572
+ try {
573
+ while (true) {
574
+ if (queue.length > 0) {
575
+ yield queue.shift()!;
576
+ continue;
577
+ }
578
+ if (done) break;
579
+ await new Promise<void>((resolve, reject) => {
580
+ wake = resolve;
581
+ if (timeoutMs !== undefined && timeoutMs > 0) {
582
+ const timer = setTimeout(
583
+ () => reject(new Error(`WebSocket idle timeout after ${timeoutMs}ms`)),
584
+ timeoutMs,
585
+ );
586
+ const priorWake = wake;
587
+ wake = () => {
588
+ clearTimeout(timer);
589
+ priorWake();
590
+ };
591
+ }
592
+ });
593
+ }
594
+ if (failure) throw failure;
595
+ if (!terminal) throw new Error("WebSocket ended without a terminal response");
596
+ } finally {
597
+ socket.removeEventListener("message", onMessage);
598
+ socket.removeEventListener("error", onError);
599
+ socket.removeEventListener("close", onClose);
600
+ signal?.removeEventListener("abort", onAbort);
601
+ }
602
+ }
603
+
604
+ function normalizeEvent(event: JsonRecord): JsonRecord {
605
+ const type = event.type;
606
+ if (type === "error") {
607
+ const nested = isObject(event["error"]) ? event["error"] : undefined;
608
+ throw new CodexResponseError(
609
+ typeof event["message"] === "string"
610
+ ? event["message"]
611
+ : typeof nested?.["message"] === "string"
612
+ ? nested["message"]
613
+ : "Codex request failed",
614
+ );
615
+ }
616
+ if (type === "response.failed") {
617
+ const response = isObject(event.response) ? event.response : undefined;
618
+ const error = isObject(response?.["error"]) ? response["error"] : undefined;
619
+ throw new CodexResponseError(
620
+ typeof error?.["message"] === "string" ? error["message"] : "Codex response failed",
621
+ );
622
+ }
623
+ if (type === "response.done") {
624
+ return { ...event, type: "response.completed" };
625
+ }
626
+ return event;
627
+ }
628
+
629
+ async function* requestSse(
630
+ model: Model<any>,
631
+ body: JsonRecord,
632
+ options: CodexTransportOptions,
633
+ headers: Headers,
634
+ ): AsyncGenerator<JsonRecord> {
635
+ const bodyJson = JSON.stringify(body);
636
+ const compressed = compressBody(bodyJson);
637
+ if (compressed) headers.set("content-encoding", "zstd");
638
+ const requestBody = compressed ?? bodyJson;
639
+ const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
640
+
641
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
642
+ const timeoutSignal =
643
+ options.timeoutMs !== undefined && options.timeoutMs > 0
644
+ ? AbortSignal.timeout(options.timeoutMs)
645
+ : undefined;
646
+ const combined = combineAbortSignals([options.signal, timeoutSignal]);
647
+ let response: Response;
648
+ try {
649
+ try {
650
+ response = await (options.fetch ?? globalThis.fetch)(resolveCodexUrl(model.baseUrl), {
651
+ method: "POST",
652
+ headers,
653
+ body: requestBody,
654
+ ...(combined.signal ? { signal: combined.signal } : {}),
655
+ });
656
+ } catch (error) {
657
+ if (attempt < maxRetries && !options.signal?.aborted) {
658
+ await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
659
+ continue;
660
+ }
661
+ throw error;
662
+ }
663
+ } finally {
664
+ combined.cleanup();
665
+ }
666
+ await options.onResponse?.(
667
+ { status: response.status, headers: headersToRecord(response.headers) },
668
+ model,
669
+ );
670
+ if (!response.ok) {
671
+ const errorText = await response.text();
672
+ if (attempt < maxRetries && isRetryable(response.status, errorText)) {
673
+ await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
674
+ continue;
675
+ }
676
+ throw new Error(errorText || `Codex request failed with status ${response.status}`);
677
+ }
678
+ for await (const event of parseSse(response, options.signal)) {
679
+ yield normalizeEvent(event);
680
+ }
681
+ return;
682
+ }
683
+ }
684
+
685
+ async function* requestWebSocket(
686
+ model: Model<any>,
687
+ body: JsonRecord,
688
+ options: CodexTransportOptions,
689
+ headers: Headers,
690
+ sessionId: string | undefined,
691
+ ): AsyncGenerator<JsonRecord> {
692
+ const acquired = await acquireWebSocket(
693
+ resolveCodexWebSocketUrl(model.baseUrl),
694
+ headers,
695
+ sessionId,
696
+ options.signal,
697
+ options.websocketConnectTimeoutMs ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,
698
+ );
699
+ let keep = true;
700
+ try {
701
+ const useContinuation =
702
+ options.transport === "auto" || options.transport === "websocket-cached";
703
+ const requestBody =
704
+ useContinuation && acquired.entry ? cachedRequestBody(acquired.entry, body) : body;
705
+ acquired.socket.send(JSON.stringify({ type: "response.create", ...requestBody }));
706
+ const responseItems: JsonRecord[] = [];
707
+ let responseId: string | undefined;
708
+ for await (const event of parseWebSocket(acquired.socket, options.signal, options.timeoutMs)) {
709
+ if (event.type === "response.output_item.done" && isObject(event.item)) {
710
+ responseItems.push(structuredClone(event.item));
711
+ }
712
+ if (
713
+ (event.type === "response.completed" ||
714
+ event.type === "response.done" ||
715
+ event.type === "response.incomplete") &&
716
+ isObject(event.response)
717
+ ) {
718
+ if (typeof event.response.id === "string") responseId = event.response.id;
719
+ if (Array.isArray(event.response["output"])) {
720
+ const terminalItems = event.response["output"].filter(isObject);
721
+ if (terminalItems.length > 0) {
722
+ responseItems.splice(
723
+ 0,
724
+ responseItems.length,
725
+ ...terminalItems.map((item) => structuredClone(item)),
726
+ );
727
+ }
728
+ }
729
+ }
730
+ yield normalizeEvent(event);
731
+ }
732
+ if (useContinuation && acquired.entry && responseId) {
733
+ acquired.entry.continuation = {
734
+ lastRequestBody: structuredClone(body),
735
+ lastResponseId: responseId,
736
+ lastResponseItems: responseItems.map(normalizeReplayItem),
737
+ };
738
+ }
739
+ } catch (error) {
740
+ if (acquired.entry) delete acquired.entry.continuation;
741
+ keep = false;
742
+ throw error;
743
+ } finally {
744
+ acquired.release(keep);
745
+ }
746
+ }
747
+
748
+ export async function requestCodexJson(
749
+ model: Model<any>,
750
+ path: string,
751
+ body: JsonRecord,
752
+ options: CodexJsonRequestOptions,
753
+ ): Promise<unknown> {
754
+ const headers = jsonHeaders(
755
+ model.headers,
756
+ options.headers,
757
+ options.extraHeaders,
758
+ extractAccountId(options.apiKey),
759
+ options.apiKey,
760
+ );
761
+ const response = await (options.fetch ?? globalThis.fetch)(
762
+ resolveCodexApiUrl(model.baseUrl, path),
763
+ {
764
+ method: "POST",
765
+ headers,
766
+ body: JSON.stringify(body),
767
+ ...(options.signal ? { signal: options.signal } : {}),
768
+ },
769
+ );
770
+ const responseText = await response.text();
771
+ if (!response.ok) {
772
+ throw new Error(responseText || `Codex request failed with status ${response.status}`);
773
+ }
774
+ try {
775
+ return JSON.parse(responseText) as unknown;
776
+ } catch (error) {
777
+ throw new Error(
778
+ `Codex returned invalid JSON from ${path}: ${
779
+ error instanceof Error ? error.message : String(error)
780
+ }`,
781
+ );
782
+ }
783
+ }
784
+
785
+ export class CodexTransport {
786
+ async *request(
787
+ model: Model<any>,
788
+ body: JsonRecord,
789
+ options: CodexTransportOptions,
790
+ ): AsyncGenerator<JsonRecord> {
791
+ if (!options.apiKey) throw new Error(`No API key for provider: ${model.provider}`);
792
+ const accountId = extractAccountId(options.apiKey);
793
+ const sessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
794
+ const transport = options.transport ?? "auto";
795
+
796
+ const websocketDisabled =
797
+ transport === "auto" && sessionId !== undefined && websocketFallbackSessions.has(sessionId);
798
+ if (transport !== "sse" && !websocketDisabled) {
799
+ const headers = websocketHeaders(
800
+ model.headers,
801
+ options.headers,
802
+ accountId,
803
+ options.apiKey,
804
+ sessionId ?? crypto.randomUUID(),
805
+ );
806
+ let emitted = false;
807
+ try {
808
+ for await (const event of requestWebSocket(model, body, options, headers, sessionId)) {
809
+ emitted = true;
810
+ yield event;
811
+ }
812
+ return;
813
+ } catch (error) {
814
+ if (
815
+ emitted ||
816
+ error instanceof CodexResponseError ||
817
+ transport === "websocket" ||
818
+ transport === "websocket-cached"
819
+ ) {
820
+ throw error;
821
+ }
822
+ if (sessionId) websocketFallbackSessions.add(sessionId);
823
+ }
824
+ }
825
+
826
+ const headers = sseHeaders(
827
+ model.headers,
828
+ options.headers,
829
+ accountId,
830
+ options.apiKey,
831
+ sessionId,
832
+ );
833
+ yield* requestSse(model, body, options, headers);
834
+ }
835
+
836
+ close(sessionId?: string): void {
837
+ if (sessionId) {
838
+ const entry = websocketSessions.get(sessionId);
839
+ if (entry?.idleTimer) clearTimeout(entry.idleTimer);
840
+ if (entry) closeSocket(entry.socket, "session_shutdown");
841
+ websocketSessions.delete(sessionId);
842
+ websocketFallbackSessions.delete(sessionId);
843
+ return;
844
+ }
845
+ for (const entry of websocketSessions.values()) {
846
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
847
+ closeSocket(entry.socket, "shutdown");
848
+ }
849
+ websocketSessions.clear();
850
+ websocketFallbackSessions.clear();
851
+ }
852
+ }
853
+
854
+ const transportCleanup = new CodexTransport();
855
+ registerSessionResourceCleanup((sessionId) => transportCleanup.close(sessionId));