pi-openai-codex-compat 0.0.2 → 0.0.3

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.
@@ -6,9 +6,11 @@ import {
6
6
  type OpenAICodexResponsesOptions,
7
7
  type ProviderEnv,
8
8
  type ProviderHeaders,
9
+ uuidv7,
9
10
  } from "@earendil-works/pi-ai";
11
+ import { codexCacheKey } from "./codex-cache-key.ts";
10
12
  import { isObject, type JsonRecord } from "./codex-protocol.ts";
11
- import { normalizeReplayItem, replayItemsEqual, stableResponsesJson } from "./responses-replay.ts";
13
+ import { normalizeReplayItem } from "./responses-replay.ts";
12
14
 
13
15
  /**
14
16
  * Focused adaptation of @earendil-works/pi-ai@0.83.0
@@ -19,10 +21,13 @@ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
19
21
  const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
20
22
  const DEFAULT_MAX_RETRIES = 0;
21
23
  const BASE_DELAY_MS = 1_000;
24
+ const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
22
25
  const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
23
26
  const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
24
27
  const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1_000;
25
28
  const SESSION_WEBSOCKET_MAX_AGE_MS = 55 * 60 * 1_000;
29
+ const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
30
+ const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
26
31
 
27
32
  type ProcessWithBuiltinModules = typeof process & {
28
33
  getBuiltinModule?: {
@@ -31,10 +36,6 @@ type ProcessWithBuiltinModules = typeof process & {
31
36
  };
32
37
  };
33
38
 
34
- type CodexTransportOptions = OpenAICodexResponsesOptions & {
35
- env?: ProviderEnv;
36
- };
37
-
38
39
  export type CodexJsonRequestOptions = {
39
40
  apiKey: string;
40
41
  headers?: ProviderHeaders;
@@ -43,6 +44,60 @@ export type CodexJsonRequestOptions = {
43
44
  fetch?: typeof fetch;
44
45
  };
45
46
 
47
+ export type CodexTransportDiagnostic = {
48
+ type: "provider_transport_failure";
49
+ timestamp: number;
50
+ error: {
51
+ name?: string;
52
+ message: string;
53
+ stack?: string;
54
+ code?: string | number;
55
+ };
56
+ details: {
57
+ configuredTransport: string;
58
+ fallbackTransport?: "sse";
59
+ eventsEmitted: boolean;
60
+ phase: "before_message_stream_start" | "after_message_stream_start";
61
+ requestBytes: number;
62
+ };
63
+ };
64
+
65
+ export type CodexContinuationHandle = {
66
+ readonly responseId: string;
67
+ replaceResponseItems(items: readonly JsonRecord[]): boolean;
68
+ };
69
+
70
+ export type CodexWebSocketResponseHandle = {
71
+ discard(): boolean;
72
+ failParsing(error: unknown): boolean;
73
+ };
74
+
75
+ export interface OpenAICodexWebSocketDebugStats {
76
+ requests: number;
77
+ connectionsCreated: number;
78
+ connectionsReused: number;
79
+ cachedContextRequests: number;
80
+ storeTrueRequests: number;
81
+ fullContextRequests: number;
82
+ deltaRequests: number;
83
+ lastInputItems: number;
84
+ lastDeltaInputItems?: number;
85
+ lastPreviousResponseId?: string;
86
+ websocketFailures: number;
87
+ sseFallbacks: number;
88
+ websocketFallbackActive?: boolean;
89
+ lastWebSocketError?: string;
90
+ }
91
+
92
+ type CodexTransportOptions = OpenAICodexResponsesOptions & {
93
+ accountId?: string;
94
+ env?: ProviderEnv;
95
+ onContinuationReady?(handle: CodexContinuationHandle): void;
96
+ onWebSocketResponseHandle?(handle: CodexWebSocketResponseHandle): void;
97
+ onTransportStart?(): void;
98
+ onTransportDiagnostic?(diagnostic: CodexTransportDiagnostic): void;
99
+ };
100
+
46
101
  type WebSocketEventType = "open" | "message" | "error" | "close";
47
102
  type WebSocketListener = (event: unknown) => void;
48
103
 
@@ -71,10 +126,132 @@ type CachedWebSocket = {
71
126
  };
72
127
  };
73
128
 
74
- const websocketSessions = new Map<string, CachedWebSocket>();
129
+ const websocketSessions = new Map<string, Map<string, CachedWebSocket>>();
75
130
  const websocketFallbackSessions = new Set<string>();
131
+ const websocketDebugStats = new Map<string, OpenAICodexWebSocketDebugStats>();
132
+
133
+ function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats {
134
+ let stats = websocketDebugStats.get(sessionId);
135
+ if (!stats) {
136
+ stats = {
137
+ requests: 0,
138
+ connectionsCreated: 0,
139
+ connectionsReused: 0,
140
+ cachedContextRequests: 0,
141
+ storeTrueRequests: 0,
142
+ fullContextRequests: 0,
143
+ deltaRequests: 0,
144
+ lastInputItems: 0,
145
+ websocketFailures: 0,
146
+ sseFallbacks: 0,
147
+ };
148
+ websocketDebugStats.set(sessionId, stats);
149
+ }
150
+ return stats;
151
+ }
152
+
153
+ export function getOpenAICodexWebSocketDebugStats(
154
+ sessionId: string,
155
+ ): OpenAICodexWebSocketDebugStats | undefined {
156
+ const stats = websocketDebugStats.get(sessionId);
157
+ return stats ? { ...stats } : undefined;
158
+ }
159
+
160
+ export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void {
161
+ if (sessionId) {
162
+ websocketDebugStats.delete(sessionId);
163
+ websocketFallbackSessions.delete(sessionId);
164
+ return;
165
+ }
166
+ websocketDebugStats.clear();
167
+ websocketFallbackSessions.clear();
168
+ }
76
169
 
77
- class CodexResponseError extends Error {}
170
+ export function closeOpenAICodexWebSocketSessions(sessionId?: string): void {
171
+ const closeEntry = (entry: CachedWebSocket): void => {
172
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
173
+ closeSocket(entry.socket, "debug_close");
174
+ };
175
+ if (sessionId) {
176
+ for (const entry of websocketSessions.get(sessionId)?.values() ?? []) closeEntry(entry);
177
+ websocketSessions.delete(sessionId);
178
+ websocketFallbackSessions.delete(sessionId);
179
+ const stats = websocketDebugStats.get(sessionId);
180
+ if (stats?.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
181
+ return;
182
+ }
183
+ for (const accountEntries of websocketSessions.values()) {
184
+ for (const entry of accountEntries.values()) closeEntry(entry);
185
+ }
186
+ websocketSessions.clear();
187
+ websocketFallbackSessions.clear();
188
+ for (const stats of websocketDebugStats.values()) {
189
+ if (stats.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
190
+ }
191
+ }
192
+
193
+ function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean {
194
+ return sessionId ? websocketFallbackSessions.has(sessionId) : false;
195
+ }
196
+
197
+ function recordWebSocketSseFallback(sessionId: string | undefined): void {
198
+ if (!sessionId) return;
199
+ const stats = getOrCreateWebSocketDebugStats(sessionId);
200
+ stats.sseFallbacks += 1;
201
+ stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId);
202
+ }
203
+
204
+ function recordWebSocketFailure(sessionId: string | undefined, error: unknown): void {
205
+ if (!sessionId) return;
206
+ websocketFallbackSessions.add(sessionId);
207
+ const stats = getOrCreateWebSocketDebugStats(sessionId);
208
+ stats.websocketFailures += 1;
209
+ stats.lastWebSocketError = thrownMessage(error);
210
+ stats.websocketFallbackActive = true;
211
+ }
212
+
213
+ class CodexApiError extends Error {
214
+ readonly code: string | undefined;
215
+ readonly payload: JsonRecord;
216
+
217
+ constructor(message: string, code: string | undefined, payload: JsonRecord) {
218
+ super(message);
219
+ this.name = "CodexApiError";
220
+ this.code = code;
221
+ this.payload = payload;
222
+ }
223
+ }
224
+
225
+ class CodexProtocolError extends Error {
226
+ readonly payload: unknown;
227
+
228
+ constructor(message: string, payload: unknown, cause: unknown) {
229
+ super(message, { cause });
230
+ this.name = "CodexProtocolError";
231
+ this.payload = payload;
232
+ }
233
+ }
234
+
235
+ class WebSocketCloseError extends Error {
236
+ readonly code: number | undefined;
237
+ readonly reason: string | undefined;
238
+ readonly wasClean: boolean | undefined;
239
+
240
+ constructor(
241
+ message: string,
242
+ options: {
243
+ code: number | undefined;
244
+ reason: string | undefined;
245
+ wasClean: boolean | undefined;
246
+ },
247
+ ) {
248
+ super(message);
249
+ this.name = "WebSocketCloseError";
250
+ this.code = options.code;
251
+ this.reason = options.reason;
252
+ this.wasClean = options.wasClean;
253
+ }
254
+ }
78
255
 
79
256
  function nodeOs(): typeof NodeOs | undefined {
80
257
  const currentProcess = process as ProcessWithBuiltinModules;
@@ -86,8 +263,94 @@ function nodeZlib(): typeof NodeZlib | undefined {
86
263
  return currentProcess.getBuiltinModule?.("node:zlib");
87
264
  }
88
265
 
89
- function explain(error: unknown): string {
90
- return error instanceof Error ? error.message : String(error);
266
+ function extractWebSocketError(event: unknown): Error {
267
+ if (isObject(event)) {
268
+ if (typeof event["message"] === "string" && event["message"].length > 0) {
269
+ return new Error(event["message"]);
270
+ }
271
+ const nestedError = event["error"];
272
+ if (nestedError instanceof Error && nestedError.message.length > 0) return nestedError;
273
+ if (
274
+ isObject(nestedError) &&
275
+ typeof nestedError["message"] === "string" &&
276
+ nestedError["message"].length > 0
277
+ ) {
278
+ return new Error(nestedError["message"]);
279
+ }
280
+ }
281
+ return new Error("WebSocket error");
282
+ }
283
+
284
+ function extractWebSocketCloseError(
285
+ event: unknown,
286
+ context = "WebSocket closed",
287
+ ): WebSocketCloseError {
288
+ const code = isObject(event) && typeof event["code"] === "number" ? event["code"] : undefined;
289
+ let reason =
290
+ isObject(event) && typeof event["reason"] === "string" && event["reason"].length > 0
291
+ ? event["reason"]
292
+ : undefined;
293
+ if (reason === undefined && code === 1_009) reason = "message too big";
294
+ const wasClean =
295
+ isObject(event) && typeof event["wasClean"] === "boolean" ? event["wasClean"] : undefined;
296
+ const details = [
297
+ code === undefined ? undefined : `code ${code}`,
298
+ reason === undefined ? undefined : `reason: ${reason}`,
299
+ wasClean === undefined ? undefined : `wasClean: ${String(wasClean)}`,
300
+ ].filter((detail): detail is string => detail !== undefined);
301
+ return new WebSocketCloseError(
302
+ details.length > 0 ? `${context} (${details.join(", ")})` : context,
303
+ { code, reason, wasClean },
304
+ );
305
+ }
306
+
307
+ function thrownMessage(error: unknown): string {
308
+ return error instanceof Error ? error.message || error.name : String(error);
309
+ }
310
+
311
+ function isCodexNonTransportError(error: unknown): boolean {
312
+ return error instanceof CodexApiError || error instanceof CodexProtocolError;
313
+ }
314
+
315
+ function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
316
+ return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
317
+ }
318
+
319
+ function isPreviousResponseNotFoundError(error: unknown): boolean {
320
+ return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
321
+ }
322
+
323
+ function diagnosticError(error: unknown): CodexTransportDiagnostic["error"] {
324
+ if (!(error instanceof Error)) {
325
+ return { name: "ThrownValue", message: thrownMessage(error) };
326
+ }
327
+ const code = (error as Error & { code?: unknown }).code;
328
+ return {
329
+ ...(error.name ? { name: error.name } : {}),
330
+ message: error.message || error.name,
331
+ ...(error.stack ? { stack: error.stack } : {}),
332
+ ...(typeof code === "string" || typeof code === "number" ? { code } : {}),
333
+ };
334
+ }
335
+
336
+ function transportDiagnostic(
337
+ error: unknown,
338
+ transport: string,
339
+ emitted: boolean,
340
+ requestBytes: number,
341
+ ): CodexTransportDiagnostic {
342
+ return {
343
+ type: "provider_transport_failure",
344
+ timestamp: Date.now(),
345
+ error: diagnosticError(error),
346
+ details: {
347
+ configuredTransport: transport,
348
+ ...(!emitted ? { fallbackTransport: "sse" as const } : {}),
349
+ eventsEmitted: emitted,
350
+ phase: emitted ? "after_message_stream_start" : "before_message_stream_start",
351
+ requestBytes,
352
+ },
353
+ };
91
354
  }
92
355
 
93
356
  function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
@@ -108,6 +371,79 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
108
371
  });
109
372
  }
110
373
 
374
+ function normalizeTimeoutMs(value: number | undefined): number | undefined {
375
+ if (value === undefined) return undefined;
376
+ if (!Number.isFinite(value) || value < 0) {
377
+ throw new Error(`Invalid timeoutMs: ${String(value)}`);
378
+ }
379
+ return Math.floor(value);
380
+ }
381
+
382
+ function isTerminalRateLimitError(errorText: string): boolean {
383
+ return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
384
+ errorText,
385
+ );
386
+ }
387
+
388
+ function retryDelayMs(headers: Headers): number | undefined {
389
+ const retryAfterMs = headers.get("retry-after-ms");
390
+ if (retryAfterMs !== null) {
391
+ const milliseconds = Number(retryAfterMs);
392
+ if (Number.isFinite(milliseconds)) return Math.max(0, milliseconds);
393
+ }
394
+
395
+ const retryAfter = headers.get("retry-after");
396
+ if (!retryAfter) return undefined;
397
+ const seconds = Number(retryAfter);
398
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
399
+ const date = Date.parse(retryAfter);
400
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
401
+ }
402
+
403
+ class RetryDelayExceededError extends Error {}
404
+
405
+ function validateRetryDelay(delayMs: number, options: CodexTransportOptions): number {
406
+ const maximum = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
407
+ if (maximum > 0 && delayMs > maximum) {
408
+ throw new RetryDelayExceededError(
409
+ `Server requested ${Math.ceil(delayMs / 1_000)}s retry delay (max: ${Math.ceil(maximum / 1_000)}s)`,
410
+ );
411
+ }
412
+ return delayMs;
413
+ }
414
+
415
+ function codexHttpError(status: number, statusText: string, raw: string): Error {
416
+ let message = raw || statusText || "Request failed";
417
+ let friendlyMessage: string | undefined;
418
+ try {
419
+ const parsed = JSON.parse(raw) as {
420
+ error?: {
421
+ code?: string;
422
+ type?: string;
423
+ message?: string;
424
+ plan_type?: string;
425
+ resets_at?: number;
426
+ };
427
+ };
428
+ const error = parsed?.error;
429
+ if (!error) return new Error(message);
430
+ const code = error.code || error.type || "";
431
+ if (
432
+ status === 429 ||
433
+ /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code)
434
+ ) {
435
+ const plan = error.plan_type ? ` (${error.plan_type.toLowerCase()} plan)` : "";
436
+ const resetMinutes = error.resets_at
437
+ ? Math.max(0, Math.round((error.resets_at * 1_000 - Date.now()) / 60_000))
438
+ : undefined;
439
+ const reset = resetMinutes === undefined ? "" : ` Try again in ~${String(resetMinutes)} min.`;
440
+ friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${reset}`.trim();
441
+ }
442
+ message = error.message || friendlyMessage || message;
443
+ } catch {}
444
+ return new Error(friendlyMessage || message);
445
+ }
446
+
111
447
  function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
112
448
  signal?: AbortSignal;
113
449
  cleanup(): void;
@@ -150,7 +486,11 @@ function extractAccountId(token: string): string {
150
486
  if (parts.length !== 3) throw new Error("Invalid token");
151
487
  const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonRecord;
152
488
  const authentication = payload["https://api.openai.com/auth"];
153
- if (!isObject(authentication) || typeof authentication["chatgpt_account_id"] !== "string") {
489
+ if (
490
+ !isObject(authentication) ||
491
+ typeof authentication["chatgpt_account_id"] !== "string" ||
492
+ authentication["chatgpt_account_id"].length === 0
493
+ ) {
154
494
  throw new Error("No account ID");
155
495
  }
156
496
  return authentication["chatgpt_account_id"];
@@ -159,6 +499,11 @@ function extractAccountId(token: string): string {
159
499
  }
160
500
  }
161
501
 
502
+ export function validateCodexAuthentication(model: Model<any>, apiKey: string | undefined): string {
503
+ if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
504
+ return extractAccountId(apiKey);
505
+ }
506
+
162
507
  function resolveCodexUrl(baseUrl?: string): string {
163
508
  const raw = baseUrl?.trim() || DEFAULT_CODEX_BASE_URL;
164
509
  const normalized = raw.replace(/\/+$/, "");
@@ -268,13 +613,13 @@ function compressBody(body: string): Uint8Array | undefined {
268
613
  }
269
614
 
270
615
  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;
616
+ if (status === 429 && isTerminalRateLimitError(text)) return false;
617
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
618
+ return true;
276
619
  }
277
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
620
+ return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(
621
+ text,
622
+ );
278
623
  }
279
624
 
280
625
  async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerator<JsonRecord> {
@@ -289,6 +634,7 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
289
634
  while (true) {
290
635
  if (signal?.aborted) throw new Error("Request was aborted");
291
636
  const { done, value } = await reader.read();
637
+ if (signal?.aborted) throw new Error("Request was aborted");
292
638
  if (done) break;
293
639
  buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
294
640
  let boundary = buffer.indexOf("\n\n");
@@ -302,7 +648,16 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
302
648
  .join("\n")
303
649
  .trim();
304
650
  if (data && data !== "[DONE]") {
305
- const parsed = JSON.parse(data) as unknown;
651
+ let parsed: unknown;
652
+ try {
653
+ parsed = JSON.parse(data) as unknown;
654
+ } catch (error) {
655
+ throw new CodexProtocolError(
656
+ `Invalid Codex SSE JSON: ${thrownMessage(error)}`,
657
+ data,
658
+ error,
659
+ );
660
+ }
306
661
  if (!isObject(parsed)) throw new Error("Invalid Codex SSE event");
307
662
  yield parsed;
308
663
  }
@@ -312,7 +667,9 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
312
667
  } finally {
313
668
  signal?.removeEventListener("abort", onAbort);
314
669
  await reader.cancel().catch(() => {});
315
- reader.releaseLock();
670
+ try {
671
+ reader.releaseLock();
672
+ } catch {}
316
673
  }
317
674
  }
318
675
 
@@ -331,12 +688,14 @@ function socketReusable(socket: WebSocketLike): boolean {
331
688
  return socket.readyState === undefined || socket.readyState === 1;
332
689
  }
333
690
 
334
- function scheduleSocketExpiry(sessionId: string, entry: CachedWebSocket): void {
691
+ function scheduleSocketExpiry(sessionId: string, accountId: string, entry: CachedWebSocket): void {
335
692
  if (entry.idleTimer) clearTimeout(entry.idleTimer);
336
693
  entry.idleTimer = setTimeout(() => {
337
694
  if (entry.busy) return;
338
695
  closeSocket(entry.socket, "idle_timeout");
339
- websocketSessions.delete(sessionId);
696
+ const accountEntries = websocketSessions.get(sessionId);
697
+ if (accountEntries?.get(accountId) === entry) accountEntries.delete(accountId);
698
+ if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
340
699
  }, SESSION_WEBSOCKET_CACHE_TTL_MS);
341
700
  }
342
701
 
@@ -347,7 +706,9 @@ async function connectWebSocket(
347
706
  timeoutMs: number,
348
707
  ): Promise<WebSocketLike> {
349
708
  const WebSocketClass = websocketConstructor();
350
- if (!WebSocketClass) throw new Error("WebSocket transport is unavailable");
709
+ if (!WebSocketClass) {
710
+ throw new Error("WebSocket transport is not available in this runtime");
711
+ }
351
712
  const requestHeaders = headersToRecord(headers);
352
713
  delete requestHeaders["OpenAI-Beta"];
353
714
 
@@ -363,11 +724,11 @@ async function connectWebSocket(
363
724
  socket.removeEventListener("close", onClose);
364
725
  signal?.removeEventListener("abort", onAbort);
365
726
  };
366
- const fail = (error: Error) => {
727
+ const fail = (error: Error, closeReason: string) => {
367
728
  if (settled) return;
368
729
  settled = true;
369
730
  cleanup();
370
- closeSocket(socket, "connect_failure");
731
+ closeSocket(socket, closeReason);
371
732
  reject(error);
372
733
  };
373
734
  const onOpen = () => {
@@ -376,25 +737,27 @@ async function connectWebSocket(
376
737
  cleanup();
377
738
  resolve(socket);
378
739
  };
379
- const onError = (event: unknown) => fail(new Error(`WebSocket error: ${explain(event)}`));
740
+ const onError = (event: unknown) => fail(extractWebSocketError(event), "connect_failure");
380
741
  const onClose = (event: unknown) =>
381
- fail(new Error(`WebSocket closed during connect: ${explain(event)}`));
382
- const onAbort = () => fail(new Error("Request was aborted"));
742
+ fail(extractWebSocketCloseError(event, "WebSocket closed during connect"), "connect_failure");
743
+ const onAbort = () => fail(new Error("Request was aborted"), "aborted");
383
744
 
384
745
  try {
385
746
  socket = new WebSocketClass(url, { headers: requestHeaders });
386
747
  } catch (error) {
387
- reject(error);
748
+ reject(error instanceof Error ? error : new Error(String(error)));
388
749
  return;
389
750
  }
390
751
  socket.addEventListener("open", onOpen);
391
752
  socket.addEventListener("error", onError);
392
753
  socket.addEventListener("close", onClose);
393
754
  signal?.addEventListener("abort", onAbort, { once: true });
394
- timer = setTimeout(
395
- () => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)),
396
- timeoutMs,
397
- );
755
+ if (timeoutMs > 0) {
756
+ timer = setTimeout(
757
+ () => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`), "connect_timeout"),
758
+ timeoutMs,
759
+ );
760
+ }
398
761
  if (signal?.aborted) onAbort();
399
762
  });
400
763
  }
@@ -403,11 +766,19 @@ async function acquireWebSocket(
403
766
  url: string,
404
767
  headers: Headers,
405
768
  sessionId: string | undefined,
769
+ accountId: string,
406
770
  signal: AbortSignal | undefined,
407
771
  timeoutMs: number,
408
- ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocket; release(keep: boolean): void }> {
772
+ ): Promise<{
773
+ socket: WebSocketLike;
774
+ entry?: CachedWebSocket;
775
+ reused: boolean;
776
+ release(keep: boolean): void;
777
+ }> {
778
+ if (signal?.aborted) throw new Error("Request was aborted");
409
779
  if (sessionId) {
410
- const cached = websocketSessions.get(sessionId);
780
+ let accountEntries = websocketSessions.get(sessionId);
781
+ const cached = accountEntries?.get(accountId);
411
782
  if (cached?.idleTimer) {
412
783
  clearTimeout(cached.idleTimer);
413
784
  delete cached.idleTimer;
@@ -418,70 +789,101 @@ async function acquireWebSocket(
418
789
  return {
419
790
  socket: cached.socket,
420
791
  entry: cached,
792
+ reused: true,
421
793
  release(keep) {
422
794
  if (!keep || !socketReusable(cached.socket)) {
795
+ if (cached.idleTimer) {
796
+ clearTimeout(cached.idleTimer);
797
+ delete cached.idleTimer;
798
+ }
423
799
  closeSocket(cached.socket);
424
- websocketSessions.delete(sessionId);
800
+ const currentEntries = websocketSessions.get(sessionId);
801
+ if (currentEntries?.get(accountId) === cached) currentEntries.delete(accountId);
802
+ if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
425
803
  return;
426
804
  }
427
805
  cached.busy = false;
428
- scheduleSocketExpiry(sessionId, cached);
806
+ scheduleSocketExpiry(sessionId, accountId, cached);
429
807
  },
430
808
  };
431
809
  }
432
810
  if (cached && !cached.busy) {
433
- closeSocket(cached.socket);
434
- websocketSessions.delete(sessionId);
811
+ closeSocket(cached.socket, expired ? "connection_age_limit" : "done");
812
+ accountEntries?.delete(accountId);
813
+ if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
814
+ }
815
+ if (cached?.busy) {
816
+ const socket = await connectWebSocket(url, headers, signal, timeoutMs);
817
+ return { socket, reused: false, release: () => closeSocket(socket) };
818
+ }
819
+
820
+ const socket = await connectWebSocket(url, headers, signal, timeoutMs);
821
+ const entry: CachedWebSocket = { socket, busy: true, createdAt: Date.now() };
822
+ accountEntries = websocketSessions.get(sessionId);
823
+ if (!accountEntries) {
824
+ accountEntries = new Map();
825
+ websocketSessions.set(sessionId, accountEntries);
435
826
  }
827
+ accountEntries.set(accountId, entry);
828
+ return {
829
+ socket,
830
+ entry,
831
+ reused: false,
832
+ release(keep) {
833
+ if (!keep || !socketReusable(socket)) {
834
+ closeSocket(socket);
835
+ if (entry.idleTimer) clearTimeout(entry.idleTimer);
836
+ const currentEntries = websocketSessions.get(sessionId);
837
+ if (currentEntries?.get(accountId) === entry) currentEntries.delete(accountId);
838
+ if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
839
+ return;
840
+ }
841
+ entry.busy = false;
842
+ scheduleSocketExpiry(sessionId, accountId, entry);
843
+ },
844
+ };
436
845
  }
437
846
 
438
847
  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
- };
848
+ return { socket, reused: false, release: () => closeSocket(socket) };
457
849
  }
458
850
 
459
851
  function requestWithoutHistory(body: JsonRecord): JsonRecord {
460
- const result = structuredClone(body);
852
+ const result = { ...body };
461
853
  delete result.input;
462
854
  delete result.previous_response_id;
463
855
  return result;
464
856
  }
465
857
 
858
+ function jsonWireRequestBody(body: JsonRecord): JsonRecord {
859
+ const snapshot = JSON.parse(JSON.stringify(body)) as unknown;
860
+ if (!isObject(snapshot)) {
861
+ throw new Error("Codex request body must serialize to a JSON object");
862
+ }
863
+ return snapshot;
864
+ }
865
+
466
866
  function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord {
467
867
  const continuation = entry.continuation;
468
868
  if (!continuation) return body;
469
869
  if (
470
- stableResponsesJson(requestWithoutHistory(body)) !==
471
- stableResponsesJson(requestWithoutHistory(continuation.lastRequestBody))
870
+ JSON.stringify(requestWithoutHistory(body)) !==
871
+ JSON.stringify(requestWithoutHistory(continuation.lastRequestBody))
472
872
  ) {
473
873
  delete entry.continuation;
474
874
  return body;
475
875
  }
476
876
 
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
- : [];
877
+ const currentInput = body.input ?? [];
878
+ const previousInput = continuation.lastRequestBody.input ?? [];
879
+ if (!Array.isArray(currentInput) || !Array.isArray(previousInput)) {
880
+ delete entry.continuation;
881
+ return body;
882
+ }
481
883
  const baseline = [...previousInput, ...continuation.lastResponseItems];
482
884
  if (
483
885
  currentInput.length < baseline.length ||
484
- !replayItemsEqual(currentInput.slice(0, baseline.length), baseline)
886
+ JSON.stringify(currentInput.slice(0, baseline.length)) !== JSON.stringify(baseline)
485
887
  ) {
486
888
  delete entry.continuation;
487
889
  return body;
@@ -494,6 +896,10 @@ function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord
494
896
  };
495
897
  }
496
898
 
899
+ function requestInputLength(body: JsonRecord): number {
900
+ return typeof body.input === "string" || Array.isArray(body.input) ? body.input.length : 0;
901
+ }
902
+
497
903
  async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
498
904
  if (typeof data === "string") return data;
499
905
  if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
@@ -525,9 +931,10 @@ async function* parseWebSocket(
525
931
  };
526
932
  const onMessage = (event: unknown) => {
527
933
  void (async () => {
934
+ let text: string | undefined;
528
935
  try {
529
936
  if (!isObject(event)) return;
530
- const text = await decodeWebSocketData(event["data"]);
937
+ text = await decodeWebSocketData(event["data"]);
531
938
  if (!text) return;
532
939
  const parsed = JSON.parse(text) as unknown;
533
940
  if (!isObject(parsed)) throw new Error("Invalid WebSocket event");
@@ -543,19 +950,23 @@ async function* parseWebSocket(
543
950
  queue.push(parsed);
544
951
  notify();
545
952
  } catch (error) {
546
- failure = error instanceof Error ? error : new Error(String(error));
953
+ failure = new CodexProtocolError(
954
+ `Invalid Codex WebSocket JSON: ${thrownMessage(error)}`,
955
+ text,
956
+ error,
957
+ );
547
958
  done = true;
548
959
  notify();
549
960
  }
550
961
  })();
551
962
  };
552
963
  const onError = (event: unknown) => {
553
- failure = new Error(`WebSocket error: ${explain(event)}`);
964
+ if (!failure) failure = extractWebSocketError(event);
554
965
  done = true;
555
966
  notify();
556
967
  };
557
968
  const onClose = (event: unknown) => {
558
- if (!terminal) failure = new Error(`WebSocket closed: ${explain(event)}`);
969
+ if (!terminal && !failure) failure = extractWebSocketCloseError(event);
559
970
  done = true;
560
971
  notify();
561
972
  };
@@ -571,6 +982,7 @@ async function* parseWebSocket(
571
982
  signal?.addEventListener("abort", onAbort, { once: true });
572
983
  try {
573
984
  while (true) {
985
+ if (signal?.aborted) throw new Error("Request was aborted");
574
986
  if (queue.length > 0) {
575
987
  yield queue.shift()!;
576
988
  continue;
@@ -579,10 +991,14 @@ async function* parseWebSocket(
579
991
  await new Promise<void>((resolve, reject) => {
580
992
  wake = resolve;
581
993
  if (timeoutMs !== undefined && timeoutMs > 0) {
582
- const timer = setTimeout(
583
- () => reject(new Error(`WebSocket idle timeout after ${timeoutMs}ms`)),
584
- timeoutMs,
585
- );
994
+ const timer = setTimeout(() => {
995
+ const error = new Error(`WebSocket idle timeout after ${timeoutMs}ms`);
996
+ failure = error;
997
+ done = true;
998
+ wake = undefined;
999
+ closeSocket(socket, "idle_timeout");
1000
+ reject(error);
1001
+ }, timeoutMs);
586
1002
  const priorWake = wake;
587
1003
  wake = () => {
588
1004
  clearTimeout(timer);
@@ -592,7 +1008,7 @@ async function* parseWebSocket(
592
1008
  });
593
1009
  }
594
1010
  if (failure) throw failure;
595
- if (!terminal) throw new Error("WebSocket ended without a terminal response");
1011
+ if (!terminal) throw new Error("WebSocket stream closed before response.completed");
596
1012
  } finally {
597
1013
  socket.removeEventListener("message", onMessage);
598
1014
  socket.removeEventListener("error", onError);
@@ -601,24 +1017,35 @@ async function* parseWebSocket(
601
1017
  }
602
1018
  }
603
1019
 
604
- function normalizeEvent(event: JsonRecord): JsonRecord {
605
- const type = event.type;
1020
+ function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
1021
+ const type = typeof event.type === "string" ? event.type : undefined;
1022
+ if (!type) return undefined;
606
1023
  if (type === "error") {
607
1024
  const nested = isObject(event["error"]) ? event["error"] : undefined;
608
- throw new CodexResponseError(
1025
+ const code =
1026
+ typeof event["code"] === "string"
1027
+ ? event["code"]
1028
+ : typeof nested?.["code"] === "string"
1029
+ ? nested["code"]
1030
+ : undefined;
1031
+ const message =
609
1032
  typeof event["message"] === "string"
610
1033
  ? event["message"]
611
1034
  : typeof nested?.["message"] === "string"
612
1035
  ? nested["message"]
613
- : "Codex request failed",
1036
+ : undefined;
1037
+ throw new CodexApiError(
1038
+ `Codex error: ${message || code || JSON.stringify(event)}`,
1039
+ code,
1040
+ event,
614
1041
  );
615
1042
  }
616
1043
  if (type === "response.failed") {
617
1044
  const response = isObject(event.response) ? event.response : undefined;
618
1045
  const error = isObject(response?.["error"]) ? response["error"] : undefined;
619
- throw new CodexResponseError(
620
- typeof error?.["message"] === "string" ? error["message"] : "Codex response failed",
621
- );
1046
+ const message = typeof error?.["message"] === "string" ? error["message"] : undefined;
1047
+ const code = typeof error?.["code"] === "string" ? error["code"] : undefined;
1048
+ throw new CodexApiError(message || "Codex response failed", code, event);
622
1049
  }
623
1050
  if (type === "response.done") {
624
1051
  return { ...event, type: "response.completed" };
@@ -626,59 +1053,93 @@ function normalizeEvent(event: JsonRecord): JsonRecord {
626
1053
  return event;
627
1054
  }
628
1055
 
1056
+ function isTerminalEvent(event: JsonRecord): boolean {
1057
+ return event.type === "response.completed" || event.type === "response.incomplete";
1058
+ }
1059
+
629
1060
  async function* requestSse(
630
1061
  model: Model<any>,
631
- body: JsonRecord,
1062
+ bodyJson: string,
632
1063
  options: CodexTransportOptions,
633
1064
  headers: Headers,
1065
+ timeoutMs: number | undefined,
634
1066
  ): AsyncGenerator<JsonRecord> {
635
- const bodyJson = JSON.stringify(body);
636
1067
  const compressed = compressBody(bodyJson);
637
1068
  if (compressed) headers.set("content-encoding", "zstd");
638
1069
  const requestBody = compressed ?? bodyJson;
639
1070
  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1071
+ let response: Response | undefined;
1072
+ let lastError: Error | undefined;
640
1073
 
641
1074
  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;
1075
+ if (options.signal?.aborted) throw new Error("Request was aborted");
648
1076
  try {
1077
+ const timeoutSignal =
1078
+ timeoutMs !== undefined && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
1079
+ const combined = combineAbortSignals([options.signal, timeoutSignal]);
649
1080
  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;
1081
+ try {
1082
+ response = await (options.fetch ?? globalThis.fetch)(resolveCodexUrl(model.baseUrl), {
1083
+ method: "POST",
1084
+ headers,
1085
+ body: requestBody,
1086
+ ...(combined.signal ? { signal: combined.signal } : {}),
1087
+ });
1088
+ } catch (error) {
1089
+ if (timeoutSignal?.aborted && !options.signal?.aborted) {
1090
+ throw new Error(`Codex SSE response headers timed out after ${String(timeoutMs)}ms`);
1091
+ }
1092
+ throw error;
660
1093
  }
661
- throw error;
1094
+ } finally {
1095
+ combined.cleanup();
662
1096
  }
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) {
1097
+ await options.onResponse?.(
1098
+ { status: response.status, headers: headersToRecord(response.headers) },
1099
+ model,
1100
+ );
1101
+ if (response.ok) break;
1102
+
671
1103
  const errorText = await response.text();
672
1104
  if (attempt < maxRetries && isRetryable(response.status, errorText)) {
1105
+ const requestedDelay = retryDelayMs(response.headers);
1106
+ const delay =
1107
+ requestedDelay === undefined
1108
+ ? BASE_DELAY_MS * 2 ** attempt
1109
+ : validateRetryDelay(requestedDelay, options);
1110
+ await sleep(delay, options.signal);
1111
+ continue;
1112
+ }
1113
+ throw codexHttpError(response.status, response.statusText, errorText);
1114
+ } catch (error) {
1115
+ if (
1116
+ options.signal?.aborted ||
1117
+ (error instanceof Error &&
1118
+ (error.name === "AbortError" || error.message === "Request was aborted"))
1119
+ ) {
1120
+ throw new Error("Request was aborted");
1121
+ }
1122
+ lastError = error instanceof Error ? error : new Error(String(error));
1123
+ if (
1124
+ attempt < maxRetries &&
1125
+ !(lastError instanceof RetryDelayExceededError) &&
1126
+ !lastError.message.includes("usage limit")
1127
+ ) {
673
1128
  await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
674
1129
  continue;
675
1130
  }
676
- throw new Error(errorText || `Codex request failed with status ${response.status}`);
1131
+ throw lastError;
677
1132
  }
678
- for await (const event of parseSse(response, options.signal)) {
679
- yield normalizeEvent(event);
680
- }
681
- return;
1133
+ }
1134
+
1135
+ if (!response?.ok) throw lastError ?? new Error("Failed after retries");
1136
+ if (!response.body) throw new Error("No response body");
1137
+ options.onTransportStart?.();
1138
+ for await (const event of parseSse(response, options.signal)) {
1139
+ const normalized = normalizeEvent(event);
1140
+ if (!normalized) continue;
1141
+ yield normalized;
1142
+ if (isTerminalEvent(normalized)) return;
682
1143
  }
683
1144
  }
684
1145
 
@@ -688,24 +1149,74 @@ async function* requestWebSocket(
688
1149
  options: CodexTransportOptions,
689
1150
  headers: Headers,
690
1151
  sessionId: string | undefined,
1152
+ accountId: string,
1153
+ timeoutMs: number | undefined,
1154
+ connectTimeoutMs: number,
1155
+ requestBytes: number,
691
1156
  ): AsyncGenerator<JsonRecord> {
692
1157
  const acquired = await acquireWebSocket(
693
1158
  resolveCodexWebSocketUrl(model.baseUrl),
694
1159
  headers,
695
1160
  sessionId,
1161
+ accountId,
696
1162
  options.signal,
697
- options.websocketConnectTimeoutMs ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,
1163
+ connectTimeoutMs,
698
1164
  );
699
- let keep = true;
1165
+ let keep = false;
1166
+ let responseHandleActive = true;
1167
+ const discard = (): boolean => {
1168
+ if (!responseHandleActive) return false;
1169
+ responseHandleActive = false;
1170
+ if (acquired.entry) delete acquired.entry.continuation;
1171
+ acquired.release(false);
1172
+ return true;
1173
+ };
700
1174
  try {
1175
+ if (options.signal?.aborted) throw new Error("Request was aborted");
701
1176
  const useContinuation =
702
1177
  options.transport === "auto" || options.transport === "websocket-cached";
1178
+ const fullBody = useContinuation && acquired.entry ? jsonWireRequestBody(body) : body;
703
1179
  const requestBody =
704
- useContinuation && acquired.entry ? cachedRequestBody(acquired.entry, body) : body;
1180
+ useContinuation && acquired.entry ? cachedRequestBody(acquired.entry, fullBody) : fullBody;
1181
+ const stats = sessionId ? getOrCreateWebSocketDebugStats(sessionId) : undefined;
1182
+ if (stats) {
1183
+ stats.requests += 1;
1184
+ if (acquired.reused) stats.connectionsReused += 1;
1185
+ else stats.connectionsCreated += 1;
1186
+ if (useContinuation) stats.cachedContextRequests += 1;
1187
+ if (requestBody.store === true) stats.storeTrueRequests += 1;
1188
+ stats.lastInputItems = requestInputLength(requestBody);
1189
+ if (requestBody.previous_response_id) {
1190
+ stats.deltaRequests += 1;
1191
+ stats.lastDeltaInputItems = requestInputLength(requestBody);
1192
+ stats.lastPreviousResponseId = requestBody.previous_response_id as string;
1193
+ } else {
1194
+ stats.fullContextRequests += 1;
1195
+ delete stats.lastDeltaInputItems;
1196
+ delete stats.lastPreviousResponseId;
1197
+ }
1198
+ }
1199
+ options.onWebSocketResponseHandle?.({
1200
+ discard,
1201
+ failParsing(error) {
1202
+ if (!discard()) return false;
1203
+ options.onTransportDiagnostic?.(
1204
+ transportDiagnostic(error, options.transport ?? "auto", true, requestBytes),
1205
+ );
1206
+ return true;
1207
+ },
1208
+ });
705
1209
  acquired.socket.send(JSON.stringify({ type: "response.create", ...requestBody }));
706
1210
  const responseItems: JsonRecord[] = [];
707
1211
  let responseId: string | undefined;
708
- for await (const event of parseWebSocket(acquired.socket, options.signal, options.timeoutMs)) {
1212
+ for await (const event of parseWebSocket(acquired.socket, options.signal, timeoutMs)) {
1213
+ if (
1214
+ event.type === "response.created" &&
1215
+ isObject(event.response) &&
1216
+ typeof event.response.id === "string"
1217
+ ) {
1218
+ responseId = event.response.id;
1219
+ }
709
1220
  if (event.type === "response.output_item.done" && isObject(event.item)) {
710
1221
  responseItems.push(structuredClone(event.item));
711
1222
  }
@@ -727,20 +1238,43 @@ async function* requestWebSocket(
727
1238
  }
728
1239
  }
729
1240
  }
730
- yield normalizeEvent(event);
1241
+ const normalized = normalizeEvent(event);
1242
+ if (!normalized) continue;
1243
+ yield normalized;
1244
+ if (isTerminalEvent(normalized)) break;
731
1245
  }
1246
+ if (options.signal?.aborted) throw new Error("Request was aborted");
732
1247
  if (useContinuation && acquired.entry && responseId) {
733
- acquired.entry.continuation = {
734
- lastRequestBody: structuredClone(body),
1248
+ const entry = acquired.entry;
1249
+ const continuation = {
1250
+ lastRequestBody: fullBody,
735
1251
  lastResponseId: responseId,
736
1252
  lastResponseItems: responseItems.map(normalizeReplayItem),
737
1253
  };
1254
+ entry.continuation = continuation;
1255
+ if (sessionId) {
1256
+ options.onContinuationReady?.({
1257
+ responseId,
1258
+ replaceResponseItems(items) {
1259
+ if (
1260
+ websocketSessions.get(sessionId)?.get(accountId) !== entry ||
1261
+ entry.continuation !== continuation ||
1262
+ continuation.lastResponseId !== responseId
1263
+ ) {
1264
+ return false;
1265
+ }
1266
+ continuation.lastResponseItems = items.map((item) => structuredClone(item));
1267
+ return true;
1268
+ },
1269
+ });
1270
+ }
738
1271
  }
1272
+ keep = true;
739
1273
  } catch (error) {
740
- if (acquired.entry) delete acquired.entry.continuation;
741
- keep = false;
1274
+ responseHandleActive = false;
742
1275
  throw error;
743
1276
  } finally {
1277
+ if (!keep && acquired.entry) delete acquired.entry.continuation;
744
1278
  acquired.release(keep);
745
1279
  }
746
1280
  }
@@ -755,7 +1289,7 @@ export async function requestCodexJson(
755
1289
  model.headers,
756
1290
  options.headers,
757
1291
  options.extraHeaders,
758
- extractAccountId(options.apiKey),
1292
+ validateCodexAuthentication(model, options.apiKey),
759
1293
  options.apiKey,
760
1294
  );
761
1295
  const response = await (options.fetch ?? globalThis.fetch)(
@@ -769,7 +1303,7 @@ export async function requestCodexJson(
769
1303
  );
770
1304
  const responseText = await response.text();
771
1305
  if (!response.ok) {
772
- throw new Error(responseText || `Codex request failed with status ${response.status}`);
1306
+ throw codexHttpError(response.status, response.statusText, responseText);
773
1307
  }
774
1308
  try {
775
1309
  return JSON.parse(responseText) as unknown;
@@ -789,37 +1323,70 @@ export class CodexTransport {
789
1323
  options: CodexTransportOptions,
790
1324
  ): AsyncGenerator<JsonRecord> {
791
1325
  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;
1326
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
1327
+ const connectTimeoutMs =
1328
+ normalizeTimeoutMs(options.websocketConnectTimeoutMs) ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
1329
+ const bodyJson = JSON.stringify(body);
1330
+ const requestBytes = new TextEncoder().encode(bodyJson).byteLength;
1331
+ const accountId = options.accountId ?? validateCodexAuthentication(model, options.apiKey);
1332
+ const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
1333
+ const requestId = codexCacheKey(cacheSessionId);
794
1334
  const transport = options.transport ?? "auto";
795
1335
 
796
- const websocketDisabled =
797
- transport === "auto" && sessionId !== undefined && websocketFallbackSessions.has(sessionId);
1336
+ const websocketDisabled = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
1337
+ if (websocketDisabled) recordWebSocketSseFallback(cacheSessionId);
798
1338
  if (transport !== "sse" && !websocketDisabled) {
799
1339
  const headers = websocketHeaders(
800
1340
  model.headers,
801
1341
  options.headers,
802
1342
  accountId,
803
1343
  options.apiKey,
804
- sessionId ?? crypto.randomUUID(),
1344
+ requestId || uuidv7(),
805
1345
  );
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;
1346
+ let retriedConnectionLimit = false;
1347
+ let retriedMissingContinuation = false;
1348
+ while (true) {
1349
+ let emitted = false;
1350
+ try {
1351
+ for await (const event of requestWebSocket(
1352
+ model,
1353
+ body,
1354
+ options,
1355
+ headers,
1356
+ cacheSessionId,
1357
+ accountId,
1358
+ timeoutMs,
1359
+ connectTimeoutMs,
1360
+ requestBytes,
1361
+ )) {
1362
+ if (!emitted) options.onTransportStart?.();
1363
+ emitted = true;
1364
+ yield event;
1365
+ }
1366
+ return;
1367
+ } catch (error) {
1368
+ const aborted = options.signal?.aborted;
1369
+ const connectionLimitBeforeStart =
1370
+ !emitted && isWebSocketConnectionLimitReachedError(error);
1371
+ if (!aborted && isPreviousResponseNotFoundError(error) && !retriedMissingContinuation) {
1372
+ retriedMissingContinuation = true;
1373
+ continue;
1374
+ }
1375
+ if (!aborted && connectionLimitBeforeStart && !retriedConnectionLimit) {
1376
+ retriedConnectionLimit = true;
1377
+ continue;
1378
+ }
1379
+ if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
1380
+ throw error;
1381
+ }
1382
+ options.onTransportDiagnostic?.(
1383
+ transportDiagnostic(error, transport, emitted, requestBytes),
1384
+ );
1385
+ recordWebSocketFailure(cacheSessionId, error);
1386
+ if (emitted) throw error;
1387
+ recordWebSocketSseFallback(cacheSessionId);
1388
+ break;
821
1389
  }
822
- if (sessionId) websocketFallbackSessions.add(sessionId);
823
1390
  }
824
1391
  }
825
1392
 
@@ -828,28 +1395,14 @@ export class CodexTransport {
828
1395
  options.headers,
829
1396
  accountId,
830
1397
  options.apiKey,
831
- sessionId,
1398
+ requestId,
832
1399
  );
833
- yield* requestSse(model, body, options, headers);
1400
+ yield* requestSse(model, bodyJson, options, headers, timeoutMs);
834
1401
  }
835
1402
 
836
1403
  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();
1404
+ closeOpenAICodexWebSocketSessions(sessionId);
851
1405
  }
852
1406
  }
853
1407
 
854
- const transportCleanup = new CodexTransport();
855
- registerSessionResourceCleanup((sessionId) => transportCleanup.close(sessionId));
1408
+ registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions);