pi-openai-codex-compat 0.0.3 → 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.
@@ -9,8 +9,16 @@ import {
9
9
  uuidv7,
10
10
  } from "@earendil-works/pi-ai";
11
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";
12
19
  import { isObject, type JsonRecord } from "./codex-protocol.ts";
13
- import { normalizeReplayItem } from "./responses-replay.ts";
20
+ import { normalizeReplayItem, stableResponsesJson } from "./responses-replay.ts";
21
+ import { applyResponsesLiteHeaders, responsesLiteSsePayload } from "./responses-lite.ts";
14
22
 
15
23
  /**
16
24
  * Focused adaptation of @earendil-works/pi-ai@0.83.0
@@ -19,15 +27,20 @@ import { normalizeReplayItem } from "./responses-replay.ts";
19
27
 
20
28
  const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
21
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";
22
31
  const DEFAULT_MAX_RETRIES = 0;
23
- 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;
24
37
  const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
25
38
  const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
26
39
  const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
27
- const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1_000;
28
- const SESSION_WEBSOCKET_MAX_AGE_MS = 55 * 60 * 1_000;
29
40
  const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
30
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";
31
44
 
32
45
  type ProcessWithBuiltinModules = typeof process & {
33
46
  getBuiltinModule?: {
@@ -44,7 +57,7 @@ export type CodexJsonRequestOptions = {
44
57
  fetch?: typeof fetch;
45
58
  };
46
59
 
47
- export type CodexTransportDiagnostic = {
60
+ export type CodexTransportFailureDiagnostic = {
48
61
  type: "provider_transport_failure";
49
62
  timestamp: number;
50
63
  error: {
@@ -62,6 +75,154 @@ export type CodexTransportDiagnostic = {
62
75
  };
63
76
  };
64
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
+
65
226
  export type CodexContinuationHandle = {
66
227
  readonly responseId: string;
67
228
  replaceResponseItems(items: readonly JsonRecord[]): boolean;
@@ -85,6 +246,7 @@ export interface OpenAICodexWebSocketDebugStats {
85
246
  lastPreviousResponseId?: string;
86
247
  websocketFailures: number;
87
248
  sseFallbacks: number;
249
+ prewarmRequests: number;
88
250
  websocketFallbackActive?: boolean;
89
251
  lastWebSocketError?: string;
90
252
  }
@@ -96,6 +258,14 @@ type CodexTransportOptions = OpenAICodexResponsesOptions & {
96
258
  onWebSocketResponseHandle?(handle: CodexWebSocketResponseHandle): void;
97
259
  onTransportStart?(): void;
98
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;
99
269
  };
100
270
 
101
271
  type WebSocketEventType = "open" | "message" | "error" | "close";
@@ -117,8 +287,6 @@ type WebSocketConstructor = new (
117
287
  type CachedWebSocket = {
118
288
  socket: WebSocketLike;
119
289
  busy: boolean;
120
- createdAt: number;
121
- idleTimer?: ReturnType<typeof setTimeout>;
122
290
  continuation?: {
123
291
  lastRequestBody: JsonRecord;
124
292
  lastResponseId: string;
@@ -126,8 +294,23 @@ type CachedWebSocket = {
126
294
  };
127
295
  };
128
296
 
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
+
129
312
  const websocketSessions = new Map<string, Map<string, CachedWebSocket>>();
130
- const websocketFallbackSessions = new Set<string>();
313
+ const websocketFallbackSessions = new Map<string, WebSocketFallbackSession>();
131
314
  const websocketDebugStats = new Map<string, OpenAICodexWebSocketDebugStats>();
132
315
 
133
316
  function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats {
@@ -144,6 +327,7 @@ function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocket
144
327
  lastInputItems: 0,
145
328
  websocketFailures: 0,
146
329
  sseFallbacks: 0,
330
+ prewarmRequests: 0,
147
331
  };
148
332
  websocketDebugStats.set(sessionId, stats);
149
333
  }
@@ -169,7 +353,6 @@ export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void {
169
353
 
170
354
  export function closeOpenAICodexWebSocketSessions(sessionId?: string): void {
171
355
  const closeEntry = (entry: CachedWebSocket): void => {
172
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
173
356
  closeSocket(entry.socket, "debug_close");
174
357
  };
175
358
  if (sessionId) {
@@ -194,6 +377,12 @@ function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean {
194
377
  return sessionId ? websocketFallbackSessions.has(sessionId) : false;
195
378
  }
196
379
 
380
+ function webSocketFallbackSession(
381
+ sessionId: string | undefined,
382
+ ): WebSocketFallbackSession | undefined {
383
+ return sessionId ? websocketFallbackSessions.get(sessionId) : undefined;
384
+ }
385
+
197
386
  function recordWebSocketSseFallback(sessionId: string | undefined): void {
198
387
  if (!sessionId) return;
199
388
  const stats = getOrCreateWebSocketDebugStats(sessionId);
@@ -201,9 +390,13 @@ function recordWebSocketSseFallback(sessionId: string | undefined): void {
201
390
  stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId);
202
391
  }
203
392
 
204
- function recordWebSocketFailure(sessionId: string | undefined, error: unknown): void {
393
+ function recordWebSocketFailure(
394
+ sessionId: string | undefined,
395
+ error: unknown,
396
+ cacheIdentity: CacheIdentitySnapshot,
397
+ ): void {
205
398
  if (!sessionId) return;
206
- websocketFallbackSessions.add(sessionId);
399
+ websocketFallbackSessions.set(sessionId, { cacheIdentity });
207
400
  const stats = getOrCreateWebSocketDebugStats(sessionId);
208
401
  stats.websocketFailures += 1;
209
402
  stats.lastWebSocketError = thrownMessage(error);
@@ -232,6 +425,16 @@ class CodexProtocolError extends Error {
232
425
  }
233
426
  }
234
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
+
235
438
  class WebSocketCloseError extends Error {
236
439
  readonly code: number | undefined;
237
440
  readonly reason: string | undefined;
@@ -309,7 +512,11 @@ function thrownMessage(error: unknown): string {
309
512
  }
310
513
 
311
514
  function isCodexNonTransportError(error: unknown): boolean {
312
- return error instanceof CodexApiError || error instanceof CodexProtocolError;
515
+ return (
516
+ error instanceof CodexApiError ||
517
+ error instanceof CodexHttpError ||
518
+ error instanceof CodexProtocolError
519
+ );
313
520
  }
314
521
 
315
522
  function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
@@ -320,7 +527,7 @@ function isPreviousResponseNotFoundError(error: unknown): boolean {
320
527
  return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
321
528
  }
322
529
 
323
- function diagnosticError(error: unknown): CodexTransportDiagnostic["error"] {
530
+ function diagnosticError(error: unknown): CodexTransportFailureDiagnostic["error"] {
324
531
  if (!(error instanceof Error)) {
325
532
  return { name: "ThrownValue", message: thrownMessage(error) };
326
533
  }
@@ -338,14 +545,15 @@ function transportDiagnostic(
338
545
  transport: string,
339
546
  emitted: boolean,
340
547
  requestBytes: number,
341
- ): CodexTransportDiagnostic {
548
+ fallbackSelected = !emitted,
549
+ ): CodexTransportFailureDiagnostic {
342
550
  return {
343
551
  type: "provider_transport_failure",
344
552
  timestamp: Date.now(),
345
553
  error: diagnosticError(error),
346
554
  details: {
347
555
  configuredTransport: transport,
348
- ...(!emitted ? { fallbackTransport: "sse" as const } : {}),
556
+ ...(fallbackSelected ? { fallbackTransport: "sse" as const } : {}),
349
557
  eventsEmitted: emitted,
350
558
  phase: emitted ? "after_message_stream_start" : "before_message_stream_start",
351
559
  requestBytes,
@@ -371,6 +579,13 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
371
579
  });
372
580
  }
373
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
+
374
589
  function normalizeTimeoutMs(value: number | undefined): number | undefined {
375
590
  if (value === undefined) return undefined;
376
591
  if (!Number.isFinite(value) || value < 0) {
@@ -379,8 +594,16 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
379
594
  return Math.floor(value);
380
595
  }
381
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
+
382
605
  function isTerminalRateLimitError(errorText: string): boolean {
383
- return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
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(
384
607
  errorText,
385
608
  );
386
609
  }
@@ -401,6 +624,7 @@ function retryDelayMs(headers: Headers): number | undefined {
401
624
  }
402
625
 
403
626
  class RetryDelayExceededError extends Error {}
627
+ class SseStreamIncompleteError extends Error {}
404
628
 
405
629
  function validateRetryDelay(delayMs: number, options: CodexTransportOptions): number {
406
630
  const maximum = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
@@ -412,9 +636,10 @@ function validateRetryDelay(delayMs: number, options: CodexTransportOptions): nu
412
636
  return delayMs;
413
637
  }
414
638
 
415
- function codexHttpError(status: number, statusText: string, raw: string): Error {
639
+ function codexHttpError(status: number, statusText: string, raw: string): CodexHttpError {
416
640
  let message = raw || statusText || "Request failed";
417
641
  let friendlyMessage: string | undefined;
642
+ const retryable = isRetryable(status, raw);
418
643
  try {
419
644
  const parsed = JSON.parse(raw) as {
420
645
  error?: {
@@ -426,7 +651,7 @@ function codexHttpError(status: number, statusText: string, raw: string): Error
426
651
  };
427
652
  };
428
653
  const error = parsed?.error;
429
- if (!error) return new Error(message);
654
+ if (!error) return new CodexHttpError(message, retryable);
430
655
  const code = error.code || error.type || "";
431
656
  if (
432
657
  status === 429 ||
@@ -441,7 +666,7 @@ function codexHttpError(status: number, statusText: string, raw: string): Error
441
666
  }
442
667
  message = error.message || friendlyMessage || message;
443
668
  } catch {}
444
- return new Error(friendlyMessage || message);
669
+ return new CodexHttpError(friendlyMessage || message, retryable);
445
670
  }
446
671
 
447
672
  function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
@@ -476,10 +701,280 @@ function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
476
701
  };
477
702
  }
478
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
+
479
910
  function headersToRecord(headers: Headers): Record<string, string> {
480
911
  return Object.fromEntries(headers.entries());
481
912
  }
482
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
+
483
978
  function extractAccountId(token: string): string {
484
979
  try {
485
980
  const parts = token.split(".");
@@ -553,15 +1048,25 @@ function sseHeaders(
553
1048
  accountId: string,
554
1049
  token: string,
555
1050
  sessionId: string | undefined,
1051
+ body: JsonRecord,
556
1052
  ): Headers {
557
1053
  const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
558
- headers.set("OpenAI-Beta", "responses=experimental");
1054
+ headers.delete("OpenAI-Beta");
1055
+ headers.delete("openai-beta");
559
1056
  headers.set("accept", "text/event-stream");
560
1057
  headers.set("content-type", "application/json");
561
1058
  if (sessionId) {
562
1059
  headers.set("session-id", sessionId);
563
- headers.set("x-client-request-id", sessionId);
564
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);
565
1070
  return headers;
566
1071
  }
567
1072
 
@@ -587,6 +1092,7 @@ function websocketHeaders(
587
1092
  accountId: string,
588
1093
  token: string,
589
1094
  requestId: string,
1095
+ body: JsonRecord,
590
1096
  ): Headers {
591
1097
  const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
592
1098
  headers.delete("accept");
@@ -596,9 +1102,65 @@ function websocketHeaders(
596
1102
  headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
597
1103
  headers.set("x-client-request-id", requestId);
598
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);
599
1110
  return headers;
600
1111
  }
601
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
+
602
1164
  function compressBody(body: string): Uint8Array | undefined {
603
1165
  const zlib = nodeZlib();
604
1166
  if (!zlib || typeof zlib.zstdCompressSync !== "function") return undefined;
@@ -688,17 +1250,6 @@ function socketReusable(socket: WebSocketLike): boolean {
688
1250
  return socket.readyState === undefined || socket.readyState === 1;
689
1251
  }
690
1252
 
691
- function scheduleSocketExpiry(sessionId: string, accountId: string, entry: CachedWebSocket): void {
692
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
693
- entry.idleTimer = setTimeout(() => {
694
- if (entry.busy) return;
695
- closeSocket(entry.socket, "idle_timeout");
696
- const accountEntries = websocketSessions.get(sessionId);
697
- if (accountEntries?.get(accountId) === entry) accountEntries.delete(accountId);
698
- if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
699
- }, SESSION_WEBSOCKET_CACHE_TTL_MS);
700
- }
701
-
702
1253
  async function connectWebSocket(
703
1254
  url: string,
704
1255
  headers: Headers,
@@ -779,12 +1330,7 @@ async function acquireWebSocket(
779
1330
  if (sessionId) {
780
1331
  let accountEntries = websocketSessions.get(sessionId);
781
1332
  const cached = accountEntries?.get(accountId);
782
- if (cached?.idleTimer) {
783
- clearTimeout(cached.idleTimer);
784
- delete cached.idleTimer;
785
- }
786
- const expired = cached && Date.now() - cached.createdAt >= SESSION_WEBSOCKET_MAX_AGE_MS;
787
- if (cached && !cached.busy && !expired && socketReusable(cached.socket)) {
1333
+ if (cached && !cached.busy && socketReusable(cached.socket)) {
788
1334
  cached.busy = true;
789
1335
  return {
790
1336
  socket: cached.socket,
@@ -792,10 +1338,6 @@ async function acquireWebSocket(
792
1338
  reused: true,
793
1339
  release(keep) {
794
1340
  if (!keep || !socketReusable(cached.socket)) {
795
- if (cached.idleTimer) {
796
- clearTimeout(cached.idleTimer);
797
- delete cached.idleTimer;
798
- }
799
1341
  closeSocket(cached.socket);
800
1342
  const currentEntries = websocketSessions.get(sessionId);
801
1343
  if (currentEntries?.get(accountId) === cached) currentEntries.delete(accountId);
@@ -803,12 +1345,11 @@ async function acquireWebSocket(
803
1345
  return;
804
1346
  }
805
1347
  cached.busy = false;
806
- scheduleSocketExpiry(sessionId, accountId, cached);
807
1348
  },
808
1349
  };
809
1350
  }
810
1351
  if (cached && !cached.busy) {
811
- closeSocket(cached.socket, expired ? "connection_age_limit" : "done");
1352
+ closeSocket(cached.socket, "done");
812
1353
  accountEntries?.delete(accountId);
813
1354
  if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
814
1355
  }
@@ -818,7 +1359,7 @@ async function acquireWebSocket(
818
1359
  }
819
1360
 
820
1361
  const socket = await connectWebSocket(url, headers, signal, timeoutMs);
821
- const entry: CachedWebSocket = { socket, busy: true, createdAt: Date.now() };
1362
+ const entry: CachedWebSocket = { socket, busy: true };
822
1363
  accountEntries = websocketSessions.get(sessionId);
823
1364
  if (!accountEntries) {
824
1365
  accountEntries = new Map();
@@ -832,14 +1373,12 @@ async function acquireWebSocket(
832
1373
  release(keep) {
833
1374
  if (!keep || !socketReusable(socket)) {
834
1375
  closeSocket(socket);
835
- if (entry.idleTimer) clearTimeout(entry.idleTimer);
836
1376
  const currentEntries = websocketSessions.get(sessionId);
837
1377
  if (currentEntries?.get(accountId) === entry) currentEntries.delete(accountId);
838
1378
  if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
839
1379
  return;
840
1380
  }
841
1381
  entry.busy = false;
842
- scheduleSocketExpiry(sessionId, accountId, entry);
843
1382
  },
844
1383
  };
845
1384
  }
@@ -852,9 +1391,24 @@ function requestWithoutHistory(body: JsonRecord): JsonRecord {
852
1391
  const result = { ...body };
853
1392
  delete result.input;
854
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"];
855
1398
  return result;
856
1399
  }
857
1400
 
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
+
858
1412
  function jsonWireRequestBody(body: JsonRecord): JsonRecord {
859
1413
  const snapshot = JSON.parse(JSON.stringify(body)) as unknown;
860
1414
  if (!isObject(snapshot)) {
@@ -863,36 +1417,95 @@ function jsonWireRequestBody(body: JsonRecord): JsonRecord {
863
1417
  return snapshot;
864
1418
  }
865
1419
 
866
- function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord {
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 {
867
1445
  const continuation = entry.continuation;
868
- 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;
869
1450
  if (
870
- JSON.stringify(requestWithoutHistory(body)) !==
871
- JSON.stringify(requestWithoutHistory(continuation.lastRequestBody))
1451
+ stableResponsesJson(requestWithoutHistory(body)) !==
1452
+ stableResponsesJson(requestWithoutHistory(continuation.lastRequestBody))
872
1453
  ) {
873
1454
  delete entry.continuation;
874
- return body;
1455
+ return {
1456
+ body,
1457
+ contextMode: "full",
1458
+ previousResponseId,
1459
+ bypassReason: "request_template_changed",
1460
+ cacheIdentityPreserved,
1461
+ };
875
1462
  }
876
1463
 
877
1464
  const currentInput = body.input ?? [];
878
1465
  const previousInput = continuation.lastRequestBody.input ?? [];
879
1466
  if (!Array.isArray(currentInput) || !Array.isArray(previousInput)) {
880
1467
  delete entry.continuation;
881
- return body;
1468
+ return {
1469
+ body,
1470
+ contextMode: "full",
1471
+ previousResponseId,
1472
+ bypassReason: "non_array_input",
1473
+ cacheIdentityPreserved,
1474
+ };
882
1475
  }
883
1476
  const baseline = [...previousInput, ...continuation.lastResponseItems];
884
- if (
885
- currentInput.length < baseline.length ||
886
- JSON.stringify(currentInput.slice(0, baseline.length)) !== JSON.stringify(baseline)
887
- ) {
1477
+ const mismatchIndex = baseline.findIndex(
1478
+ (item, index) => index >= currentInput.length || !responseItemsMatch(item, currentInput[index]),
1479
+ );
1480
+ if (mismatchIndex >= 0) {
888
1481
  delete entry.continuation;
889
- 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
+ };
890
1498
  }
891
1499
 
892
1500
  return {
893
- ...body,
894
- previous_response_id: continuation.lastResponseId,
895
- 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,
896
1509
  };
897
1510
  }
898
1511
 
@@ -900,6 +1513,15 @@ function requestInputLength(body: JsonRecord): number {
900
1513
  return typeof body.input === "string" || Array.isArray(body.input) ? body.input.length : 0;
901
1514
  }
902
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
+
903
1525
  async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
904
1526
  if (typeof data === "string") return data;
905
1527
  if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
@@ -942,7 +1564,8 @@ async function* parseWebSocket(
942
1564
  if (
943
1565
  type === "response.completed" ||
944
1566
  type === "response.done" ||
945
- type === "response.incomplete"
1567
+ type === "response.incomplete" ||
1568
+ type === "response.failed"
946
1569
  ) {
947
1570
  terminal = true;
948
1571
  done = true;
@@ -1040,13 +1663,6 @@ function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
1040
1663
  event,
1041
1664
  );
1042
1665
  }
1043
- if (type === "response.failed") {
1044
- const response = isObject(event.response) ? event.response : undefined;
1045
- const error = isObject(response?.["error"]) ? response["error"] : undefined;
1046
- const message = typeof error?.["message"] === "string" ? error["message"] : undefined;
1047
- const code = typeof error?.["code"] === "string" ? error["code"] : undefined;
1048
- throw new CodexApiError(message || "Codex response failed", code, event);
1049
- }
1050
1666
  if (type === "response.done") {
1051
1667
  return { ...event, type: "response.completed" };
1052
1668
  }
@@ -1054,7 +1670,11 @@ function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
1054
1670
  }
1055
1671
 
1056
1672
  function isTerminalEvent(event: JsonRecord): boolean {
1057
- return event.type === "response.completed" || event.type === "response.incomplete";
1673
+ return (
1674
+ event.type === "response.completed" ||
1675
+ event.type === "response.incomplete" ||
1676
+ event.type === "response.failed"
1677
+ );
1058
1678
  }
1059
1679
 
1060
1680
  async function* requestSse(
@@ -1063,6 +1683,7 @@ async function* requestSse(
1063
1683
  options: CodexTransportOptions,
1064
1684
  headers: Headers,
1065
1685
  timeoutMs: number | undefined,
1686
+ onAttempt: (turnStateReplayedValue: string | undefined) => void,
1066
1687
  ): AsyncGenerator<JsonRecord> {
1067
1688
  const compressed = compressBody(bodyJson);
1068
1689
  if (compressed) headers.set("content-encoding", "zstd");
@@ -1074,6 +1695,7 @@ async function* requestSse(
1074
1695
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
1075
1696
  if (options.signal?.aborted) throw new Error("Request was aborted");
1076
1697
  try {
1698
+ onAttempt(applyTurnStateHeader(headers, options.turnState));
1077
1699
  const timeoutSignal =
1078
1700
  timeoutMs !== undefined && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
1079
1701
  const combined = combineAbortSignals([options.signal, timeoutSignal]);
@@ -1094,6 +1716,7 @@ async function* requestSse(
1094
1716
  } finally {
1095
1717
  combined.cleanup();
1096
1718
  }
1719
+ captureTurnStateHeader(response.headers, options.turnState);
1097
1720
  await options.onResponse?.(
1098
1721
  { status: response.status, headers: headersToRecord(response.headers) },
1099
1722
  model,
@@ -1105,7 +1728,7 @@ async function* requestSse(
1105
1728
  const requestedDelay = retryDelayMs(response.headers);
1106
1729
  const delay =
1107
1730
  requestedDelay === undefined
1108
- ? BASE_DELAY_MS * 2 ** attempt
1731
+ ? retryBackoffMs(BASE_DELAY_MS, attempt + 1)
1109
1732
  : validateRetryDelay(requestedDelay, options);
1110
1733
  await sleep(delay, options.signal);
1111
1734
  continue;
@@ -1122,10 +1745,11 @@ async function* requestSse(
1122
1745
  lastError = error instanceof Error ? error : new Error(String(error));
1123
1746
  if (
1124
1747
  attempt < maxRetries &&
1748
+ !(lastError instanceof CodexHttpError) &&
1125
1749
  !(lastError instanceof RetryDelayExceededError) &&
1126
1750
  !lastError.message.includes("usage limit")
1127
1751
  ) {
1128
- await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
1752
+ await sleep(retryBackoffMs(BASE_DELAY_MS, attempt + 1), options.signal);
1129
1753
  continue;
1130
1754
  }
1131
1755
  throw lastError;
@@ -1135,12 +1759,18 @@ async function* requestSse(
1135
1759
  if (!response?.ok) throw lastError ?? new Error("Failed after retries");
1136
1760
  if (!response.body) throw new Error("No response body");
1137
1761
  options.onTransportStart?.();
1762
+ let terminal = false;
1138
1763
  for await (const event of parseSse(response, options.signal)) {
1139
1764
  const normalized = normalizeEvent(event);
1140
1765
  if (!normalized) continue;
1141
1766
  yield normalized;
1142
- if (isTerminalEvent(normalized)) return;
1767
+ if (isTerminalEvent(normalized)) {
1768
+ terminal = true;
1769
+ break;
1770
+ }
1143
1771
  }
1772
+ if (!terminal)
1773
+ throw new SseStreamIncompleteError("Codex SSE stream ended before a terminal event");
1144
1774
  }
1145
1775
 
1146
1776
  async function* requestWebSocket(
@@ -1153,6 +1783,7 @@ async function* requestWebSocket(
1153
1783
  timeoutMs: number | undefined,
1154
1784
  connectTimeoutMs: number,
1155
1785
  requestBytes: number,
1786
+ onAttempt: (attempt: CodexWebSocketAttempt) => void,
1156
1787
  ): AsyncGenerator<JsonRecord> {
1157
1788
  const acquired = await acquireWebSocket(
1158
1789
  resolveCodexWebSocketUrl(model.baseUrl),
@@ -1175,12 +1806,57 @@ async function* requestWebSocket(
1175
1806
  if (options.signal?.aborted) throw new Error("Request was aborted");
1176
1807
  const useContinuation =
1177
1808
  options.transport === "auto" || options.transport === "websocket-cached";
1178
- const fullBody = useContinuation && acquired.entry ? jsonWireRequestBody(body) : body;
1179
- const requestBody =
1180
- useContinuation && acquired.entry ? cachedRequestBody(acquired.entry, fullBody) : fullBody;
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
+ });
1181
1856
  const stats = sessionId ? getOrCreateWebSocketDebugStats(sessionId) : undefined;
1182
1857
  if (stats) {
1183
1858
  stats.requests += 1;
1859
+ if (warmup) stats.prewarmRequests += 1;
1184
1860
  if (acquired.reused) stats.connectionsReused += 1;
1185
1861
  else stats.connectionsCreated += 1;
1186
1862
  if (useContinuation) stats.cachedContextRequests += 1;
@@ -1206,10 +1882,13 @@ async function* requestWebSocket(
1206
1882
  return true;
1207
1883
  },
1208
1884
  });
1209
- acquired.socket.send(JSON.stringify({ type: "response.create", ...requestBody }));
1885
+ acquired.socket.send(wireRequestJson);
1210
1886
  const responseItems: JsonRecord[] = [];
1211
1887
  let responseId: string | undefined;
1888
+ let responseCompleted = false;
1212
1889
  for await (const event of parseWebSocket(acquired.socket, options.signal, timeoutMs)) {
1890
+ captureTurnStateEvent(event, options.turnState);
1891
+ if (event.type === "response.metadata") continue;
1213
1892
  if (
1214
1893
  event.type === "response.created" &&
1215
1894
  isObject(event.response) &&
@@ -1223,9 +1902,13 @@ async function* requestWebSocket(
1223
1902
  if (
1224
1903
  (event.type === "response.completed" ||
1225
1904
  event.type === "response.done" ||
1226
- event.type === "response.incomplete") &&
1905
+ event.type === "response.incomplete" ||
1906
+ event.type === "response.failed") &&
1227
1907
  isObject(event.response)
1228
1908
  ) {
1909
+ if (event.type === "response.completed" || event.type === "response.done") {
1910
+ responseCompleted = true;
1911
+ }
1229
1912
  if (typeof event.response.id === "string") responseId = event.response.id;
1230
1913
  if (Array.isArray(event.response["output"])) {
1231
1914
  const terminalItems = event.response["output"].filter(isObject);
@@ -1244,7 +1927,7 @@ async function* requestWebSocket(
1244
1927
  if (isTerminalEvent(normalized)) break;
1245
1928
  }
1246
1929
  if (options.signal?.aborted) throw new Error("Request was aborted");
1247
- if (useContinuation && acquired.entry && responseId) {
1930
+ if (useContinuation && acquired.entry && responseId && responseCompleted) {
1248
1931
  const entry = acquired.entry;
1249
1932
  const continuation = {
1250
1933
  lastRequestBody: fullBody,
@@ -1317,6 +2000,99 @@ export async function requestCodexJson(
1317
2000
  }
1318
2001
 
1319
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
+
1320
2096
  async *request(
1321
2097
  model: Model<any>,
1322
2098
  body: JsonRecord,
@@ -1326,27 +2102,71 @@ export class CodexTransport {
1326
2102
  const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
1327
2103
  const connectTimeoutMs =
1328
2104
  normalizeTimeoutMs(options.websocketConnectTimeoutMs) ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
1329
- const bodyJson = JSON.stringify(body);
1330
- const requestBytes = new TextEncoder().encode(bodyJson).byteLength;
2105
+ const sseBody = responsesLiteSsePayload(body);
1331
2106
  const accountId = options.accountId ?? validateCodexAuthentication(model, options.apiKey);
1332
2107
  const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
1333
2108
  const requestId = codexCacheKey(cacheSessionId);
1334
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
+ );
1335
2133
 
1336
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
+ }
1337
2148
  if (websocketDisabled) recordWebSocketSseFallback(cacheSessionId);
1338
2149
  if (transport !== "sse" && !websocketDisabled) {
2150
+ const websocketRequestBytes = serializedBytes(JSON.stringify(body));
1339
2151
  const headers = websocketHeaders(
1340
2152
  model.headers,
1341
2153
  options.headers,
1342
2154
  accountId,
1343
2155
  options.apiKey,
1344
2156
  requestId || uuidv7(),
2157
+ body,
1345
2158
  );
2159
+ const webSocketCacheIdentity = cacheIdentitySnapshot(body, headers, accountId);
1346
2160
  let retriedConnectionLimit = false;
1347
2161
  let retriedMissingContinuation = false;
2162
+ let websocketRetries = 0;
2163
+ let visibleOutputEmitted = false;
2164
+ let anyEventEmitted = false;
1348
2165
  while (true) {
1349
2166
  let emitted = false;
2167
+ let attempt: CodexWebSocketAttempt | undefined;
2168
+ let usage: CodexCacheUsageDiagnostic | undefined;
2169
+ let responseId: string | undefined;
1350
2170
  try {
1351
2171
  for await (const event of requestWebSocket(
1352
2172
  model,
@@ -1357,12 +2177,58 @@ export class CodexTransport {
1357
2177
  accountId,
1358
2178
  timeoutMs,
1359
2179
  connectTimeoutMs,
1360
- requestBytes,
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
+ },
1361
2204
  )) {
1362
2205
  if (!emitted) options.onTransportStart?.();
1363
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
+ }
1364
2213
  yield event;
1365
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
+ }
1366
2232
  return;
1367
2233
  } catch (error) {
1368
2234
  const aborted = options.signal?.aborted;
@@ -1370,34 +2236,211 @@ export class CodexTransport {
1370
2236
  !emitted && isWebSocketConnectionLimitReachedError(error);
1371
2237
  if (!aborted && isPreviousResponseNotFoundError(error) && !retriedMissingContinuation) {
1372
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
+ }
1373
2276
  continue;
1374
2277
  }
1375
2278
  if (!aborted && connectionLimitBeforeStart && !retriedConnectionLimit) {
1376
2279
  retriedConnectionLimit = true;
1377
2280
  continue;
1378
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
+ }
1379
2309
  if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
1380
2310
  throw error;
1381
2311
  }
1382
2312
  options.onTransportDiagnostic?.(
1383
- transportDiagnostic(error, transport, emitted, requestBytes),
2313
+ transportDiagnostic(
2314
+ error,
2315
+ transport,
2316
+ anyEventEmitted,
2317
+ websocketRequestBytes,
2318
+ !visibleOutputEmitted,
2319
+ ),
1384
2320
  );
1385
- recordWebSocketFailure(cacheSessionId, error);
1386
- if (emitted) throw error;
2321
+ recordWebSocketFailure(cacheSessionId, error, webSocketCacheIdentity);
2322
+ if (visibleOutputEmitted) throw error;
1387
2323
  recordWebSocketSseFallback(cacheSessionId);
2324
+ if (options.warmup) throw error;
2325
+ sseRecovery = {
2326
+ trigger: "sse_after_websocket_failure",
2327
+ previousCacheIdentity: webSocketCacheIdentity,
2328
+ };
1388
2329
  break;
1389
2330
  }
1390
2331
  }
1391
2332
  }
1392
2333
 
2334
+ const bodyJson = JSON.stringify(sseBody);
2335
+ const sseRequestBytes = serializedBytes(bodyJson);
1393
2336
  const headers = sseHeaders(
1394
2337
  model.headers,
1395
2338
  options.headers,
1396
2339
  accountId,
1397
2340
  options.apiKey,
1398
2341
  requestId,
2342
+ body,
1399
2343
  );
1400
- yield* requestSse(model, bodyJson, options, headers, timeoutMs);
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
+ }
1401
2444
  }
1402
2445
 
1403
2446
  close(sessionId?: string): void {