pi-openai-codex-compat 0.0.2 → 0.0.4

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 (29) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +86 -38
  3. package/extensions/openai-codex-compat/apply-patch-diff-render.ts +6 -2
  4. package/extensions/openai-codex-compat/apply-patch-engine.ts +89 -5
  5. package/extensions/openai-codex-compat/apply-patch.ts +4 -5
  6. package/extensions/openai-codex-compat/codex-cache-diagnostics.ts +97 -0
  7. package/extensions/openai-codex-compat/codex-cache-key.ts +9 -0
  8. package/extensions/openai-codex-compat/codex-installation.ts +51 -0
  9. package/extensions/openai-codex-compat/codex-metadata.ts +139 -0
  10. package/extensions/openai-codex-compat/codex-protocol.ts +4 -2
  11. package/extensions/openai-codex-compat/codex-provider.ts +708 -128
  12. package/extensions/openai-codex-compat/codex-stream.ts +137 -40
  13. package/extensions/openai-codex-compat/codex-thread-lineage.ts +156 -0
  14. package/extensions/openai-codex-compat/codex-transport.ts +1795 -199
  15. package/extensions/openai-codex-compat/compaction-checkpoint.ts +2 -2
  16. package/extensions/openai-codex-compat/config.ts +15 -2
  17. package/extensions/openai-codex-compat/image-generation-schema.ts +37 -0
  18. package/extensions/openai-codex-compat/image-generation.ts +25 -48
  19. package/extensions/openai-codex-compat/index.ts +13 -0
  20. package/extensions/openai-codex-compat/namespaced-tools.ts +2 -0
  21. package/extensions/openai-codex-compat/output-limit-continuation.ts +151 -0
  22. package/extensions/openai-codex-compat/provider-error.ts +79 -0
  23. package/extensions/openai-codex-compat/remote-compaction.ts +13 -0
  24. package/extensions/openai-codex-compat/request-options.ts +2 -2
  25. package/extensions/openai-codex-compat/responses-lite.ts +147 -0
  26. package/extensions/openai-codex-compat/responses-replay.ts +0 -7
  27. package/extensions/openai-codex-compat/settings-pane.ts +11 -0
  28. package/extensions/openai-codex-compat/web-run.ts +7 -0
  29. package/package.json +2 -1
@@ -6,9 +6,19 @@ 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";
12
+ import type { CodexCacheDiagnosticContext } from "./codex-cache-diagnostics.ts";
13
+ import {
14
+ applyCodexMetadataHeaders,
15
+ CODEX_INSTALLATION_ID_METADATA_KEY,
16
+ CODEX_WINDOW_ID_HEADER,
17
+ type CodexRequestKind,
18
+ } from "./codex-metadata.ts";
10
19
  import { isObject, type JsonRecord } from "./codex-protocol.ts";
11
- import { normalizeReplayItem, replayItemsEqual, stableResponsesJson } from "./responses-replay.ts";
20
+ import { normalizeReplayItem, stableResponsesJson } from "./responses-replay.ts";
21
+ import { applyResponsesLiteHeaders, responsesLiteSsePayload } from "./responses-lite.ts";
12
22
 
13
23
  /**
14
24
  * Focused adaptation of @earendil-works/pi-ai@0.83.0
@@ -17,12 +27,20 @@ import { normalizeReplayItem, replayItemsEqual, stableResponsesJson } from "./re
17
27
 
18
28
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
19
29
  const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
30
+ export const CODEX_WS_REQUEST_START_METADATA_KEY = "x-codex-ws-stream-request-start-ms";
20
31
  const DEFAULT_MAX_RETRIES = 0;
21
- const BASE_DELAY_MS = 1_000;
32
+ const BASE_DELAY_MS = 200;
33
+ const DEFAULT_WEBSOCKET_MAX_RETRIES = 5;
34
+ const DEFAULT_WEBSOCKET_RETRY_BASE_DELAY_MS = 200;
35
+ const DEFAULT_SSE_STREAM_MAX_RETRIES = 5;
36
+ const DEFAULT_SSE_STREAM_RETRY_BASE_DELAY_MS = 200;
37
+ const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
22
38
  const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
23
39
  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;
40
+ const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
41
+ const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
42
+ const CODEX_TURN_STATE_HEADER = "x-codex-turn-state";
43
+ const CODEX_ROUTING_HINT_HEADER = "x-codex-routing-hint";
26
44
 
27
45
  type ProcessWithBuiltinModules = typeof process & {
28
46
  getBuiltinModule?: {
@@ -31,10 +49,6 @@ type ProcessWithBuiltinModules = typeof process & {
31
49
  };
32
50
  };
33
51
 
34
- type CodexTransportOptions = OpenAICodexResponsesOptions & {
35
- env?: ProviderEnv;
36
- };
37
-
38
52
  export type CodexJsonRequestOptions = {
39
53
  apiKey: string;
40
54
  headers?: ProviderHeaders;
@@ -43,6 +57,217 @@ export type CodexJsonRequestOptions = {
43
57
  fetch?: typeof fetch;
44
58
  };
45
59
 
60
+ export type CodexTransportFailureDiagnostic = {
61
+ type: "provider_transport_failure";
62
+ timestamp: number;
63
+ error: {
64
+ name?: string;
65
+ message: string;
66
+ stack?: string;
67
+ code?: string | number;
68
+ };
69
+ details: {
70
+ configuredTransport: string;
71
+ fallbackTransport?: "sse";
72
+ eventsEmitted: boolean;
73
+ phase: "before_message_stream_start" | "after_message_stream_start";
74
+ requestBytes: number;
75
+ };
76
+ };
77
+
78
+ export type CodexContinuationBypassReason =
79
+ | "request_template_changed"
80
+ | "non_array_input"
81
+ | "history_prefix_changed";
82
+
83
+ type CodexContinuationHistoryMismatch = {
84
+ index: number;
85
+ baselineInputItems: number;
86
+ currentInputItems: number;
87
+ baselineItem?: unknown;
88
+ currentItem?: unknown;
89
+ };
90
+
91
+ type CodexTransportRecoveryAttempt = {
92
+ transport: "websocket" | "sse";
93
+ connection?: "new" | "reused";
94
+ contextMode: "full" | "delta";
95
+ inputItems: number;
96
+ fullInputItems: number;
97
+ fullRequestBytes: number;
98
+ wireRequestBytes: number;
99
+ outcome: "selected" | "previous_response_not_found" | "retry_scheduled";
100
+ turnStateReplayed?: boolean;
101
+ turnStateReplayedValue?: string;
102
+ };
103
+
104
+ export type CodexTransportRecoveryDiagnostic = {
105
+ type: "codex_transport_recovery";
106
+ timestamp: number;
107
+ details: {
108
+ trigger:
109
+ | "previous_response_not_found"
110
+ | "local_continuation_bypass"
111
+ | "websocket_retry"
112
+ | "sse_stream_retry"
113
+ | "sse_after_websocket_failure"
114
+ | "sticky_sse_after_websocket_failure";
115
+ configuredTransport: string;
116
+ previousResponseId?: string;
117
+ continuationBypassReason?: CodexContinuationBypassReason;
118
+ historyMismatch?: CodexContinuationHistoryMismatch;
119
+ error?: CodexTransportFailureDiagnostic["error"];
120
+ attempts: CodexTransportRecoveryAttempt[];
121
+ cacheIdentity: CacheIdentitySnapshot;
122
+ previousCacheIdentity?: CacheIdentitySnapshot;
123
+ cacheAffinityEnabled: boolean;
124
+ cacheIdentityPreserved?: boolean;
125
+ promptKeyAndHeaderAligned: boolean;
126
+ accountIdentityPreserved?: boolean;
127
+ retryNumber?: number;
128
+ maxRetries?: number;
129
+ };
130
+ };
131
+
132
+ export type CodexTransportPrewarmDiagnostic = {
133
+ type: "codex_transport_prewarm";
134
+ timestamp: number;
135
+ details: {
136
+ outcome: "completed" | "failed" | "skipped";
137
+ continuationReady: boolean;
138
+ reason?: "sse_configured" | "sticky_sse_fallback";
139
+ cache?: CodexCacheDiagnosticContext;
140
+ turnStateAvailableAtStart?: boolean;
141
+ turnStateReceived?: boolean;
142
+ turnStateAtStart?: string;
143
+ turnStateReceivedValue?: string;
144
+ };
145
+ };
146
+
147
+ export type CodexCacheUsageDiagnostic = {
148
+ inputTokens: number;
149
+ cachedTokens: number;
150
+ cacheWriteTokens: number;
151
+ };
152
+
153
+ export type CodexTransportRequestDiagnostic = {
154
+ type: "codex_transport_request";
155
+ timestamp: number;
156
+ details: {
157
+ requestKind: CodexRequestKind;
158
+ configuredTransport: string;
159
+ selectedTransport: "websocket" | "sse";
160
+ connection?: "new" | "reused";
161
+ contextMode: "full" | "delta";
162
+ inputItems: number;
163
+ fullInputItems: number;
164
+ fullRequestBytes: number;
165
+ wireRequestBytes: number;
166
+ cacheAffinityEnabled: boolean;
167
+ promptKeyAndHeaderAligned: boolean;
168
+ cacheIdentity: CacheIdentitySnapshot;
169
+ sessionId?: string;
170
+ promptCacheKey?: string;
171
+ accountId: string;
172
+ clientSessionId?: string;
173
+ threadId?: string;
174
+ turnId?: string;
175
+ installationId?: string;
176
+ windowId?: string;
177
+ routingHint?: string;
178
+ turnMetadata?: string;
179
+ responseId?: string;
180
+ previousResponseId?: string;
181
+ turnStateAvailableAtStart: boolean;
182
+ turnStateReplayed: boolean;
183
+ turnStateReceived: boolean;
184
+ turnStateAtStart?: string;
185
+ turnStateReplayedValue?: string;
186
+ turnStateReceivedValue?: string;
187
+ usage?: CodexCacheUsageDiagnostic;
188
+ cache?: CodexCacheDiagnosticContext;
189
+ };
190
+ };
191
+
192
+ export type CodexTransportDiagnostic =
193
+ | CodexTransportFailureDiagnostic
194
+ | CodexTransportRecoveryDiagnostic
195
+ | CodexTransportPrewarmDiagnostic
196
+ | CodexTransportRequestDiagnostic;
197
+
198
+ /**
199
+ * In-memory, turn-scoped server routing state. Diagnostics read the value
200
+ * explicitly so it is visible only where the transport records it deliberately.
201
+ */
202
+ export class CodexTurnState {
203
+ #value: string | undefined;
204
+ #revision = 0;
205
+
206
+ get available(): boolean {
207
+ return this.#value !== undefined;
208
+ }
209
+
210
+ get revision(): number {
211
+ return this.#revision;
212
+ }
213
+
214
+ replayValue(): string | undefined {
215
+ return this.#value;
216
+ }
217
+
218
+ capture(value: string | undefined): boolean {
219
+ if (this.#value !== undefined || !value) return false;
220
+ this.#value = value;
221
+ this.#revision += 1;
222
+ return true;
223
+ }
224
+ }
225
+
226
+ export type CodexContinuationHandle = {
227
+ readonly responseId: string;
228
+ replaceResponseItems(items: readonly JsonRecord[]): boolean;
229
+ };
230
+
231
+ export type CodexWebSocketResponseHandle = {
232
+ discard(): boolean;
233
+ failParsing(error: unknown): boolean;
234
+ };
235
+
236
+ export interface OpenAICodexWebSocketDebugStats {
237
+ requests: number;
238
+ connectionsCreated: number;
239
+ connectionsReused: number;
240
+ cachedContextRequests: number;
241
+ storeTrueRequests: number;
242
+ fullContextRequests: number;
243
+ deltaRequests: number;
244
+ lastInputItems: number;
245
+ lastDeltaInputItems?: number;
246
+ lastPreviousResponseId?: string;
247
+ websocketFailures: number;
248
+ sseFallbacks: number;
249
+ prewarmRequests: number;
250
+ websocketFallbackActive?: boolean;
251
+ lastWebSocketError?: string;
252
+ }
253
+
254
+ type CodexTransportOptions = OpenAICodexResponsesOptions & {
255
+ accountId?: string;
256
+ env?: ProviderEnv;
257
+ onContinuationReady?(handle: CodexContinuationHandle): void;
258
+ onWebSocketResponseHandle?(handle: CodexWebSocketResponseHandle): void;
259
+ onTransportStart?(): void;
260
+ onTransportDiagnostic?(diagnostic: CodexTransportDiagnostic): void;
261
+ warmup?: boolean;
262
+ requestKind?: CodexRequestKind;
263
+ turnState?: CodexTurnState;
264
+ cacheDiagnostics?: CodexCacheDiagnosticContext;
265
+ websocketMaxRetries?: number;
266
+ websocketRetryBaseDelayMs?: number;
267
+ sseStreamMaxRetries?: number;
268
+ sseStreamRetryBaseDelayMs?: number;
269
+ };
270
+
46
271
  type WebSocketEventType = "open" | "message" | "error" | "close";
47
272
  type WebSocketListener = (event: unknown) => void;
48
273
 
@@ -62,8 +287,6 @@ type WebSocketConstructor = new (
62
287
  type CachedWebSocket = {
63
288
  socket: WebSocketLike;
64
289
  busy: boolean;
65
- createdAt: number;
66
- idleTimer?: ReturnType<typeof setTimeout>;
67
290
  continuation?: {
68
291
  lastRequestBody: JsonRecord;
69
292
  lastResponseId: string;
@@ -71,10 +294,167 @@ type CachedWebSocket = {
71
294
  };
72
295
  };
73
296
 
74
- const websocketSessions = new Map<string, CachedWebSocket>();
75
- const websocketFallbackSessions = new Set<string>();
297
+ export type CacheIdentitySnapshot = {
298
+ promptCacheKey: string | undefined;
299
+ sessionHeader: string | null;
300
+ threadHeader: string | null;
301
+ clientRequestHeader: string | null;
302
+ installationId?: string;
303
+ windowId?: string;
304
+ routingHint: string | null;
305
+ accountId: string;
306
+ };
307
+
308
+ type WebSocketFallbackSession = {
309
+ cacheIdentity: CacheIdentitySnapshot;
310
+ };
311
+
312
+ const websocketSessions = new Map<string, Map<string, CachedWebSocket>>();
313
+ const websocketFallbackSessions = new Map<string, WebSocketFallbackSession>();
314
+ const websocketDebugStats = new Map<string, OpenAICodexWebSocketDebugStats>();
315
+
316
+ function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats {
317
+ let stats = websocketDebugStats.get(sessionId);
318
+ if (!stats) {
319
+ stats = {
320
+ requests: 0,
321
+ connectionsCreated: 0,
322
+ connectionsReused: 0,
323
+ cachedContextRequests: 0,
324
+ storeTrueRequests: 0,
325
+ fullContextRequests: 0,
326
+ deltaRequests: 0,
327
+ lastInputItems: 0,
328
+ websocketFailures: 0,
329
+ sseFallbacks: 0,
330
+ prewarmRequests: 0,
331
+ };
332
+ websocketDebugStats.set(sessionId, stats);
333
+ }
334
+ return stats;
335
+ }
336
+
337
+ export function getOpenAICodexWebSocketDebugStats(
338
+ sessionId: string,
339
+ ): OpenAICodexWebSocketDebugStats | undefined {
340
+ const stats = websocketDebugStats.get(sessionId);
341
+ return stats ? { ...stats } : undefined;
342
+ }
343
+
344
+ export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void {
345
+ if (sessionId) {
346
+ websocketDebugStats.delete(sessionId);
347
+ websocketFallbackSessions.delete(sessionId);
348
+ return;
349
+ }
350
+ websocketDebugStats.clear();
351
+ websocketFallbackSessions.clear();
352
+ }
353
+
354
+ export function closeOpenAICodexWebSocketSessions(sessionId?: string): void {
355
+ const closeEntry = (entry: CachedWebSocket): void => {
356
+ closeSocket(entry.socket, "debug_close");
357
+ };
358
+ if (sessionId) {
359
+ for (const entry of websocketSessions.get(sessionId)?.values() ?? []) closeEntry(entry);
360
+ websocketSessions.delete(sessionId);
361
+ websocketFallbackSessions.delete(sessionId);
362
+ const stats = websocketDebugStats.get(sessionId);
363
+ if (stats?.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
364
+ return;
365
+ }
366
+ for (const accountEntries of websocketSessions.values()) {
367
+ for (const entry of accountEntries.values()) closeEntry(entry);
368
+ }
369
+ websocketSessions.clear();
370
+ websocketFallbackSessions.clear();
371
+ for (const stats of websocketDebugStats.values()) {
372
+ if (stats.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
373
+ }
374
+ }
375
+
376
+ function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean {
377
+ return sessionId ? websocketFallbackSessions.has(sessionId) : false;
378
+ }
379
+
380
+ function webSocketFallbackSession(
381
+ sessionId: string | undefined,
382
+ ): WebSocketFallbackSession | undefined {
383
+ return sessionId ? websocketFallbackSessions.get(sessionId) : undefined;
384
+ }
385
+
386
+ function recordWebSocketSseFallback(sessionId: string | undefined): void {
387
+ if (!sessionId) return;
388
+ const stats = getOrCreateWebSocketDebugStats(sessionId);
389
+ stats.sseFallbacks += 1;
390
+ stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId);
391
+ }
392
+
393
+ function recordWebSocketFailure(
394
+ sessionId: string | undefined,
395
+ error: unknown,
396
+ cacheIdentity: CacheIdentitySnapshot,
397
+ ): void {
398
+ if (!sessionId) return;
399
+ websocketFallbackSessions.set(sessionId, { cacheIdentity });
400
+ const stats = getOrCreateWebSocketDebugStats(sessionId);
401
+ stats.websocketFailures += 1;
402
+ stats.lastWebSocketError = thrownMessage(error);
403
+ stats.websocketFallbackActive = true;
404
+ }
76
405
 
77
- class CodexResponseError extends Error {}
406
+ class CodexApiError extends Error {
407
+ readonly code: string | undefined;
408
+ readonly payload: JsonRecord;
409
+
410
+ constructor(message: string, code: string | undefined, payload: JsonRecord) {
411
+ super(message);
412
+ this.name = "CodexApiError";
413
+ this.code = code;
414
+ this.payload = payload;
415
+ }
416
+ }
417
+
418
+ class CodexProtocolError extends Error {
419
+ readonly payload: unknown;
420
+
421
+ constructor(message: string, payload: unknown, cause: unknown) {
422
+ super(message, { cause });
423
+ this.name = "CodexProtocolError";
424
+ this.payload = payload;
425
+ }
426
+ }
427
+
428
+ class CodexHttpError extends Error {
429
+ readonly retryable: boolean;
430
+
431
+ constructor(message: string, retryable: boolean) {
432
+ super(message);
433
+ this.name = "CodexHttpError";
434
+ this.retryable = retryable;
435
+ }
436
+ }
437
+
438
+ class WebSocketCloseError extends Error {
439
+ readonly code: number | undefined;
440
+ readonly reason: string | undefined;
441
+ readonly wasClean: boolean | undefined;
442
+
443
+ constructor(
444
+ message: string,
445
+ options: {
446
+ code: number | undefined;
447
+ reason: string | undefined;
448
+ wasClean: boolean | undefined;
449
+ },
450
+ ) {
451
+ super(message);
452
+ this.name = "WebSocketCloseError";
453
+ this.code = options.code;
454
+ this.reason = options.reason;
455
+ this.wasClean = options.wasClean;
456
+ }
457
+ }
78
458
 
79
459
  function nodeOs(): typeof NodeOs | undefined {
80
460
  const currentProcess = process as ProcessWithBuiltinModules;
@@ -86,8 +466,99 @@ function nodeZlib(): typeof NodeZlib | undefined {
86
466
  return currentProcess.getBuiltinModule?.("node:zlib");
87
467
  }
88
468
 
89
- function explain(error: unknown): string {
90
- return error instanceof Error ? error.message : String(error);
469
+ function extractWebSocketError(event: unknown): Error {
470
+ if (isObject(event)) {
471
+ if (typeof event["message"] === "string" && event["message"].length > 0) {
472
+ return new Error(event["message"]);
473
+ }
474
+ const nestedError = event["error"];
475
+ if (nestedError instanceof Error && nestedError.message.length > 0) return nestedError;
476
+ if (
477
+ isObject(nestedError) &&
478
+ typeof nestedError["message"] === "string" &&
479
+ nestedError["message"].length > 0
480
+ ) {
481
+ return new Error(nestedError["message"]);
482
+ }
483
+ }
484
+ return new Error("WebSocket error");
485
+ }
486
+
487
+ function extractWebSocketCloseError(
488
+ event: unknown,
489
+ context = "WebSocket closed",
490
+ ): WebSocketCloseError {
491
+ const code = isObject(event) && typeof event["code"] === "number" ? event["code"] : undefined;
492
+ let reason =
493
+ isObject(event) && typeof event["reason"] === "string" && event["reason"].length > 0
494
+ ? event["reason"]
495
+ : undefined;
496
+ if (reason === undefined && code === 1_009) reason = "message too big";
497
+ const wasClean =
498
+ isObject(event) && typeof event["wasClean"] === "boolean" ? event["wasClean"] : undefined;
499
+ const details = [
500
+ code === undefined ? undefined : `code ${code}`,
501
+ reason === undefined ? undefined : `reason: ${reason}`,
502
+ wasClean === undefined ? undefined : `wasClean: ${String(wasClean)}`,
503
+ ].filter((detail): detail is string => detail !== undefined);
504
+ return new WebSocketCloseError(
505
+ details.length > 0 ? `${context} (${details.join(", ")})` : context,
506
+ { code, reason, wasClean },
507
+ );
508
+ }
509
+
510
+ function thrownMessage(error: unknown): string {
511
+ return error instanceof Error ? error.message || error.name : String(error);
512
+ }
513
+
514
+ function isCodexNonTransportError(error: unknown): boolean {
515
+ return (
516
+ error instanceof CodexApiError ||
517
+ error instanceof CodexHttpError ||
518
+ error instanceof CodexProtocolError
519
+ );
520
+ }
521
+
522
+ function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
523
+ return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
524
+ }
525
+
526
+ function isPreviousResponseNotFoundError(error: unknown): boolean {
527
+ return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
528
+ }
529
+
530
+ function diagnosticError(error: unknown): CodexTransportFailureDiagnostic["error"] {
531
+ if (!(error instanceof Error)) {
532
+ return { name: "ThrownValue", message: thrownMessage(error) };
533
+ }
534
+ const code = (error as Error & { code?: unknown }).code;
535
+ return {
536
+ ...(error.name ? { name: error.name } : {}),
537
+ message: error.message || error.name,
538
+ ...(error.stack ? { stack: error.stack } : {}),
539
+ ...(typeof code === "string" || typeof code === "number" ? { code } : {}),
540
+ };
541
+ }
542
+
543
+ function transportDiagnostic(
544
+ error: unknown,
545
+ transport: string,
546
+ emitted: boolean,
547
+ requestBytes: number,
548
+ fallbackSelected = !emitted,
549
+ ): CodexTransportFailureDiagnostic {
550
+ return {
551
+ type: "provider_transport_failure",
552
+ timestamp: Date.now(),
553
+ error: diagnosticError(error),
554
+ details: {
555
+ configuredTransport: transport,
556
+ ...(fallbackSelected ? { fallbackTransport: "sse" as const } : {}),
557
+ eventsEmitted: emitted,
558
+ phase: emitted ? "after_message_stream_start" : "before_message_stream_start",
559
+ requestBytes,
560
+ },
561
+ };
91
562
  }
92
563
 
93
564
  function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
@@ -108,6 +579,96 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
108
579
  });
109
580
  }
110
581
 
582
+ function retryBackoffMs(baseDelayMs: number, attempt: number): number {
583
+ if (baseDelayMs <= 0) return 0;
584
+ const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
585
+ const jitter = 0.9 + Math.random() * 0.2;
586
+ return Math.floor(exponential * jitter);
587
+ }
588
+
589
+ function normalizeTimeoutMs(value: number | undefined): number | undefined {
590
+ if (value === undefined) return undefined;
591
+ if (!Number.isFinite(value) || value < 0) {
592
+ throw new Error(`Invalid timeoutMs: ${String(value)}`);
593
+ }
594
+ return Math.floor(value);
595
+ }
596
+
597
+ function normalizeRetryCount(value: number | undefined, fallback: number, name: string): number {
598
+ const resolved = value ?? fallback;
599
+ if (!Number.isFinite(resolved) || resolved < 0) {
600
+ throw new Error(`Invalid ${name}: ${String(resolved)}`);
601
+ }
602
+ return Math.floor(resolved);
603
+ }
604
+
605
+ function isTerminalRateLimitError(errorText: string): boolean {
606
+ return /GoUsageLimitError|FreeUsageLimitError|usage_limit_reached|usage_not_included|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
607
+ errorText,
608
+ );
609
+ }
610
+
611
+ function retryDelayMs(headers: Headers): number | undefined {
612
+ const retryAfterMs = headers.get("retry-after-ms");
613
+ if (retryAfterMs !== null) {
614
+ const milliseconds = Number(retryAfterMs);
615
+ if (Number.isFinite(milliseconds)) return Math.max(0, milliseconds);
616
+ }
617
+
618
+ const retryAfter = headers.get("retry-after");
619
+ if (!retryAfter) return undefined;
620
+ const seconds = Number(retryAfter);
621
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
622
+ const date = Date.parse(retryAfter);
623
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
624
+ }
625
+
626
+ class RetryDelayExceededError extends Error {}
627
+ class SseStreamIncompleteError extends Error {}
628
+
629
+ function validateRetryDelay(delayMs: number, options: CodexTransportOptions): number {
630
+ const maximum = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
631
+ if (maximum > 0 && delayMs > maximum) {
632
+ throw new RetryDelayExceededError(
633
+ `Server requested ${Math.ceil(delayMs / 1_000)}s retry delay (max: ${Math.ceil(maximum / 1_000)}s)`,
634
+ );
635
+ }
636
+ return delayMs;
637
+ }
638
+
639
+ function codexHttpError(status: number, statusText: string, raw: string): CodexHttpError {
640
+ let message = raw || statusText || "Request failed";
641
+ let friendlyMessage: string | undefined;
642
+ const retryable = isRetryable(status, raw);
643
+ try {
644
+ const parsed = JSON.parse(raw) as {
645
+ error?: {
646
+ code?: string;
647
+ type?: string;
648
+ message?: string;
649
+ plan_type?: string;
650
+ resets_at?: number;
651
+ };
652
+ };
653
+ const error = parsed?.error;
654
+ if (!error) return new CodexHttpError(message, retryable);
655
+ const code = error.code || error.type || "";
656
+ if (
657
+ status === 429 ||
658
+ /usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code)
659
+ ) {
660
+ const plan = error.plan_type ? ` (${error.plan_type.toLowerCase()} plan)` : "";
661
+ const resetMinutes = error.resets_at
662
+ ? Math.max(0, Math.round((error.resets_at * 1_000 - Date.now()) / 60_000))
663
+ : undefined;
664
+ const reset = resetMinutes === undefined ? "" : ` Try again in ~${String(resetMinutes)} min.`;
665
+ friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${reset}`.trim();
666
+ }
667
+ message = error.message || friendlyMessage || message;
668
+ } catch {}
669
+ return new CodexHttpError(friendlyMessage || message, retryable);
670
+ }
671
+
111
672
  function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
112
673
  signal?: AbortSignal;
113
674
  cleanup(): void;
@@ -140,17 +701,291 @@ function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
140
701
  };
141
702
  }
142
703
 
704
+ function webSocketRecoveryAttempt(
705
+ attempt: CodexWebSocketAttempt,
706
+ outcome: CodexTransportRecoveryAttempt["outcome"],
707
+ ): CodexTransportRecoveryAttempt {
708
+ return {
709
+ transport: "websocket",
710
+ connection: attempt.connection,
711
+ contextMode: attempt.contextMode,
712
+ inputItems: attempt.inputItems,
713
+ fullInputItems: attempt.fullInputItems,
714
+ fullRequestBytes: attempt.fullRequestBytes,
715
+ wireRequestBytes: attempt.wireRequestBytes,
716
+ outcome,
717
+ ...(attempt.turnStateReplayed === undefined
718
+ ? {}
719
+ : { turnStateReplayed: attempt.turnStateReplayed }),
720
+ ...(attempt.turnStateReplayedValue
721
+ ? { turnStateReplayedValue: attempt.turnStateReplayedValue }
722
+ : {}),
723
+ };
724
+ }
725
+
726
+ function sseRecoveryAttempt(
727
+ body: JsonRecord,
728
+ requestBytes: number,
729
+ turnStateReplayedValue: string | undefined,
730
+ ): CodexTransportRecoveryAttempt {
731
+ const inputItems = requestInputLength(body);
732
+ return {
733
+ transport: "sse",
734
+ contextMode: "full",
735
+ inputItems,
736
+ fullInputItems: inputItems,
737
+ fullRequestBytes: requestBytes,
738
+ wireRequestBytes: requestBytes,
739
+ outcome: "selected",
740
+ turnStateReplayed: turnStateReplayedValue !== undefined,
741
+ ...(turnStateReplayedValue ? { turnStateReplayedValue } : {}),
742
+ };
743
+ }
744
+
745
+ function transportRecoveryDiagnostic(options: {
746
+ trigger: CodexTransportRecoveryDiagnostic["details"]["trigger"];
747
+ configuredTransport: string;
748
+ attempts: CodexTransportRecoveryAttempt[];
749
+ cacheIdentity: CacheIdentitySnapshot;
750
+ previousResponseId?: string;
751
+ continuationBypassReason?: CodexContinuationBypassReason;
752
+ historyMismatch?: CodexContinuationHistoryMismatch;
753
+ error?: unknown;
754
+ previousCacheIdentity?: CacheIdentitySnapshot;
755
+ cacheIdentityPreserved?: boolean;
756
+ accountIdentityPreserved?: boolean;
757
+ retryNumber?: number;
758
+ maxRetries?: number;
759
+ }): CodexTransportRecoveryDiagnostic {
760
+ return {
761
+ type: "codex_transport_recovery",
762
+ timestamp: Date.now(),
763
+ details: {
764
+ trigger: options.trigger,
765
+ configuredTransport: options.configuredTransport,
766
+ ...(options.previousResponseId ? { previousResponseId: options.previousResponseId } : {}),
767
+ ...(options.continuationBypassReason
768
+ ? { continuationBypassReason: options.continuationBypassReason }
769
+ : {}),
770
+ ...(options.historyMismatch ? { historyMismatch: options.historyMismatch } : {}),
771
+ ...(options.error === undefined ? {} : { error: diagnosticError(options.error) }),
772
+ attempts: options.attempts,
773
+ cacheIdentity: options.cacheIdentity,
774
+ ...(options.previousCacheIdentity
775
+ ? { previousCacheIdentity: options.previousCacheIdentity }
776
+ : {}),
777
+ cacheAffinityEnabled: cacheAffinityEnabled(options.cacheIdentity),
778
+ ...(options.cacheIdentityPreserved !== undefined
779
+ ? { cacheIdentityPreserved: options.cacheIdentityPreserved }
780
+ : options.previousCacheIdentity
781
+ ? {
782
+ cacheIdentityPreserved: sameCacheIdentity(
783
+ options.previousCacheIdentity,
784
+ options.cacheIdentity,
785
+ ),
786
+ }
787
+ : {}),
788
+ ...(options.accountIdentityPreserved !== undefined
789
+ ? { accountIdentityPreserved: options.accountIdentityPreserved }
790
+ : options.previousCacheIdentity
791
+ ? {
792
+ accountIdentityPreserved:
793
+ options.previousCacheIdentity.accountId === options.cacheIdentity.accountId,
794
+ }
795
+ : {}),
796
+ promptKeyAndHeaderAligned: promptKeyAndHeaderAligned(options.cacheIdentity),
797
+ ...(options.retryNumber === undefined ? {} : { retryNumber: options.retryNumber }),
798
+ ...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
799
+ },
800
+ };
801
+ }
802
+
803
+ function cacheUsageDiagnostic(event: JsonRecord): CodexCacheUsageDiagnostic | undefined {
804
+ if (!isTerminalEvent(event) || !isObject(event.response) || !isObject(event.response.usage)) {
805
+ return undefined;
806
+ }
807
+ const usage = event.response.usage;
808
+ const details = isObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
809
+ return {
810
+ inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
811
+ cachedTokens: typeof details?.cached_tokens === "number" ? details.cached_tokens : 0,
812
+ cacheWriteTokens:
813
+ typeof details?.cache_write_tokens === "number" ? details.cache_write_tokens : 0,
814
+ };
815
+ }
816
+
817
+ function transportRequestDiagnostic(options: {
818
+ requestOptions: CodexTransportOptions;
819
+ body: JsonRecord;
820
+ configuredTransport: string;
821
+ selectedTransport: "websocket" | "sse";
822
+ attempt:
823
+ | CodexWebSocketAttempt
824
+ | {
825
+ contextMode: "full";
826
+ inputItems: number;
827
+ fullInputItems: number;
828
+ fullRequestBytes: number;
829
+ wireRequestBytes: number;
830
+ turnStateReplayed: boolean;
831
+ turnStateReplayedValue?: string;
832
+ };
833
+ cacheIdentity: CacheIdentitySnapshot;
834
+ turnStateAvailableAtStart: boolean;
835
+ turnStateRevisionAtStart: number;
836
+ turnStateValueAtStart?: string;
837
+ responseId?: string;
838
+ usage?: CodexCacheUsageDiagnostic;
839
+ }): CodexTransportRequestDiagnostic {
840
+ const metadata = isObject(options.body.client_metadata)
841
+ ? options.body.client_metadata
842
+ : undefined;
843
+ const turnStateReceived =
844
+ (options.requestOptions.turnState?.revision ?? options.turnStateRevisionAtStart) >
845
+ options.turnStateRevisionAtStart;
846
+ const currentTurnStateValue = options.requestOptions.turnState?.replayValue();
847
+ const routingHint = codexRoutingHint(options.body);
848
+ return {
849
+ type: "codex_transport_request",
850
+ timestamp: Date.now(),
851
+ details: {
852
+ requestKind: options.requestOptions.requestKind ?? "turn",
853
+ configuredTransport: options.configuredTransport,
854
+ selectedTransport: options.selectedTransport,
855
+ ...("connection" in options.attempt ? { connection: options.attempt.connection } : {}),
856
+ contextMode: options.attempt.contextMode,
857
+ inputItems: options.attempt.inputItems,
858
+ fullInputItems: options.attempt.fullInputItems,
859
+ fullRequestBytes: options.attempt.fullRequestBytes,
860
+ wireRequestBytes: options.attempt.wireRequestBytes,
861
+ cacheAffinityEnabled: cacheAffinityEnabled(options.cacheIdentity),
862
+ promptKeyAndHeaderAligned: promptKeyAndHeaderAligned(options.cacheIdentity),
863
+ cacheIdentity: options.cacheIdentity,
864
+ ...(options.requestOptions.sessionId ? { sessionId: options.requestOptions.sessionId } : {}),
865
+ ...(typeof options.body.prompt_cache_key === "string"
866
+ ? { promptCacheKey: options.body.prompt_cache_key }
867
+ : {}),
868
+ accountId: options.cacheIdentity.accountId,
869
+ ...(typeof metadata?.["session_id"] === "string"
870
+ ? { clientSessionId: metadata["session_id"] }
871
+ : {}),
872
+ ...(typeof metadata?.["thread_id"] === "string" ? { threadId: metadata["thread_id"] } : {}),
873
+ ...(typeof metadata?.["turn_id"] === "string" ? { turnId: metadata["turn_id"] } : {}),
874
+ ...(typeof metadata?.[CODEX_INSTALLATION_ID_METADATA_KEY] === "string"
875
+ ? { installationId: metadata[CODEX_INSTALLATION_ID_METADATA_KEY] }
876
+ : {}),
877
+ ...(typeof metadata?.[CODEX_WINDOW_ID_HEADER] === "string"
878
+ ? { windowId: metadata[CODEX_WINDOW_ID_HEADER] }
879
+ : {}),
880
+ ...(routingHint ? { routingHint } : {}),
881
+ ...(typeof metadata?.["x-codex-turn-metadata"] === "string"
882
+ ? { turnMetadata: metadata["x-codex-turn-metadata"] }
883
+ : {}),
884
+ ...(options.responseId ? { responseId: options.responseId } : {}),
885
+ ...("previousResponseId" in options.attempt && options.attempt.previousResponseId
886
+ ? { previousResponseId: options.attempt.previousResponseId }
887
+ : {}),
888
+ turnStateAvailableAtStart: options.turnStateAvailableAtStart,
889
+ turnStateReplayed: options.attempt.turnStateReplayed ?? false,
890
+ turnStateReceived,
891
+ ...(options.turnStateValueAtStart ? { turnStateAtStart: options.turnStateValueAtStart } : {}),
892
+ ...(options.attempt.turnStateReplayedValue
893
+ ? { turnStateReplayedValue: options.attempt.turnStateReplayedValue }
894
+ : {}),
895
+ ...(turnStateReceived && currentTurnStateValue
896
+ ? { turnStateReceivedValue: currentTurnStateValue }
897
+ : {}),
898
+ ...(options.usage ? { usage: options.usage } : {}),
899
+ ...(options.requestOptions.cacheDiagnostics
900
+ ? { cache: options.requestOptions.cacheDiagnostics }
901
+ : {}),
902
+ },
903
+ };
904
+ }
905
+
906
+ function shouldReportRequestDiagnostic(options: CodexTransportOptions): boolean {
907
+ return options.cacheDiagnostics !== undefined || options.turnState !== undefined;
908
+ }
909
+
143
910
  function headersToRecord(headers: Headers): Record<string, string> {
144
911
  return Object.fromEntries(headers.entries());
145
912
  }
146
913
 
914
+ function applyTurnStateHeader(
915
+ headers: Headers,
916
+ turnState: CodexTurnState | undefined,
917
+ ): string | undefined {
918
+ if (!turnState) return undefined;
919
+ const value = turnState?.replayValue();
920
+ if (!value) {
921
+ headers.delete(CODEX_TURN_STATE_HEADER);
922
+ return undefined;
923
+ }
924
+ headers.set(CODEX_TURN_STATE_HEADER, value);
925
+ return value;
926
+ }
927
+
928
+ function codexRoutingHint(body: JsonRecord): string | undefined {
929
+ if (typeof body.model !== "string" || body.model.length === 0) return undefined;
930
+ const tier =
931
+ typeof body.service_tier === "string" && body.service_tier.length > 0
932
+ ? `;tier=${body.service_tier}`
933
+ : "";
934
+ return `model=${body.model}${tier}`;
935
+ }
936
+
937
+ function applyCodexRoutingHint(headers: Headers, body: JsonRecord): void {
938
+ const hint = codexRoutingHint(body);
939
+ if (!hint) {
940
+ headers.delete(CODEX_ROUTING_HINT_HEADER);
941
+ return;
942
+ }
943
+ headers.set(CODEX_ROUTING_HINT_HEADER, hint);
944
+ }
945
+
946
+ function captureTurnStateHeader(headers: Headers, turnState: CodexTurnState | undefined): boolean {
947
+ return turnState?.capture(headers.get(CODEX_TURN_STATE_HEADER) ?? undefined) ?? false;
948
+ }
949
+
950
+ function captureTurnStateEvent(event: JsonRecord, turnState: CodexTurnState | undefined): boolean {
951
+ if (event.type !== "response.metadata" || !isObject(event["headers"])) return false;
952
+ for (const [name, value] of Object.entries(event["headers"])) {
953
+ if (name.toLowerCase() === CODEX_TURN_STATE_HEADER && typeof value === "string") {
954
+ return turnState?.capture(value) ?? false;
955
+ }
956
+ }
957
+ return false;
958
+ }
959
+
960
+ function withTurnStateMetadata(
961
+ body: JsonRecord,
962
+ turnState: CodexTurnState | undefined,
963
+ ): { body: JsonRecord; replayedValue?: string } {
964
+ const value = turnState?.replayValue();
965
+ if (!value) return { body };
966
+ return {
967
+ body: {
968
+ ...body,
969
+ client_metadata: {
970
+ ...(isObject(body.client_metadata) ? body.client_metadata : {}),
971
+ [CODEX_TURN_STATE_HEADER]: value,
972
+ },
973
+ },
974
+ replayedValue: value,
975
+ };
976
+ }
977
+
147
978
  function extractAccountId(token: string): string {
148
979
  try {
149
980
  const parts = token.split(".");
150
981
  if (parts.length !== 3) throw new Error("Invalid token");
151
982
  const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonRecord;
152
983
  const authentication = payload["https://api.openai.com/auth"];
153
- if (!isObject(authentication) || typeof authentication["chatgpt_account_id"] !== "string") {
984
+ if (
985
+ !isObject(authentication) ||
986
+ typeof authentication["chatgpt_account_id"] !== "string" ||
987
+ authentication["chatgpt_account_id"].length === 0
988
+ ) {
154
989
  throw new Error("No account ID");
155
990
  }
156
991
  return authentication["chatgpt_account_id"];
@@ -159,6 +994,11 @@ function extractAccountId(token: string): string {
159
994
  }
160
995
  }
161
996
 
997
+ export function validateCodexAuthentication(model: Model<any>, apiKey: string | undefined): string {
998
+ if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
999
+ return extractAccountId(apiKey);
1000
+ }
1001
+
162
1002
  function resolveCodexUrl(baseUrl?: string): string {
163
1003
  const raw = baseUrl?.trim() || DEFAULT_CODEX_BASE_URL;
164
1004
  const normalized = raw.replace(/\/+$/, "");
@@ -208,15 +1048,25 @@ function sseHeaders(
208
1048
  accountId: string,
209
1049
  token: string,
210
1050
  sessionId: string | undefined,
1051
+ body: JsonRecord,
211
1052
  ): Headers {
212
1053
  const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
213
- headers.set("OpenAI-Beta", "responses=experimental");
1054
+ headers.delete("OpenAI-Beta");
1055
+ headers.delete("openai-beta");
214
1056
  headers.set("accept", "text/event-stream");
215
1057
  headers.set("content-type", "application/json");
216
1058
  if (sessionId) {
217
1059
  headers.set("session-id", sessionId);
218
- headers.set("x-client-request-id", sessionId);
219
1060
  }
1061
+ applyCodexMetadataHeaders(headers, body);
1062
+ if (sessionId && !headers.has("thread-id")) headers.set("thread-id", sessionId);
1063
+ const threadId = headers.get("thread-id");
1064
+ if (threadId) {
1065
+ // Official Responses HTTP uses the thread identity as x-client-request-id.
1066
+ headers.set("x-client-request-id", threadId);
1067
+ }
1068
+ applyResponsesLiteHeaders(headers, body);
1069
+ applyCodexRoutingHint(headers, body);
220
1070
  return headers;
221
1071
  }
222
1072
 
@@ -242,6 +1092,7 @@ function websocketHeaders(
242
1092
  accountId: string,
243
1093
  token: string,
244
1094
  requestId: string,
1095
+ body: JsonRecord,
245
1096
  ): Headers {
246
1097
  const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
247
1098
  headers.delete("accept");
@@ -251,9 +1102,65 @@ function websocketHeaders(
251
1102
  headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
252
1103
  headers.set("x-client-request-id", requestId);
253
1104
  headers.set("session-id", requestId);
1105
+ applyCodexMetadataHeaders(headers, body);
1106
+ if (!headers.has("thread-id")) headers.set("thread-id", requestId);
1107
+ const threadId = headers.get("thread-id");
1108
+ if (threadId) headers.set("x-client-request-id", threadId);
1109
+ applyCodexRoutingHint(headers, body);
254
1110
  return headers;
255
1111
  }
256
1112
 
1113
+ function cacheIdentitySnapshot(
1114
+ body: JsonRecord,
1115
+ headers: Headers,
1116
+ accountId: string,
1117
+ ): CacheIdentitySnapshot {
1118
+ const metadata = isObject(body.client_metadata) ? body.client_metadata : undefined;
1119
+ return {
1120
+ promptCacheKey: typeof body.prompt_cache_key === "string" ? body.prompt_cache_key : undefined,
1121
+ sessionHeader: headers.get("session-id"),
1122
+ threadHeader: headers.get("thread-id"),
1123
+ clientRequestHeader: headers.get("x-client-request-id"),
1124
+ ...(typeof metadata?.[CODEX_INSTALLATION_ID_METADATA_KEY] === "string"
1125
+ ? { installationId: metadata[CODEX_INSTALLATION_ID_METADATA_KEY] }
1126
+ : {}),
1127
+ ...(typeof metadata?.[CODEX_WINDOW_ID_HEADER] === "string"
1128
+ ? { windowId: metadata[CODEX_WINDOW_ID_HEADER] }
1129
+ : {}),
1130
+ routingHint: headers.get(CODEX_ROUTING_HINT_HEADER),
1131
+ accountId,
1132
+ };
1133
+ }
1134
+
1135
+ function cacheAffinityEnabled(identity: CacheIdentitySnapshot): boolean {
1136
+ return Boolean(identity.promptCacheKey || identity.sessionHeader || identity.clientRequestHeader);
1137
+ }
1138
+
1139
+ function promptKeyAndHeaderAligned(identity: CacheIdentitySnapshot): boolean {
1140
+ return Boolean(
1141
+ identity.promptCacheKey &&
1142
+ identity.promptCacheKey === identity.sessionHeader &&
1143
+ identity.promptCacheKey === identity.threadHeader &&
1144
+ identity.promptCacheKey === identity.clientRequestHeader,
1145
+ );
1146
+ }
1147
+
1148
+ function sameCacheIdentity(a: CacheIdentitySnapshot, b: CacheIdentitySnapshot): boolean {
1149
+ return (
1150
+ a.promptCacheKey === b.promptCacheKey &&
1151
+ a.sessionHeader === b.sessionHeader &&
1152
+ a.threadHeader === b.threadHeader &&
1153
+ a.clientRequestHeader === b.clientRequestHeader &&
1154
+ a.installationId === b.installationId &&
1155
+ a.windowId === b.windowId &&
1156
+ a.routingHint === b.routingHint
1157
+ );
1158
+ }
1159
+
1160
+ function serializedBytes(value: string): number {
1161
+ return new TextEncoder().encode(value).byteLength;
1162
+ }
1163
+
257
1164
  function compressBody(body: string): Uint8Array | undefined {
258
1165
  const zlib = nodeZlib();
259
1166
  if (!zlib || typeof zlib.zstdCompressSync !== "function") return undefined;
@@ -268,13 +1175,13 @@ function compressBody(body: string): Uint8Array | undefined {
268
1175
  }
269
1176
 
270
1177
  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;
1178
+ if (status === 429 && isTerminalRateLimitError(text)) return false;
1179
+ if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
1180
+ return true;
276
1181
  }
277
- return status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
1182
+ return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(
1183
+ text,
1184
+ );
278
1185
  }
279
1186
 
280
1187
  async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerator<JsonRecord> {
@@ -289,6 +1196,7 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
289
1196
  while (true) {
290
1197
  if (signal?.aborted) throw new Error("Request was aborted");
291
1198
  const { done, value } = await reader.read();
1199
+ if (signal?.aborted) throw new Error("Request was aborted");
292
1200
  if (done) break;
293
1201
  buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
294
1202
  let boundary = buffer.indexOf("\n\n");
@@ -302,7 +1210,16 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
302
1210
  .join("\n")
303
1211
  .trim();
304
1212
  if (data && data !== "[DONE]") {
305
- const parsed = JSON.parse(data) as unknown;
1213
+ let parsed: unknown;
1214
+ try {
1215
+ parsed = JSON.parse(data) as unknown;
1216
+ } catch (error) {
1217
+ throw new CodexProtocolError(
1218
+ `Invalid Codex SSE JSON: ${thrownMessage(error)}`,
1219
+ data,
1220
+ error,
1221
+ );
1222
+ }
306
1223
  if (!isObject(parsed)) throw new Error("Invalid Codex SSE event");
307
1224
  yield parsed;
308
1225
  }
@@ -312,7 +1229,9 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
312
1229
  } finally {
313
1230
  signal?.removeEventListener("abort", onAbort);
314
1231
  await reader.cancel().catch(() => {});
315
- reader.releaseLock();
1232
+ try {
1233
+ reader.releaseLock();
1234
+ } catch {}
316
1235
  }
317
1236
  }
318
1237
 
@@ -331,15 +1250,6 @@ function socketReusable(socket: WebSocketLike): boolean {
331
1250
  return socket.readyState === undefined || socket.readyState === 1;
332
1251
  }
333
1252
 
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
1253
  async function connectWebSocket(
344
1254
  url: string,
345
1255
  headers: Headers,
@@ -347,7 +1257,9 @@ async function connectWebSocket(
347
1257
  timeoutMs: number,
348
1258
  ): Promise<WebSocketLike> {
349
1259
  const WebSocketClass = websocketConstructor();
350
- if (!WebSocketClass) throw new Error("WebSocket transport is unavailable");
1260
+ if (!WebSocketClass) {
1261
+ throw new Error("WebSocket transport is not available in this runtime");
1262
+ }
351
1263
  const requestHeaders = headersToRecord(headers);
352
1264
  delete requestHeaders["OpenAI-Beta"];
353
1265
 
@@ -363,11 +1275,11 @@ async function connectWebSocket(
363
1275
  socket.removeEventListener("close", onClose);
364
1276
  signal?.removeEventListener("abort", onAbort);
365
1277
  };
366
- const fail = (error: Error) => {
1278
+ const fail = (error: Error, closeReason: string) => {
367
1279
  if (settled) return;
368
1280
  settled = true;
369
1281
  cleanup();
370
- closeSocket(socket, "connect_failure");
1282
+ closeSocket(socket, closeReason);
371
1283
  reject(error);
372
1284
  };
373
1285
  const onOpen = () => {
@@ -376,25 +1288,27 @@ async function connectWebSocket(
376
1288
  cleanup();
377
1289
  resolve(socket);
378
1290
  };
379
- const onError = (event: unknown) => fail(new Error(`WebSocket error: ${explain(event)}`));
1291
+ const onError = (event: unknown) => fail(extractWebSocketError(event), "connect_failure");
380
1292
  const onClose = (event: unknown) =>
381
- fail(new Error(`WebSocket closed during connect: ${explain(event)}`));
382
- const onAbort = () => fail(new Error("Request was aborted"));
1293
+ fail(extractWebSocketCloseError(event, "WebSocket closed during connect"), "connect_failure");
1294
+ const onAbort = () => fail(new Error("Request was aborted"), "aborted");
383
1295
 
384
1296
  try {
385
1297
  socket = new WebSocketClass(url, { headers: requestHeaders });
386
1298
  } catch (error) {
387
- reject(error);
1299
+ reject(error instanceof Error ? error : new Error(String(error)));
388
1300
  return;
389
1301
  }
390
1302
  socket.addEventListener("open", onOpen);
391
1303
  socket.addEventListener("error", onError);
392
1304
  socket.addEventListener("close", onClose);
393
1305
  signal?.addEventListener("abort", onAbort, { once: true });
394
- timer = setTimeout(
395
- () => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`)),
396
- timeoutMs,
397
- );
1306
+ if (timeoutMs > 0) {
1307
+ timer = setTimeout(
1308
+ () => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`), "connect_timeout"),
1309
+ timeoutMs,
1310
+ );
1311
+ }
398
1312
  if (signal?.aborted) onAbort();
399
1313
  });
400
1314
  }
@@ -403,97 +1317,211 @@ async function acquireWebSocket(
403
1317
  url: string,
404
1318
  headers: Headers,
405
1319
  sessionId: string | undefined,
1320
+ accountId: string,
406
1321
  signal: AbortSignal | undefined,
407
1322
  timeoutMs: number,
408
- ): Promise<{ socket: WebSocketLike; entry?: CachedWebSocket; release(keep: boolean): void }> {
1323
+ ): Promise<{
1324
+ socket: WebSocketLike;
1325
+ entry?: CachedWebSocket;
1326
+ reused: boolean;
1327
+ release(keep: boolean): void;
1328
+ }> {
1329
+ if (signal?.aborted) throw new Error("Request was aborted");
409
1330
  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)) {
1331
+ let accountEntries = websocketSessions.get(sessionId);
1332
+ const cached = accountEntries?.get(accountId);
1333
+ if (cached && !cached.busy && socketReusable(cached.socket)) {
417
1334
  cached.busy = true;
418
1335
  return {
419
1336
  socket: cached.socket,
420
1337
  entry: cached,
1338
+ reused: true,
421
1339
  release(keep) {
422
1340
  if (!keep || !socketReusable(cached.socket)) {
423
1341
  closeSocket(cached.socket);
424
- websocketSessions.delete(sessionId);
1342
+ const currentEntries = websocketSessions.get(sessionId);
1343
+ if (currentEntries?.get(accountId) === cached) currentEntries.delete(accountId);
1344
+ if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
425
1345
  return;
426
1346
  }
427
1347
  cached.busy = false;
428
- scheduleSocketExpiry(sessionId, cached);
429
1348
  },
430
1349
  };
431
1350
  }
432
1351
  if (cached && !cached.busy) {
433
- closeSocket(cached.socket);
434
- websocketSessions.delete(sessionId);
1352
+ closeSocket(cached.socket, "done");
1353
+ accountEntries?.delete(accountId);
1354
+ if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
1355
+ }
1356
+ if (cached?.busy) {
1357
+ const socket = await connectWebSocket(url, headers, signal, timeoutMs);
1358
+ return { socket, reused: false, release: () => closeSocket(socket) };
1359
+ }
1360
+
1361
+ const socket = await connectWebSocket(url, headers, signal, timeoutMs);
1362
+ const entry: CachedWebSocket = { socket, busy: true };
1363
+ accountEntries = websocketSessions.get(sessionId);
1364
+ if (!accountEntries) {
1365
+ accountEntries = new Map();
1366
+ websocketSessions.set(sessionId, accountEntries);
435
1367
  }
1368
+ accountEntries.set(accountId, entry);
1369
+ return {
1370
+ socket,
1371
+ entry,
1372
+ reused: false,
1373
+ release(keep) {
1374
+ if (!keep || !socketReusable(socket)) {
1375
+ closeSocket(socket);
1376
+ const currentEntries = websocketSessions.get(sessionId);
1377
+ if (currentEntries?.get(accountId) === entry) currentEntries.delete(accountId);
1378
+ if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
1379
+ return;
1380
+ }
1381
+ entry.busy = false;
1382
+ },
1383
+ };
436
1384
  }
437
1385
 
438
1386
  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
- };
1387
+ return { socket, reused: false, release: () => closeSocket(socket) };
457
1388
  }
458
1389
 
459
1390
  function requestWithoutHistory(body: JsonRecord): JsonRecord {
460
- const result = structuredClone(body);
1391
+ const result = { ...body };
461
1392
  delete result.input;
462
1393
  delete result.previous_response_id;
1394
+ delete result.client_metadata;
1395
+ // This controls response delivery only, not the model context retained by
1396
+ // previous_response_id.
1397
+ delete result["stream_options"];
463
1398
  return result;
464
1399
  }
465
1400
 
466
- function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord {
1401
+ function responseItemsMatch(previous: unknown, current: unknown): boolean {
1402
+ if (!isObject(previous) || !isObject(current)) {
1403
+ return stableResponsesJson(previous) === stableResponsesJson(current);
1404
+ }
1405
+ const previousComparable = structuredClone(previous);
1406
+ const currentComparable = structuredClone(current);
1407
+ delete previousComparable["internal_chat_message_metadata_passthrough"];
1408
+ delete currentComparable["internal_chat_message_metadata_passthrough"];
1409
+ return stableResponsesJson(previousComparable) === stableResponsesJson(currentComparable);
1410
+ }
1411
+
1412
+ function jsonWireRequestBody(body: JsonRecord): JsonRecord {
1413
+ const snapshot = JSON.parse(JSON.stringify(body)) as unknown;
1414
+ if (!isObject(snapshot)) {
1415
+ throw new Error("Codex request body must serialize to a JSON object");
1416
+ }
1417
+ return snapshot;
1418
+ }
1419
+
1420
+ type CachedRequestDecision = {
1421
+ body: JsonRecord;
1422
+ contextMode: "full" | "delta";
1423
+ previousResponseId?: string;
1424
+ bypassReason?: CodexContinuationBypassReason;
1425
+ historyMismatch?: CodexContinuationHistoryMismatch;
1426
+ cacheIdentityPreserved?: boolean;
1427
+ };
1428
+
1429
+ type CodexWebSocketAttempt = {
1430
+ connection: "new" | "reused";
1431
+ contextMode: "full" | "delta";
1432
+ inputItems: number;
1433
+ fullInputItems: number;
1434
+ fullRequestBytes: number;
1435
+ wireRequestBytes: number;
1436
+ previousResponseId?: string;
1437
+ bypassReason?: CodexContinuationBypassReason;
1438
+ historyMismatch?: CodexContinuationHistoryMismatch;
1439
+ cacheIdentityPreserved?: boolean;
1440
+ turnStateReplayed?: boolean;
1441
+ turnStateReplayedValue?: string;
1442
+ };
1443
+
1444
+ function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): CachedRequestDecision {
467
1445
  const continuation = entry.continuation;
468
- if (!continuation) return body;
1446
+ if (!continuation) return { body, contextMode: "full" };
1447
+ const previousResponseId = continuation.lastResponseId;
1448
+ const cacheIdentityPreserved =
1449
+ continuation.lastRequestBody.prompt_cache_key === body.prompt_cache_key;
469
1450
  if (
470
1451
  stableResponsesJson(requestWithoutHistory(body)) !==
471
1452
  stableResponsesJson(requestWithoutHistory(continuation.lastRequestBody))
472
1453
  ) {
473
1454
  delete entry.continuation;
474
- return body;
1455
+ return {
1456
+ body,
1457
+ contextMode: "full",
1458
+ previousResponseId,
1459
+ bypassReason: "request_template_changed",
1460
+ cacheIdentityPreserved,
1461
+ };
475
1462
  }
476
1463
 
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
- : [];
1464
+ const currentInput = body.input ?? [];
1465
+ const previousInput = continuation.lastRequestBody.input ?? [];
1466
+ if (!Array.isArray(currentInput) || !Array.isArray(previousInput)) {
1467
+ delete entry.continuation;
1468
+ return {
1469
+ body,
1470
+ contextMode: "full",
1471
+ previousResponseId,
1472
+ bypassReason: "non_array_input",
1473
+ cacheIdentityPreserved,
1474
+ };
1475
+ }
481
1476
  const baseline = [...previousInput, ...continuation.lastResponseItems];
482
- if (
483
- currentInput.length < baseline.length ||
484
- !replayItemsEqual(currentInput.slice(0, baseline.length), baseline)
485
- ) {
1477
+ const mismatchIndex = baseline.findIndex(
1478
+ (item, index) => index >= currentInput.length || !responseItemsMatch(item, currentInput[index]),
1479
+ );
1480
+ if (mismatchIndex >= 0) {
486
1481
  delete entry.continuation;
487
- return body;
1482
+ return {
1483
+ body,
1484
+ contextMode: "full",
1485
+ previousResponseId,
1486
+ bypassReason: "history_prefix_changed",
1487
+ historyMismatch: {
1488
+ index: mismatchIndex,
1489
+ baselineInputItems: baseline.length,
1490
+ currentInputItems: currentInput.length,
1491
+ baselineItem: structuredClone(baseline[mismatchIndex]),
1492
+ ...(mismatchIndex < currentInput.length
1493
+ ? { currentItem: structuredClone(currentInput[mismatchIndex]) }
1494
+ : {}),
1495
+ },
1496
+ cacheIdentityPreserved,
1497
+ };
488
1498
  }
489
1499
 
490
1500
  return {
491
- ...body,
492
- previous_response_id: continuation.lastResponseId,
493
- input: currentInput.slice(baseline.length),
1501
+ body: {
1502
+ ...body,
1503
+ previous_response_id: previousResponseId,
1504
+ input: currentInput.slice(baseline.length),
1505
+ },
1506
+ contextMode: "delta",
1507
+ previousResponseId,
1508
+ cacheIdentityPreserved,
494
1509
  };
495
1510
  }
496
1511
 
1512
+ function requestInputLength(body: JsonRecord): number {
1513
+ return typeof body.input === "string" || Array.isArray(body.input) ? body.input.length : 0;
1514
+ }
1515
+
1516
+ function webSocketEventStartsVisibleOutput(event: JsonRecord): boolean {
1517
+ return (
1518
+ event.type !== "response.created" &&
1519
+ event.type !== "response.queued" &&
1520
+ event.type !== "response.in_progress" &&
1521
+ event.type !== "response.metadata"
1522
+ );
1523
+ }
1524
+
497
1525
  async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
498
1526
  if (typeof data === "string") return data;
499
1527
  if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
@@ -525,9 +1553,10 @@ async function* parseWebSocket(
525
1553
  };
526
1554
  const onMessage = (event: unknown) => {
527
1555
  void (async () => {
1556
+ let text: string | undefined;
528
1557
  try {
529
1558
  if (!isObject(event)) return;
530
- const text = await decodeWebSocketData(event["data"]);
1559
+ text = await decodeWebSocketData(event["data"]);
531
1560
  if (!text) return;
532
1561
  const parsed = JSON.parse(text) as unknown;
533
1562
  if (!isObject(parsed)) throw new Error("Invalid WebSocket event");
@@ -535,7 +1564,8 @@ async function* parseWebSocket(
535
1564
  if (
536
1565
  type === "response.completed" ||
537
1566
  type === "response.done" ||
538
- type === "response.incomplete"
1567
+ type === "response.incomplete" ||
1568
+ type === "response.failed"
539
1569
  ) {
540
1570
  terminal = true;
541
1571
  done = true;
@@ -543,19 +1573,23 @@ async function* parseWebSocket(
543
1573
  queue.push(parsed);
544
1574
  notify();
545
1575
  } catch (error) {
546
- failure = error instanceof Error ? error : new Error(String(error));
1576
+ failure = new CodexProtocolError(
1577
+ `Invalid Codex WebSocket JSON: ${thrownMessage(error)}`,
1578
+ text,
1579
+ error,
1580
+ );
547
1581
  done = true;
548
1582
  notify();
549
1583
  }
550
1584
  })();
551
1585
  };
552
1586
  const onError = (event: unknown) => {
553
- failure = new Error(`WebSocket error: ${explain(event)}`);
1587
+ if (!failure) failure = extractWebSocketError(event);
554
1588
  done = true;
555
1589
  notify();
556
1590
  };
557
1591
  const onClose = (event: unknown) => {
558
- if (!terminal) failure = new Error(`WebSocket closed: ${explain(event)}`);
1592
+ if (!terminal && !failure) failure = extractWebSocketCloseError(event);
559
1593
  done = true;
560
1594
  notify();
561
1595
  };
@@ -571,6 +1605,7 @@ async function* parseWebSocket(
571
1605
  signal?.addEventListener("abort", onAbort, { once: true });
572
1606
  try {
573
1607
  while (true) {
1608
+ if (signal?.aborted) throw new Error("Request was aborted");
574
1609
  if (queue.length > 0) {
575
1610
  yield queue.shift()!;
576
1611
  continue;
@@ -579,10 +1614,14 @@ async function* parseWebSocket(
579
1614
  await new Promise<void>((resolve, reject) => {
580
1615
  wake = resolve;
581
1616
  if (timeoutMs !== undefined && timeoutMs > 0) {
582
- const timer = setTimeout(
583
- () => reject(new Error(`WebSocket idle timeout after ${timeoutMs}ms`)),
584
- timeoutMs,
585
- );
1617
+ const timer = setTimeout(() => {
1618
+ const error = new Error(`WebSocket idle timeout after ${timeoutMs}ms`);
1619
+ failure = error;
1620
+ done = true;
1621
+ wake = undefined;
1622
+ closeSocket(socket, "idle_timeout");
1623
+ reject(error);
1624
+ }, timeoutMs);
586
1625
  const priorWake = wake;
587
1626
  wake = () => {
588
1627
  clearTimeout(timer);
@@ -592,7 +1631,7 @@ async function* parseWebSocket(
592
1631
  });
593
1632
  }
594
1633
  if (failure) throw failure;
595
- if (!terminal) throw new Error("WebSocket ended without a terminal response");
1634
+ if (!terminal) throw new Error("WebSocket stream closed before response.completed");
596
1635
  } finally {
597
1636
  socket.removeEventListener("message", onMessage);
598
1637
  socket.removeEventListener("error", onError);
@@ -601,23 +1640,27 @@ async function* parseWebSocket(
601
1640
  }
602
1641
  }
603
1642
 
604
- function normalizeEvent(event: JsonRecord): JsonRecord {
605
- const type = event.type;
1643
+ function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
1644
+ const type = typeof event.type === "string" ? event.type : undefined;
1645
+ if (!type) return undefined;
606
1646
  if (type === "error") {
607
1647
  const nested = isObject(event["error"]) ? event["error"] : undefined;
608
- throw new CodexResponseError(
1648
+ const code =
1649
+ typeof event["code"] === "string"
1650
+ ? event["code"]
1651
+ : typeof nested?.["code"] === "string"
1652
+ ? nested["code"]
1653
+ : undefined;
1654
+ const message =
609
1655
  typeof event["message"] === "string"
610
1656
  ? event["message"]
611
1657
  : typeof nested?.["message"] === "string"
612
1658
  ? 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",
1659
+ : undefined;
1660
+ throw new CodexApiError(
1661
+ `Codex error: ${message || code || JSON.stringify(event)}`,
1662
+ code,
1663
+ event,
621
1664
  );
622
1665
  }
623
1666
  if (type === "response.done") {
@@ -626,60 +1669,108 @@ function normalizeEvent(event: JsonRecord): JsonRecord {
626
1669
  return event;
627
1670
  }
628
1671
 
1672
+ function isTerminalEvent(event: JsonRecord): boolean {
1673
+ return (
1674
+ event.type === "response.completed" ||
1675
+ event.type === "response.incomplete" ||
1676
+ event.type === "response.failed"
1677
+ );
1678
+ }
1679
+
629
1680
  async function* requestSse(
630
1681
  model: Model<any>,
631
- body: JsonRecord,
1682
+ bodyJson: string,
632
1683
  options: CodexTransportOptions,
633
1684
  headers: Headers,
1685
+ timeoutMs: number | undefined,
1686
+ onAttempt: (turnStateReplayedValue: string | undefined) => void,
634
1687
  ): AsyncGenerator<JsonRecord> {
635
- const bodyJson = JSON.stringify(body);
636
1688
  const compressed = compressBody(bodyJson);
637
1689
  if (compressed) headers.set("content-encoding", "zstd");
638
1690
  const requestBody = compressed ?? bodyJson;
639
1691
  const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1692
+ let response: Response | undefined;
1693
+ let lastError: Error | undefined;
640
1694
 
641
1695
  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;
1696
+ if (options.signal?.aborted) throw new Error("Request was aborted");
648
1697
  try {
1698
+ onAttempt(applyTurnStateHeader(headers, options.turnState));
1699
+ const timeoutSignal =
1700
+ timeoutMs !== undefined && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
1701
+ const combined = combineAbortSignals([options.signal, timeoutSignal]);
649
1702
  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;
1703
+ try {
1704
+ response = await (options.fetch ?? globalThis.fetch)(resolveCodexUrl(model.baseUrl), {
1705
+ method: "POST",
1706
+ headers,
1707
+ body: requestBody,
1708
+ ...(combined.signal ? { signal: combined.signal } : {}),
1709
+ });
1710
+ } catch (error) {
1711
+ if (timeoutSignal?.aborted && !options.signal?.aborted) {
1712
+ throw new Error(`Codex SSE response headers timed out after ${String(timeoutMs)}ms`);
1713
+ }
1714
+ throw error;
660
1715
  }
661
- throw error;
1716
+ } finally {
1717
+ combined.cleanup();
662
1718
  }
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) {
1719
+ captureTurnStateHeader(response.headers, options.turnState);
1720
+ await options.onResponse?.(
1721
+ { status: response.status, headers: headersToRecord(response.headers) },
1722
+ model,
1723
+ );
1724
+ if (response.ok) break;
1725
+
671
1726
  const errorText = await response.text();
672
1727
  if (attempt < maxRetries && isRetryable(response.status, errorText)) {
673
- await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
1728
+ const requestedDelay = retryDelayMs(response.headers);
1729
+ const delay =
1730
+ requestedDelay === undefined
1731
+ ? retryBackoffMs(BASE_DELAY_MS, attempt + 1)
1732
+ : validateRetryDelay(requestedDelay, options);
1733
+ await sleep(delay, options.signal);
674
1734
  continue;
675
1735
  }
676
- throw new Error(errorText || `Codex request failed with status ${response.status}`);
1736
+ throw codexHttpError(response.status, response.statusText, errorText);
1737
+ } catch (error) {
1738
+ if (
1739
+ options.signal?.aborted ||
1740
+ (error instanceof Error &&
1741
+ (error.name === "AbortError" || error.message === "Request was aborted"))
1742
+ ) {
1743
+ throw new Error("Request was aborted");
1744
+ }
1745
+ lastError = error instanceof Error ? error : new Error(String(error));
1746
+ if (
1747
+ attempt < maxRetries &&
1748
+ !(lastError instanceof CodexHttpError) &&
1749
+ !(lastError instanceof RetryDelayExceededError) &&
1750
+ !lastError.message.includes("usage limit")
1751
+ ) {
1752
+ await sleep(retryBackoffMs(BASE_DELAY_MS, attempt + 1), options.signal);
1753
+ continue;
1754
+ }
1755
+ throw lastError;
677
1756
  }
678
- for await (const event of parseSse(response, options.signal)) {
679
- yield normalizeEvent(event);
1757
+ }
1758
+
1759
+ if (!response?.ok) throw lastError ?? new Error("Failed after retries");
1760
+ if (!response.body) throw new Error("No response body");
1761
+ options.onTransportStart?.();
1762
+ let terminal = false;
1763
+ for await (const event of parseSse(response, options.signal)) {
1764
+ const normalized = normalizeEvent(event);
1765
+ if (!normalized) continue;
1766
+ yield normalized;
1767
+ if (isTerminalEvent(normalized)) {
1768
+ terminal = true;
1769
+ break;
680
1770
  }
681
- return;
682
1771
  }
1772
+ if (!terminal)
1773
+ throw new SseStreamIncompleteError("Codex SSE stream ended before a terminal event");
683
1774
  }
684
1775
 
685
1776
  async function* requestWebSocket(
@@ -688,33 +1779,136 @@ async function* requestWebSocket(
688
1779
  options: CodexTransportOptions,
689
1780
  headers: Headers,
690
1781
  sessionId: string | undefined,
1782
+ accountId: string,
1783
+ timeoutMs: number | undefined,
1784
+ connectTimeoutMs: number,
1785
+ requestBytes: number,
1786
+ onAttempt: (attempt: CodexWebSocketAttempt) => void,
691
1787
  ): AsyncGenerator<JsonRecord> {
692
1788
  const acquired = await acquireWebSocket(
693
1789
  resolveCodexWebSocketUrl(model.baseUrl),
694
1790
  headers,
695
1791
  sessionId,
1792
+ accountId,
696
1793
  options.signal,
697
- options.websocketConnectTimeoutMs ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS,
1794
+ connectTimeoutMs,
698
1795
  );
699
- let keep = true;
1796
+ let keep = false;
1797
+ let responseHandleActive = true;
1798
+ const discard = (): boolean => {
1799
+ if (!responseHandleActive) return false;
1800
+ responseHandleActive = false;
1801
+ if (acquired.entry) delete acquired.entry.continuation;
1802
+ acquired.release(false);
1803
+ return true;
1804
+ };
700
1805
  try {
1806
+ if (options.signal?.aborted) throw new Error("Request was aborted");
701
1807
  const useContinuation =
702
1808
  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 }));
1809
+ const routed = withTurnStateMetadata(body, options.turnState);
1810
+ const fullBody =
1811
+ useContinuation && acquired.entry ? jsonWireRequestBody(routed.body) : routed.body;
1812
+ const decision =
1813
+ useContinuation && acquired.entry
1814
+ ? cachedRequestBody(acquired.entry, fullBody)
1815
+ : ({ body: fullBody, contextMode: "full" } satisfies CachedRequestDecision);
1816
+ const requestBody = decision.body;
1817
+ const warmup = options.warmup === true;
1818
+ const requestStartedAtUnixMs = String(Date.now());
1819
+ const withRequestStart = (candidate: JsonRecord): JsonRecord => ({
1820
+ ...candidate,
1821
+ client_metadata: {
1822
+ ...(isObject(candidate.client_metadata) ? candidate.client_metadata : {}),
1823
+ [CODEX_WS_REQUEST_START_METADATA_KEY]: requestStartedAtUnixMs,
1824
+ },
1825
+ });
1826
+ const fullRequestJson = JSON.stringify({
1827
+ type: "response.create",
1828
+ ...withRequestStart(fullBody),
1829
+ ...(warmup ? { generate: false } : {}),
1830
+ });
1831
+ const wireRequestJson = JSON.stringify({
1832
+ type: "response.create",
1833
+ ...withRequestStart(requestBody),
1834
+ ...(warmup ? { generate: false } : {}),
1835
+ });
1836
+ onAttempt({
1837
+ connection: acquired.reused ? "reused" : "new",
1838
+ contextMode: decision.contextMode,
1839
+ inputItems: requestInputLength(requestBody),
1840
+ fullInputItems: requestInputLength(fullBody),
1841
+ fullRequestBytes: serializedBytes(fullRequestJson),
1842
+ wireRequestBytes: serializedBytes(wireRequestJson),
1843
+ ...(decision.previousResponseId ? { previousResponseId: decision.previousResponseId } : {}),
1844
+ ...(decision.bypassReason ? { bypassReason: decision.bypassReason } : {}),
1845
+ ...(decision.historyMismatch ? { historyMismatch: decision.historyMismatch } : {}),
1846
+ ...(decision.cacheIdentityPreserved === undefined
1847
+ ? {}
1848
+ : { cacheIdentityPreserved: decision.cacheIdentityPreserved }),
1849
+ ...(options.turnState
1850
+ ? {
1851
+ turnStateReplayed: routed.replayedValue !== undefined,
1852
+ ...(routed.replayedValue ? { turnStateReplayedValue: routed.replayedValue } : {}),
1853
+ }
1854
+ : {}),
1855
+ });
1856
+ const stats = sessionId ? getOrCreateWebSocketDebugStats(sessionId) : undefined;
1857
+ if (stats) {
1858
+ stats.requests += 1;
1859
+ if (warmup) stats.prewarmRequests += 1;
1860
+ if (acquired.reused) stats.connectionsReused += 1;
1861
+ else stats.connectionsCreated += 1;
1862
+ if (useContinuation) stats.cachedContextRequests += 1;
1863
+ if (requestBody.store === true) stats.storeTrueRequests += 1;
1864
+ stats.lastInputItems = requestInputLength(requestBody);
1865
+ if (requestBody.previous_response_id) {
1866
+ stats.deltaRequests += 1;
1867
+ stats.lastDeltaInputItems = requestInputLength(requestBody);
1868
+ stats.lastPreviousResponseId = requestBody.previous_response_id as string;
1869
+ } else {
1870
+ stats.fullContextRequests += 1;
1871
+ delete stats.lastDeltaInputItems;
1872
+ delete stats.lastPreviousResponseId;
1873
+ }
1874
+ }
1875
+ options.onWebSocketResponseHandle?.({
1876
+ discard,
1877
+ failParsing(error) {
1878
+ if (!discard()) return false;
1879
+ options.onTransportDiagnostic?.(
1880
+ transportDiagnostic(error, options.transport ?? "auto", true, requestBytes),
1881
+ );
1882
+ return true;
1883
+ },
1884
+ });
1885
+ acquired.socket.send(wireRequestJson);
706
1886
  const responseItems: JsonRecord[] = [];
707
1887
  let responseId: string | undefined;
708
- for await (const event of parseWebSocket(acquired.socket, options.signal, options.timeoutMs)) {
1888
+ let responseCompleted = false;
1889
+ for await (const event of parseWebSocket(acquired.socket, options.signal, timeoutMs)) {
1890
+ captureTurnStateEvent(event, options.turnState);
1891
+ if (event.type === "response.metadata") continue;
1892
+ if (
1893
+ event.type === "response.created" &&
1894
+ isObject(event.response) &&
1895
+ typeof event.response.id === "string"
1896
+ ) {
1897
+ responseId = event.response.id;
1898
+ }
709
1899
  if (event.type === "response.output_item.done" && isObject(event.item)) {
710
1900
  responseItems.push(structuredClone(event.item));
711
1901
  }
712
1902
  if (
713
1903
  (event.type === "response.completed" ||
714
1904
  event.type === "response.done" ||
715
- event.type === "response.incomplete") &&
1905
+ event.type === "response.incomplete" ||
1906
+ event.type === "response.failed") &&
716
1907
  isObject(event.response)
717
1908
  ) {
1909
+ if (event.type === "response.completed" || event.type === "response.done") {
1910
+ responseCompleted = true;
1911
+ }
718
1912
  if (typeof event.response.id === "string") responseId = event.response.id;
719
1913
  if (Array.isArray(event.response["output"])) {
720
1914
  const terminalItems = event.response["output"].filter(isObject);
@@ -727,20 +1921,43 @@ async function* requestWebSocket(
727
1921
  }
728
1922
  }
729
1923
  }
730
- yield normalizeEvent(event);
1924
+ const normalized = normalizeEvent(event);
1925
+ if (!normalized) continue;
1926
+ yield normalized;
1927
+ if (isTerminalEvent(normalized)) break;
731
1928
  }
732
- if (useContinuation && acquired.entry && responseId) {
733
- acquired.entry.continuation = {
734
- lastRequestBody: structuredClone(body),
1929
+ if (options.signal?.aborted) throw new Error("Request was aborted");
1930
+ if (useContinuation && acquired.entry && responseId && responseCompleted) {
1931
+ const entry = acquired.entry;
1932
+ const continuation = {
1933
+ lastRequestBody: fullBody,
735
1934
  lastResponseId: responseId,
736
1935
  lastResponseItems: responseItems.map(normalizeReplayItem),
737
1936
  };
1937
+ entry.continuation = continuation;
1938
+ if (sessionId) {
1939
+ options.onContinuationReady?.({
1940
+ responseId,
1941
+ replaceResponseItems(items) {
1942
+ if (
1943
+ websocketSessions.get(sessionId)?.get(accountId) !== entry ||
1944
+ entry.continuation !== continuation ||
1945
+ continuation.lastResponseId !== responseId
1946
+ ) {
1947
+ return false;
1948
+ }
1949
+ continuation.lastResponseItems = items.map((item) => structuredClone(item));
1950
+ return true;
1951
+ },
1952
+ });
1953
+ }
738
1954
  }
1955
+ keep = true;
739
1956
  } catch (error) {
740
- if (acquired.entry) delete acquired.entry.continuation;
741
- keep = false;
1957
+ responseHandleActive = false;
742
1958
  throw error;
743
1959
  } finally {
1960
+ if (!keep && acquired.entry) delete acquired.entry.continuation;
744
1961
  acquired.release(keep);
745
1962
  }
746
1963
  }
@@ -755,7 +1972,7 @@ export async function requestCodexJson(
755
1972
  model.headers,
756
1973
  options.headers,
757
1974
  options.extraHeaders,
758
- extractAccountId(options.apiKey),
1975
+ validateCodexAuthentication(model, options.apiKey),
759
1976
  options.apiKey,
760
1977
  );
761
1978
  const response = await (options.fetch ?? globalThis.fetch)(
@@ -769,7 +1986,7 @@ export async function requestCodexJson(
769
1986
  );
770
1987
  const responseText = await response.text();
771
1988
  if (!response.ok) {
772
- throw new Error(responseText || `Codex request failed with status ${response.status}`);
1989
+ throw codexHttpError(response.status, response.statusText, responseText);
773
1990
  }
774
1991
  try {
775
1992
  return JSON.parse(responseText) as unknown;
@@ -783,73 +2000,452 @@ export async function requestCodexJson(
783
2000
  }
784
2001
 
785
2002
  export class CodexTransport {
2003
+ async prewarm(
2004
+ model: Model<any>,
2005
+ body: JsonRecord,
2006
+ options: CodexTransportOptions,
2007
+ ): Promise<boolean> {
2008
+ const transport = options.transport ?? "auto";
2009
+ const turnStateAvailableAtStart = options.turnState?.available ?? false;
2010
+ const turnStateRevisionAtStart = options.turnState?.revision ?? 0;
2011
+ const turnStateValueAtStart = options.turnState?.replayValue();
2012
+ const diagnosticDetails = () => {
2013
+ const turnStateReceived =
2014
+ (options.turnState?.revision ?? turnStateRevisionAtStart) > turnStateRevisionAtStart;
2015
+ const turnStateReceivedValue = options.turnState?.replayValue();
2016
+ return {
2017
+ ...(options.cacheDiagnostics ? { cache: options.cacheDiagnostics } : {}),
2018
+ ...(options.turnState
2019
+ ? {
2020
+ turnStateAvailableAtStart,
2021
+ turnStateReceived,
2022
+ ...(turnStateValueAtStart ? { turnStateAtStart: turnStateValueAtStart } : {}),
2023
+ ...(turnStateReceived && turnStateReceivedValue ? { turnStateReceivedValue } : {}),
2024
+ }
2025
+ : {}),
2026
+ };
2027
+ };
2028
+ if (transport === "sse") {
2029
+ options.onTransportDiagnostic?.({
2030
+ type: "codex_transport_prewarm",
2031
+ timestamp: Date.now(),
2032
+ details: {
2033
+ outcome: "skipped",
2034
+ continuationReady: false,
2035
+ reason: "sse_configured",
2036
+ ...diagnosticDetails(),
2037
+ },
2038
+ });
2039
+ return false;
2040
+ }
2041
+ const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
2042
+ if (isWebSocketSseFallbackActive(cacheSessionId)) {
2043
+ options.onTransportDiagnostic?.({
2044
+ type: "codex_transport_prewarm",
2045
+ timestamp: Date.now(),
2046
+ details: {
2047
+ outcome: "skipped",
2048
+ continuationReady: false,
2049
+ reason: "sticky_sse_fallback",
2050
+ ...diagnosticDetails(),
2051
+ },
2052
+ });
2053
+ return false;
2054
+ }
2055
+
2056
+ let continuationReady = false;
2057
+ const notifyContinuationReady = (handle: CodexContinuationHandle): void => {
2058
+ options.onContinuationReady?.(handle);
2059
+ };
2060
+ try {
2061
+ for await (const _event of this.request(model, body, {
2062
+ ...options,
2063
+ warmup: true,
2064
+ requestKind: "prewarm",
2065
+ onContinuationReady(handle) {
2066
+ continuationReady = true;
2067
+ notifyContinuationReady(handle);
2068
+ },
2069
+ })) {
2070
+ // A v2 warmup completes without model output.
2071
+ }
2072
+ } catch (error) {
2073
+ options.onTransportDiagnostic?.({
2074
+ type: "codex_transport_prewarm",
2075
+ timestamp: Date.now(),
2076
+ details: {
2077
+ outcome: "failed",
2078
+ continuationReady: false,
2079
+ ...diagnosticDetails(),
2080
+ },
2081
+ });
2082
+ throw error;
2083
+ }
2084
+ options.onTransportDiagnostic?.({
2085
+ type: "codex_transport_prewarm",
2086
+ timestamp: Date.now(),
2087
+ details: {
2088
+ outcome: "completed",
2089
+ continuationReady,
2090
+ ...diagnosticDetails(),
2091
+ },
2092
+ });
2093
+ return continuationReady;
2094
+ }
2095
+
786
2096
  async *request(
787
2097
  model: Model<any>,
788
2098
  body: JsonRecord,
789
2099
  options: CodexTransportOptions,
790
2100
  ): AsyncGenerator<JsonRecord> {
791
2101
  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;
2102
+ const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
2103
+ const connectTimeoutMs =
2104
+ normalizeTimeoutMs(options.websocketConnectTimeoutMs) ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
2105
+ const sseBody = responsesLiteSsePayload(body);
2106
+ const accountId = options.accountId ?? validateCodexAuthentication(model, options.apiKey);
2107
+ const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
2108
+ const requestId = codexCacheKey(cacheSessionId);
794
2109
  const transport = options.transport ?? "auto";
2110
+ const turnStateAvailableAtStart = options.turnState?.available ?? false;
2111
+ const turnStateRevisionAtStart = options.turnState?.revision ?? 0;
2112
+ const turnStateValueAtStart = options.turnState?.replayValue();
2113
+ const websocketMaxRetries = normalizeRetryCount(
2114
+ options.websocketMaxRetries,
2115
+ DEFAULT_WEBSOCKET_MAX_RETRIES,
2116
+ "websocketMaxRetries",
2117
+ );
2118
+ const websocketRetryBaseDelayMs = normalizeRetryCount(
2119
+ options.websocketRetryBaseDelayMs,
2120
+ DEFAULT_WEBSOCKET_RETRY_BASE_DELAY_MS,
2121
+ "websocketRetryBaseDelayMs",
2122
+ );
2123
+ const sseStreamMaxRetries = normalizeRetryCount(
2124
+ options.sseStreamMaxRetries,
2125
+ DEFAULT_SSE_STREAM_MAX_RETRIES,
2126
+ "sseStreamMaxRetries",
2127
+ );
2128
+ const sseStreamRetryBaseDelayMs = normalizeRetryCount(
2129
+ options.sseStreamRetryBaseDelayMs,
2130
+ DEFAULT_SSE_STREAM_RETRY_BASE_DELAY_MS,
2131
+ "sseStreamRetryBaseDelayMs",
2132
+ );
795
2133
 
796
- const websocketDisabled =
797
- transport === "auto" && sessionId !== undefined && websocketFallbackSessions.has(sessionId);
2134
+ const websocketDisabled = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
2135
+ let sseRecovery:
2136
+ | {
2137
+ trigger: "sse_after_websocket_failure" | "sticky_sse_after_websocket_failure";
2138
+ previousCacheIdentity: CacheIdentitySnapshot;
2139
+ }
2140
+ | undefined;
2141
+ const existingFallback = webSocketFallbackSession(cacheSessionId);
2142
+ if (websocketDisabled && existingFallback) {
2143
+ sseRecovery = {
2144
+ trigger: "sticky_sse_after_websocket_failure",
2145
+ previousCacheIdentity: existingFallback.cacheIdentity,
2146
+ };
2147
+ }
2148
+ if (websocketDisabled) recordWebSocketSseFallback(cacheSessionId);
798
2149
  if (transport !== "sse" && !websocketDisabled) {
2150
+ const websocketRequestBytes = serializedBytes(JSON.stringify(body));
799
2151
  const headers = websocketHeaders(
800
2152
  model.headers,
801
2153
  options.headers,
802
2154
  accountId,
803
2155
  options.apiKey,
804
- sessionId ?? crypto.randomUUID(),
2156
+ requestId || uuidv7(),
2157
+ body,
805
2158
  );
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;
2159
+ const webSocketCacheIdentity = cacheIdentitySnapshot(body, headers, accountId);
2160
+ let retriedConnectionLimit = false;
2161
+ let retriedMissingContinuation = false;
2162
+ let websocketRetries = 0;
2163
+ let visibleOutputEmitted = false;
2164
+ let anyEventEmitted = false;
2165
+ while (true) {
2166
+ let emitted = false;
2167
+ let attempt: CodexWebSocketAttempt | undefined;
2168
+ let usage: CodexCacheUsageDiagnostic | undefined;
2169
+ let responseId: string | undefined;
2170
+ try {
2171
+ for await (const event of requestWebSocket(
2172
+ model,
2173
+ body,
2174
+ options,
2175
+ headers,
2176
+ cacheSessionId,
2177
+ accountId,
2178
+ timeoutMs,
2179
+ connectTimeoutMs,
2180
+ websocketRequestBytes,
2181
+ (currentAttempt) => {
2182
+ attempt = currentAttempt;
2183
+ if (!currentAttempt.bypassReason) return;
2184
+ options.onTransportDiagnostic?.(
2185
+ transportRecoveryDiagnostic({
2186
+ trigger: "local_continuation_bypass",
2187
+ configuredTransport: transport,
2188
+ attempts: [webSocketRecoveryAttempt(currentAttempt, "selected")],
2189
+ cacheIdentity: webSocketCacheIdentity,
2190
+ ...(currentAttempt.previousResponseId
2191
+ ? { previousResponseId: currentAttempt.previousResponseId }
2192
+ : {}),
2193
+ continuationBypassReason: currentAttempt.bypassReason,
2194
+ ...(currentAttempt.historyMismatch
2195
+ ? { historyMismatch: currentAttempt.historyMismatch }
2196
+ : {}),
2197
+ ...(currentAttempt.cacheIdentityPreserved === undefined
2198
+ ? {}
2199
+ : { cacheIdentityPreserved: currentAttempt.cacheIdentityPreserved }),
2200
+ accountIdentityPreserved: true,
2201
+ }),
2202
+ );
2203
+ },
2204
+ )) {
2205
+ if (!emitted) options.onTransportStart?.();
2206
+ emitted = true;
2207
+ anyEventEmitted = true;
2208
+ if (webSocketEventStartsVisibleOutput(event)) visibleOutputEmitted = true;
2209
+ usage = cacheUsageDiagnostic(event) ?? usage;
2210
+ if (isObject(event.response) && typeof event.response.id === "string") {
2211
+ responseId = event.response.id;
2212
+ }
2213
+ yield event;
2214
+ }
2215
+ if (attempt && shouldReportRequestDiagnostic(options)) {
2216
+ options.onTransportDiagnostic?.(
2217
+ transportRequestDiagnostic({
2218
+ requestOptions: options,
2219
+ body,
2220
+ configuredTransport: transport,
2221
+ selectedTransport: "websocket",
2222
+ attempt,
2223
+ cacheIdentity: webSocketCacheIdentity,
2224
+ turnStateAvailableAtStart,
2225
+ turnStateRevisionAtStart,
2226
+ ...(turnStateValueAtStart ? { turnStateValueAtStart } : {}),
2227
+ ...(responseId ? { responseId } : {}),
2228
+ ...(usage ? { usage } : {}),
2229
+ }),
2230
+ );
2231
+ }
2232
+ return;
2233
+ } catch (error) {
2234
+ const aborted = options.signal?.aborted;
2235
+ const connectionLimitBeforeStart =
2236
+ !emitted && isWebSocketConnectionLimitReachedError(error);
2237
+ if (!aborted && isPreviousResponseNotFoundError(error) && !retriedMissingContinuation) {
2238
+ retriedMissingContinuation = true;
2239
+ if (attempt) {
2240
+ const retryTurnStateValue = options.turnState?.replayValue();
2241
+ options.onTransportDiagnostic?.(
2242
+ transportRecoveryDiagnostic({
2243
+ trigger: "previous_response_not_found",
2244
+ configuredTransport: transport,
2245
+ attempts: [
2246
+ webSocketRecoveryAttempt(attempt, "previous_response_not_found"),
2247
+ {
2248
+ transport: "websocket",
2249
+ connection: "new",
2250
+ contextMode: "full",
2251
+ inputItems: attempt.fullInputItems,
2252
+ fullInputItems: attempt.fullInputItems,
2253
+ fullRequestBytes: attempt.fullRequestBytes,
2254
+ wireRequestBytes: attempt.fullRequestBytes,
2255
+ outcome: "retry_scheduled",
2256
+ ...(options.turnState
2257
+ ? {
2258
+ turnStateReplayed: options.turnState.available,
2259
+ ...(retryTurnStateValue
2260
+ ? { turnStateReplayedValue: retryTurnStateValue }
2261
+ : {}),
2262
+ }
2263
+ : {}),
2264
+ },
2265
+ ],
2266
+ cacheIdentity: webSocketCacheIdentity,
2267
+ error,
2268
+ ...(attempt.previousResponseId
2269
+ ? { previousResponseId: attempt.previousResponseId }
2270
+ : {}),
2271
+ cacheIdentityPreserved: true,
2272
+ accountIdentityPreserved: true,
2273
+ }),
2274
+ );
2275
+ }
2276
+ continue;
2277
+ }
2278
+ if (!aborted && connectionLimitBeforeStart && !retriedConnectionLimit) {
2279
+ retriedConnectionLimit = true;
2280
+ continue;
2281
+ }
2282
+ const retryableTransportError =
2283
+ !aborted &&
2284
+ !visibleOutputEmitted &&
2285
+ (!isCodexNonTransportError(error) || connectionLimitBeforeStart);
2286
+ if (retryableTransportError && websocketRetries < websocketMaxRetries) {
2287
+ websocketRetries += 1;
2288
+ if (attempt) {
2289
+ options.onTransportDiagnostic?.(
2290
+ transportRecoveryDiagnostic({
2291
+ trigger: "websocket_retry",
2292
+ configuredTransport: transport,
2293
+ attempts: [webSocketRecoveryAttempt(attempt, "retry_scheduled")],
2294
+ cacheIdentity: webSocketCacheIdentity,
2295
+ error,
2296
+ cacheIdentityPreserved: true,
2297
+ accountIdentityPreserved: true,
2298
+ retryNumber: websocketRetries,
2299
+ maxRetries: websocketMaxRetries,
2300
+ }),
2301
+ );
2302
+ }
2303
+ await sleep(
2304
+ retryBackoffMs(websocketRetryBaseDelayMs, websocketRetries),
2305
+ options.signal,
2306
+ );
2307
+ continue;
2308
+ }
2309
+ if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
2310
+ throw error;
2311
+ }
2312
+ options.onTransportDiagnostic?.(
2313
+ transportDiagnostic(
2314
+ error,
2315
+ transport,
2316
+ anyEventEmitted,
2317
+ websocketRequestBytes,
2318
+ !visibleOutputEmitted,
2319
+ ),
2320
+ );
2321
+ recordWebSocketFailure(cacheSessionId, error, webSocketCacheIdentity);
2322
+ if (visibleOutputEmitted) throw error;
2323
+ recordWebSocketSseFallback(cacheSessionId);
2324
+ if (options.warmup) throw error;
2325
+ sseRecovery = {
2326
+ trigger: "sse_after_websocket_failure",
2327
+ previousCacheIdentity: webSocketCacheIdentity,
2328
+ };
2329
+ break;
821
2330
  }
822
- if (sessionId) websocketFallbackSessions.add(sessionId);
823
2331
  }
824
2332
  }
825
2333
 
2334
+ const bodyJson = JSON.stringify(sseBody);
2335
+ const sseRequestBytes = serializedBytes(bodyJson);
826
2336
  const headers = sseHeaders(
827
2337
  model.headers,
828
2338
  options.headers,
829
2339
  accountId,
830
2340
  options.apiKey,
831
- sessionId,
2341
+ requestId,
2342
+ body,
832
2343
  );
833
- yield* requestSse(model, body, options, headers);
2344
+ if (sseRecovery) {
2345
+ const sseCacheIdentity = cacheIdentitySnapshot(sseBody, headers, accountId);
2346
+ options.onTransportDiagnostic?.(
2347
+ transportRecoveryDiagnostic({
2348
+ trigger: sseRecovery.trigger,
2349
+ configuredTransport: transport,
2350
+ attempts: [
2351
+ sseRecoveryAttempt(sseBody, sseRequestBytes, options.turnState?.replayValue()),
2352
+ ],
2353
+ cacheIdentity: sseCacheIdentity,
2354
+ previousCacheIdentity: sseRecovery.previousCacheIdentity,
2355
+ }),
2356
+ );
2357
+ }
2358
+ const sseCacheIdentity = cacheIdentitySnapshot(sseBody, headers, accountId);
2359
+ let sseTurnStateReplayedValue: string | undefined;
2360
+ let usage: CodexCacheUsageDiagnostic | undefined;
2361
+ let responseId: string | undefined;
2362
+ let sseStreamRetries = 0;
2363
+ while (true) {
2364
+ let visibleOutputEmitted = false;
2365
+ try {
2366
+ for await (const event of requestSse(
2367
+ model,
2368
+ bodyJson,
2369
+ options,
2370
+ headers,
2371
+ timeoutMs,
2372
+ (replayedValue) => {
2373
+ sseTurnStateReplayedValue = replayedValue;
2374
+ },
2375
+ )) {
2376
+ if (webSocketEventStartsVisibleOutput(event)) visibleOutputEmitted = true;
2377
+ usage = cacheUsageDiagnostic(event) ?? usage;
2378
+ if (isObject(event.response) && typeof event.response.id === "string") {
2379
+ responseId = event.response.id;
2380
+ }
2381
+ yield event;
2382
+ }
2383
+ break;
2384
+ } catch (error) {
2385
+ const retryableError =
2386
+ error instanceof CodexHttpError ? error.retryable : !isCodexNonTransportError(error);
2387
+ const retryable =
2388
+ !options.signal?.aborted &&
2389
+ !visibleOutputEmitted &&
2390
+ retryableError &&
2391
+ thrownMessage(error) !== "Request was aborted" &&
2392
+ !(error instanceof RetryDelayExceededError);
2393
+ if (!retryable || sseStreamRetries >= sseStreamMaxRetries) throw error;
2394
+ sseStreamRetries += 1;
2395
+ options.onTransportDiagnostic?.(
2396
+ transportRecoveryDiagnostic({
2397
+ trigger: "sse_stream_retry",
2398
+ configuredTransport: transport,
2399
+ attempts: [
2400
+ {
2401
+ ...sseRecoveryAttempt(sseBody, sseRequestBytes, sseTurnStateReplayedValue),
2402
+ outcome: "retry_scheduled",
2403
+ },
2404
+ ],
2405
+ cacheIdentity: sseCacheIdentity,
2406
+ error,
2407
+ cacheIdentityPreserved: true,
2408
+ accountIdentityPreserved: true,
2409
+ retryNumber: sseStreamRetries,
2410
+ maxRetries: sseStreamMaxRetries,
2411
+ }),
2412
+ );
2413
+ await sleep(retryBackoffMs(sseStreamRetryBaseDelayMs, sseStreamRetries), options.signal);
2414
+ }
2415
+ }
2416
+ if (shouldReportRequestDiagnostic(options)) {
2417
+ const inputItems = requestInputLength(sseBody);
2418
+ options.onTransportDiagnostic?.(
2419
+ transportRequestDiagnostic({
2420
+ requestOptions: options,
2421
+ body: sseBody,
2422
+ configuredTransport: transport,
2423
+ selectedTransport: "sse",
2424
+ attempt: {
2425
+ contextMode: "full",
2426
+ inputItems,
2427
+ fullInputItems: inputItems,
2428
+ fullRequestBytes: sseRequestBytes,
2429
+ wireRequestBytes: sseRequestBytes,
2430
+ turnStateReplayed: sseTurnStateReplayedValue !== undefined,
2431
+ ...(sseTurnStateReplayedValue
2432
+ ? { turnStateReplayedValue: sseTurnStateReplayedValue }
2433
+ : {}),
2434
+ },
2435
+ cacheIdentity: sseCacheIdentity,
2436
+ turnStateAvailableAtStart,
2437
+ turnStateRevisionAtStart,
2438
+ ...(turnStateValueAtStart ? { turnStateValueAtStart } : {}),
2439
+ ...(responseId ? { responseId } : {}),
2440
+ ...(usage ? { usage } : {}),
2441
+ }),
2442
+ );
2443
+ }
834
2444
  }
835
2445
 
836
2446
  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();
2447
+ closeOpenAICodexWebSocketSessions(sessionId);
851
2448
  }
852
2449
  }
853
2450
 
854
- const transportCleanup = new CodexTransport();
855
- registerSessionResourceCleanup((sessionId) => transportCleanup.close(sessionId));
2451
+ registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions);