@oh-my-pi/pi-ai 16.2.1 → 16.2.2

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 (166) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/auth-gateway/server.d.ts +0 -19
  3. package/dist/types/auth-retry.d.ts +2 -10
  4. package/dist/types/auth-storage.d.ts +1 -2
  5. package/dist/types/dialect/demotion.d.ts +22 -0
  6. package/dist/types/dialect/index.d.ts +2 -0
  7. package/dist/types/error/abort.d.ts +14 -0
  8. package/dist/types/error/auth-classify.d.ts +16 -0
  9. package/dist/types/error/auth.d.ts +27 -0
  10. package/dist/types/error/aws.d.ts +23 -0
  11. package/dist/types/error/classes.d.ts +102 -0
  12. package/dist/types/error/finalize.d.ts +39 -0
  13. package/dist/types/error/flags.d.ts +70 -0
  14. package/dist/types/error/format.d.ts +20 -0
  15. package/dist/types/error/gateway.d.ts +20 -0
  16. package/dist/types/error/index.d.ts +13 -0
  17. package/dist/types/error/oauth.d.ts +43 -0
  18. package/dist/types/error/provider.d.ts +42 -0
  19. package/dist/types/{rate-limit-utils.d.ts → error/rate-limit.d.ts} +14 -1
  20. package/dist/types/error/retryable.d.ts +27 -0
  21. package/dist/types/error/validation.d.ts +32 -0
  22. package/dist/types/index.d.ts +1 -3
  23. package/dist/types/providers/amazon-bedrock.d.ts +0 -5
  24. package/dist/types/providers/anthropic-client.d.ts +2 -16
  25. package/dist/types/providers/anthropic.d.ts +6 -1
  26. package/dist/types/providers/aws-eventstream.d.ts +2 -1
  27. package/dist/types/providers/cursor.d.ts +8 -7
  28. package/dist/types/providers/google-gemini-cli.d.ts +0 -5
  29. package/dist/types/providers/google-shared.d.ts +0 -5
  30. package/dist/types/providers/ollama.d.ts +0 -5
  31. package/dist/types/providers/openai-codex/request-transformer.d.ts +2 -2
  32. package/dist/types/providers/openai-codex/response-handler.d.ts +1 -1
  33. package/dist/types/providers/openai-codex-responses.d.ts +2 -2
  34. package/dist/types/providers/openai-responses-server-schema.d.ts +6 -0
  35. package/dist/types/providers/openai-responses-wire.d.ts +1 -1
  36. package/dist/types/providers/openai-responses.d.ts +1 -0
  37. package/dist/types/providers/openai-shared.d.ts +8 -6
  38. package/dist/types/providers/pi-native-client.d.ts +0 -9
  39. package/dist/types/registry/oauth/xai-oauth.d.ts +0 -9
  40. package/dist/types/types.d.ts +6 -0
  41. package/dist/types/utils/block-symbols.d.ts +20 -0
  42. package/dist/types/utils/openai-http.d.ts +4 -8
  43. package/dist/types/utils/retry.d.ts +2 -14
  44. package/dist/types/utils/thinking-loop.d.ts +3 -1
  45. package/package.json +8 -4
  46. package/src/api-registry.ts +3 -1
  47. package/src/auth-broker/discover.ts +7 -2
  48. package/src/auth-broker/remote-store.ts +8 -7
  49. package/src/auth-gateway/server.ts +27 -115
  50. package/src/auth-retry.ts +9 -21
  51. package/src/auth-storage.ts +34 -49
  52. package/src/dialect/demotion.ts +31 -0
  53. package/src/dialect/factory.ts +0 -2
  54. package/src/dialect/index.ts +2 -0
  55. package/src/dialect/owned-stream.ts +0 -1
  56. package/src/dialect/thinking.ts +22 -10
  57. package/src/error/abort.ts +18 -0
  58. package/src/error/auth-classify.ts +30 -0
  59. package/src/error/auth.ts +48 -0
  60. package/src/error/aws.ts +31 -0
  61. package/src/error/classes.ts +186 -0
  62. package/src/error/finalize.ts +69 -0
  63. package/src/error/flags.ts +486 -0
  64. package/src/error/format.ts +36 -0
  65. package/src/error/gateway.ts +96 -0
  66. package/src/error/index.ts +13 -0
  67. package/src/error/oauth.ts +58 -0
  68. package/src/error/provider.ts +56 -0
  69. package/src/{rate-limit-utils.ts → error/rate-limit.ts} +9 -3
  70. package/src/error/retryable.ts +70 -0
  71. package/src/error/validation.ts +44 -0
  72. package/src/index.ts +1 -3
  73. package/src/providers/amazon-bedrock.ts +61 -57
  74. package/src/providers/anthropic-client.ts +13 -41
  75. package/src/providers/anthropic-messages-server.ts +9 -2
  76. package/src/providers/anthropic.ts +84 -147
  77. package/src/providers/aws-credentials.ts +41 -11
  78. package/src/providers/aws-eventstream.ts +12 -21
  79. package/src/providers/azure-openai-responses.ts +27 -17
  80. package/src/providers/cursor.ts +59 -53
  81. package/src/providers/devin.ts +28 -20
  82. package/src/providers/gitlab-duo-workflow.ts +30 -10
  83. package/src/providers/gitlab-duo.ts +22 -6
  84. package/src/providers/google-auth.ts +15 -5
  85. package/src/providers/google-gemini-cli.ts +46 -60
  86. package/src/providers/google-shared.ts +40 -38
  87. package/src/providers/google-vertex.ts +3 -2
  88. package/src/providers/google.ts +5 -1
  89. package/src/providers/mock.ts +10 -7
  90. package/src/providers/ollama.ts +34 -29
  91. package/src/providers/openai-chat-server.ts +2 -1
  92. package/src/providers/openai-codex/request-transformer.ts +13 -12
  93. package/src/providers/openai-codex/response-handler.ts +1 -1
  94. package/src/providers/openai-codex-responses.ts +931 -1012
  95. package/src/providers/openai-completions.ts +46 -36
  96. package/src/providers/openai-responses-server-schema.ts +3 -0
  97. package/src/providers/openai-responses-server.ts +82 -30
  98. package/src/providers/openai-responses-wire.ts +1 -1
  99. package/src/providers/openai-responses.ts +46 -22
  100. package/src/providers/openai-shared.ts +118 -51
  101. package/src/providers/pi-native-client.ts +12 -18
  102. package/src/providers/pi-native-server.ts +9 -6
  103. package/src/providers/register-builtins.ts +5 -2
  104. package/src/providers/transform-messages.ts +31 -63
  105. package/src/registry/alibaba-coding-plan.ts +6 -5
  106. package/src/registry/api-key-login.ts +4 -3
  107. package/src/registry/api-key-validation.ts +4 -3
  108. package/src/registry/cloudflare-ai-gateway.ts +4 -3
  109. package/src/registry/coreweave.ts +2 -1
  110. package/src/registry/deepseek.ts +2 -1
  111. package/src/registry/google-antigravity.ts +2 -1
  112. package/src/registry/google-gemini-cli.ts +2 -1
  113. package/src/registry/kagi.ts +4 -3
  114. package/src/registry/kilo.ts +32 -10
  115. package/src/registry/litellm.ts +4 -3
  116. package/src/registry/llama-cpp.ts +3 -2
  117. package/src/registry/lm-studio.ts +3 -2
  118. package/src/registry/nvidia.ts +7 -7
  119. package/src/registry/oauth/anthropic.ts +23 -6
  120. package/src/registry/oauth/callback-server.ts +5 -3
  121. package/src/registry/oauth/cursor.ts +18 -4
  122. package/src/registry/oauth/devin.ts +14 -3
  123. package/src/registry/oauth/github-copilot.ts +21 -10
  124. package/src/registry/oauth/gitlab-duo-workflow.ts +15 -3
  125. package/src/registry/oauth/gitlab-duo.ts +30 -6
  126. package/src/registry/oauth/google-antigravity.ts +14 -5
  127. package/src/registry/oauth/google-gemini-cli.ts +24 -7
  128. package/src/registry/oauth/google-oauth-shared.ts +6 -3
  129. package/src/registry/oauth/index.ts +19 -8
  130. package/src/registry/oauth/kimi.ts +40 -10
  131. package/src/registry/oauth/openai-codex.ts +26 -11
  132. package/src/registry/oauth/opencode.ts +4 -3
  133. package/src/registry/oauth/perplexity.ts +33 -11
  134. package/src/registry/oauth/xai-oauth.ts +62 -18
  135. package/src/registry/oauth/xiaomi.ts +21 -9
  136. package/src/registry/ollama-cloud.ts +5 -4
  137. package/src/registry/ollama.ts +3 -2
  138. package/src/registry/parallel.ts +4 -3
  139. package/src/registry/qwen-portal.ts +4 -3
  140. package/src/registry/tavily.ts +4 -3
  141. package/src/registry/vercel-ai-gateway.ts +4 -3
  142. package/src/registry/vllm.ts +3 -2
  143. package/src/stream.ts +79 -24
  144. package/src/types.ts +6 -0
  145. package/src/usage/claude.ts +2 -1
  146. package/src/usage/github-copilot.ts +4 -3
  147. package/src/usage/google-antigravity.ts +2 -1
  148. package/src/utils/abort.ts +4 -2
  149. package/src/utils/block-symbols.ts +32 -0
  150. package/src/utils/event-stream.ts +15 -3
  151. package/src/utils/http-inspector.ts +4 -4
  152. package/src/utils/idle-iterator.ts +6 -5
  153. package/src/utils/openai-http.ts +11 -46
  154. package/src/utils/parse-bind.ts +7 -5
  155. package/src/utils/proxy.ts +2 -1
  156. package/src/utils/retry.ts +7 -23
  157. package/src/utils/schema/normalize.ts +3 -2
  158. package/src/utils/thinking-loop.ts +15 -11
  159. package/src/utils/validation.ts +4 -3
  160. package/dist/types/dialect/pi.d.ts +0 -9
  161. package/dist/types/errors.d.ts +0 -24
  162. package/dist/types/utils/overflow.d.ts +0 -55
  163. package/src/dialect/pi.md +0 -55
  164. package/src/dialect/pi.ts +0 -600
  165. package/src/errors.ts +0 -32
  166. package/src/utils/overflow.ts +0 -140
@@ -11,7 +11,6 @@ import {
11
11
  $env,
12
12
  $flag,
13
13
  asRecord,
14
- extractHttpStatusFromError,
15
14
  fetchWithRetry,
16
15
  logger,
17
16
  parseStreamingJson,
@@ -20,6 +19,7 @@ import {
20
19
  } from "@oh-my-pi/pi-utils";
21
20
  import { type } from "arktype";
22
21
  import packageJson from "../../package.json" with { type: "json" };
22
+ import * as AIError from "../error";
23
23
  import { getEnvApiKey } from "../stream";
24
24
  import type {
25
25
  Api,
@@ -44,8 +44,9 @@ import {
44
44
  getOpenAIResponsesHistoryPayload,
45
45
  normalizeSystemPrompts,
46
46
  } from "../utils";
47
+ import { clearStreamingPartialJson, kStreamingLastParseLen, kStreamingPartialJson } from "../utils/block-symbols";
47
48
  import { AssistantMessageEventStream } from "../utils/event-stream";
48
- import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
49
+ import type { RawHttpRequestDump } from "../utils/http-inspector";
49
50
  import {
50
51
  armPreResponseTimeout,
51
52
  getOpenAIStreamFirstEventTimeoutMs,
@@ -103,7 +104,7 @@ import { transformMessages } from "./transform-messages";
103
104
  export interface OpenAICodexResponsesOptions extends StreamOptions {
104
105
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
105
106
  reasoningSummary?: "auto" | "concise" | "detailed" | null;
106
- /** `reasoning.context` replay scope. Defaults to `all_turns` under {@link OpenAICodexResponsesOptions.responsesLite}, otherwise omitted (server default is `current_turn`). */
107
+ /** `reasoning.context` replay scope; defaults to `all_turns` for every Codex request when unset. */
107
108
  reasoningContext?: CodexReasoningContext;
108
109
  textVerbosity?: "low" | "medium" | "high";
109
110
  include?: string[];
@@ -116,7 +117,7 @@ export interface OpenAICodexResponsesOptions extends StreamOptions {
116
117
  * `x-openai-internal-codex-responses-lite: true` on HTTP requests and on the
117
118
  * WebSocket upgrade (the marker is connection-scoped there, so lite and
118
119
  * non-lite turns never share a pooled socket), strips image detail from
119
- * input, and defaults `reasoning.context` to `all_turns` — mirroring codex-rs.
120
+ * input, and disables parallel tool calling — mirroring codex-rs.
120
121
  */
121
122
  responsesLite?: boolean;
122
123
  /**
@@ -138,9 +139,9 @@ const CODEX_DEBUG = $flag("PI_CODEX_DEBUG");
138
139
  const CODEX_MAX_RETRIES = 5;
139
140
  const CODEX_RETRY_DELAY_MS = 500;
140
141
  const CODEX_WEBSOCKET_CONNECT_TIMEOUT_MS = 10000;
141
- const CODEX_WEBSOCKET_PING_INTERVAL_MS = 10_000;
142
- const CODEX_WEBSOCKET_PONG_TIMEOUT_MS = 60_000;
143
- const CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = 4096;
142
+ const CODEX_WEBSOCKET_PING_INTERVAL_MS = Number($env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS || 10_000);
143
+ const CODEX_WEBSOCKET_PONG_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS || 60_000);
144
+ const CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = Number($env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY || 4096);
144
145
  /**
145
146
  * Maximum quiet period (no inbound frames AND no observed pong) we'll trust a
146
147
  * reused WebSocket for before forcing a fresh handshake. Codex backends and
@@ -154,7 +155,7 @@ const CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY = 4096;
154
155
  * execution, user typing, etc.). Set `PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS=0`
155
156
  * to disable.
156
157
  */
157
- const CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = 30_000;
158
+ const CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = Number($env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS || 30_000);
158
159
  /**
159
160
  * Steady-state liveness ceiling for the Codex WebSocket transport. Distinct from
160
161
  * the OMP-wide stream watchdog removed in #1392: a WebSocket can stay TCP-open
@@ -163,7 +164,7 @@ const CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS = 30_000;
163
164
  * states and trigger the WS→SSE fallback. Only applies AFTER the first event
164
165
  * has arrived — slow first-token paths wait as long as the caller permits.
165
166
  */
166
- const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300_000;
167
+ const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS || 300_000);
167
168
  /**
168
169
  * Maximum wait for the first WebSocket event before falling back to SSE.
169
170
  * Unlike a stream watchdog, this triggers a transport switch (not a request
@@ -171,8 +172,9 @@ const CODEX_WEBSOCKET_IDLE_TIMEOUT_MS = 300_000;
171
172
  * SSE. Generous default so legitimately slow first-token providers still get
172
173
  * a chance on the WS transport before falling through.
173
174
  */
174
- const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = 60_000;
175
- const CODEX_WEBSOCKET_RETRY_BUDGET = CODEX_MAX_RETRIES;
175
+ const CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS = Number($env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS || 60_000);
176
+ const CODEX_WEBSOCKET_RETRY_BUDGET = Number($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET || CODEX_MAX_RETRIES);
177
+ const CODEX_WEBSOCKET_RETRY_DELAY_MS = Number($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS || CODEX_RETRY_DELAY_MS);
176
178
  const CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX = "Codex websocket transport error";
177
179
  const CODEX_RETRYABLE_EVENT_CODES = new Set(["model_error", "server_error", "internal_error"]);
178
180
  const CODEX_RETRYABLE_EVENT_MESSAGE =
@@ -237,7 +239,10 @@ function createCodexWebSocketTimeoutMessage(reason: string, details: CodexWebSoc
237
239
 
238
240
  type CodexTransport = "sse" | "websocket";
239
241
  type CodexEventItem = ResponseReasoningItem | ResponseOutputMessage | ResponseFunctionToolCall | ResponseCustomToolCall;
240
- type CodexOutputBlock = ThinkingContent | TextContent | (ToolCall & { partialJson: string; lastParseLen?: number });
242
+ type CodexOutputBlock =
243
+ | ThinkingContent
244
+ | TextContent
245
+ | (ToolCall & { [kStreamingPartialJson]: string; [kStreamingLastParseLen]?: number });
241
246
 
242
247
  /**
243
248
  * Per-session request-shape counters. Despite the name, these cover both
@@ -311,7 +316,7 @@ interface CodexOpenItem {
311
316
  outputIndex?: number;
312
317
  }
313
318
 
314
- interface CodexStreamRuntime {
319
+ class CodexStreamRuntime {
315
320
  eventStream: AsyncGenerator<Record<string, unknown>>;
316
321
  requestBodyForState: RequestBody;
317
322
  transport: CodexTransport;
@@ -321,29 +326,198 @@ interface CodexStreamRuntime {
321
326
  * registers here; `output_item.done` removes. A keyed event whose `item_id`
322
327
  * is not present is dropped rather than appended to a sibling.
323
328
  */
324
- openItems: Map<string, CodexOpenItem>;
329
+ openItems = new Map<string, CodexOpenItem>();
325
330
  /**
326
331
  * Items open on the wire keyed by `output_index` for streams whose function
327
332
  * call items omit `id`; these still carry `output_index` on deltas/done.
328
333
  */
329
- openItemsByOutputIndex: Map<number, CodexOpenItem>;
334
+ openItemsByOutputIndex = new Map<number, CodexOpenItem>();
330
335
  /**
331
336
  * Most recently added open item for events that omit both `item_id` and
332
337
  * `output_index`. Always tracks the latest `output_item.added`, including
333
338
  * fully keyless items that never make it into the keyed maps; cleared when
334
339
  * its item closes.
335
340
  */
336
- currentEntry: CodexOpenItem | null;
341
+ currentEntry: CodexOpenItem | null = null;
337
342
  /** Convenience mirrors of {@link currentEntry} for legacy singleton handlers. */
338
- currentItem: CodexEventItem | null;
339
- currentBlock: CodexOutputBlock | null;
340
- nativeOutputItems: Array<Record<string, unknown>>;
341
- websocketStreamRetries: number;
342
- providerRetryAttempt: number;
343
- sawTerminalEvent: boolean;
344
- canSafelyReplayWebsocketOverSse: boolean;
343
+ currentItem: CodexEventItem | null = null;
344
+ currentBlock: CodexOutputBlock | null = null;
345
+ nativeOutputItems: Array<Record<string, unknown>> = [];
346
+ websocketStreamRetries = 0;
347
+ providerRetryAttempt = 0;
348
+ sawTerminalEvent = false;
349
+ canSafelyReplayWebsocketOverSse = true;
345
350
  whitespaceToolCallArgumentsDelta?: CodexWhitespaceToolCallArgumentsDeltaState;
346
- whitespaceLoopRetries: number;
351
+ whitespaceLoopRetries = 0;
352
+
353
+ constructor(initial: {
354
+ eventStream: AsyncGenerator<Record<string, unknown>>;
355
+ requestBodyForState: RequestBody;
356
+ transport: CodexTransport;
357
+ websocketState?: CodexWebSocketSessionState;
358
+ }) {
359
+ this.eventStream = initial.eventStream;
360
+ this.requestBodyForState = initial.requestBodyForState;
361
+ this.transport = initial.transport;
362
+ this.websocketState = initial.websocketState;
363
+ }
364
+
365
+ /**
366
+ * Wipe per-attempt accumulator state before a recovery path replays the turn.
367
+ * Keeps {@link openItems} and the legacy singleton-current pointers in lockstep
368
+ * with {@link nativeOutputItems} so a stale delta from the failed attempt can't
369
+ * bind to a sibling on the retry.
370
+ */
371
+ resetAccumulators(): void {
372
+ this.openItems.clear();
373
+ this.openItemsByOutputIndex.clear();
374
+ this.currentEntry = null;
375
+ this.currentItem = null;
376
+ this.currentBlock = null;
377
+ this.nativeOutputItems.length = 0;
378
+ }
379
+
380
+ /**
381
+ * Look up the open item a Codex stream event targets. `item_id` wins because it
382
+ * uniquely identifies a response item; `output_index` covers idless function
383
+ * call items. A keyed event whose target is already closed is dropped instead
384
+ * of being routed to a sibling. Only streams that omit both keys fall back to
385
+ * {@link currentEntry} — the most recently added item, including fully keyless
386
+ * ones that never reached the keyed maps.
387
+ */
388
+ openItemForEvent(rawEvent: Record<string, unknown>): CodexOpenItem | null {
389
+ const itemId = typeof rawEvent.item_id === "string" ? rawEvent.item_id : "";
390
+ if (itemId) return this.openItems.get(itemId) ?? null;
391
+ const outputIndex =
392
+ typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
393
+ ? Math.trunc(rawEvent.output_index)
394
+ : undefined;
395
+ if (outputIndex !== undefined) return this.openItemsByOutputIndex.get(outputIndex) ?? null;
396
+ return this.currentEntry;
397
+ }
398
+
399
+ closeOpenItem(entry: CodexOpenItem | null | undefined): void {
400
+ if (!entry) return;
401
+ if (entry.itemId) this.openItems.delete(entry.itemId);
402
+ if (entry.outputIndex !== undefined) this.openItemsByOutputIndex.delete(entry.outputIndex);
403
+ if (this.currentEntry === entry) {
404
+ this.currentEntry = null;
405
+ this.currentItem = null;
406
+ this.currentBlock = null;
407
+ }
408
+ }
409
+
410
+ observeWhitespaceToolCallArgumentsDelta(
411
+ rawEvent: Record<string, unknown>,
412
+ delta: string,
413
+ ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
414
+ if (!isJsonWhitespaceOnly(delta)) {
415
+ this.whitespaceToolCallArgumentsDelta = undefined;
416
+ return undefined;
417
+ }
418
+
419
+ const itemId =
420
+ typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0
421
+ ? rawEvent.item_id
422
+ : (this.currentItem?.id ?? "");
423
+ const outputIndex =
424
+ typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
425
+ ? Math.trunc(rawEvent.output_index)
426
+ : undefined;
427
+ const sequenceNumber =
428
+ typeof rawEvent.sequence_number === "number" && Number.isFinite(rawEvent.sequence_number)
429
+ ? Math.trunc(rawEvent.sequence_number)
430
+ : undefined;
431
+ let state = this.whitespaceToolCallArgumentsDelta;
432
+ if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) {
433
+ state = {
434
+ itemId,
435
+ outputIndex,
436
+ consecutiveEvents: 0,
437
+ consecutiveChars: 0,
438
+ firstSequenceNumber: sequenceNumber,
439
+ };
440
+ this.whitespaceToolCallArgumentsDelta = state;
441
+ }
442
+
443
+ state.consecutiveEvents += 1;
444
+ state.consecutiveChars += delta.length;
445
+ state.lastSequenceNumber = sequenceNumber;
446
+ if (
447
+ state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT &&
448
+ state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT
449
+ ) {
450
+ return undefined;
451
+ }
452
+
453
+ const itemLabel = itemId ? ` for item ${itemId}` : "";
454
+ const sequenceLabel =
455
+ state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined
456
+ ? ""
457
+ : `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`;
458
+ return {
459
+ message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`,
460
+ };
461
+ }
462
+
463
+ handleToolCallArgumentsDelta(
464
+ rawEvent: Record<string, unknown>,
465
+ stream: AssistantMessageEventStream,
466
+ output: AssistantMessage,
467
+ ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
468
+ const delta = (rawEvent as { delta?: string }).delta || "";
469
+ // Observe BEFORE the item/block guard: degenerate whitespace frames can keep
470
+ // arriving after the item closed (entry detached) and still count as
471
+ // progress for the idle watchdogs — dropping them unobserved would reopen
472
+ // the infinite-loop hole the breaker exists for.
473
+ const interruption = this.observeWhitespaceToolCallArgumentsDelta(rawEvent, delta);
474
+ if (interruption) return interruption;
475
+ // Route to the entry the event keys to; a delta whose item already closed
476
+ // is dropped instead of leaking into a sibling tool call (#2619).
477
+ const entry = this.openItemForEvent(rawEvent);
478
+ if (!entry) return undefined;
479
+ if (entry.item.type !== "function_call" || entry.block?.type !== "toolCall") return undefined;
480
+ accumulateToolCallArgumentsDelta(entry.block, delta, stream, output, entry.contentIndex);
481
+ return undefined;
482
+ }
483
+
484
+ handleToolCallArgumentsDone(rawEvent: Record<string, unknown>): void {
485
+ const entry = this.openItemForEvent(rawEvent);
486
+ if (entry?.item.type !== "function_call" || entry.block?.type !== "toolCall") return;
487
+ const args = (rawEvent as { arguments?: string }).arguments;
488
+ if (typeof args === "string") finalizeToolCallArgumentsDone(entry.block, args);
489
+ }
490
+
491
+ handleCustomToolCallInputDelta(
492
+ rawEvent: Record<string, unknown>,
493
+ stream: AssistantMessageEventStream,
494
+ output: AssistantMessage,
495
+ ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
496
+ const delta = (rawEvent as { delta?: string }).delta || "";
497
+ // Observe BEFORE the item/block guard — see handleToolCallArgumentsDelta.
498
+ const interruption = this.observeWhitespaceToolCallArgumentsDelta(rawEvent, delta);
499
+ if (interruption) return interruption;
500
+ const entry = this.openItemForEvent(rawEvent);
501
+ if (!entry) return undefined;
502
+ if (entry.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return undefined;
503
+ accumulateCustomToolCallInputDelta(entry.block, delta, stream, output, entry.contentIndex);
504
+ return undefined;
505
+ }
506
+
507
+ handleCustomToolCallInputDone(rawEvent: Record<string, unknown>): void {
508
+ const entry = this.openItemForEvent(rawEvent);
509
+ if (entry?.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return;
510
+ const input = (rawEvent as { input?: string }).input;
511
+ if (typeof input === "string") finalizeCustomToolCallInputDone(entry.block, input);
512
+ }
513
+
514
+ handleResponseCreated(rawEvent: Record<string, unknown>): void {
515
+ const response = (rawEvent as { response?: { id?: string } }).response;
516
+ const state = this.websocketState;
517
+ if (state && this.transport === "websocket" && typeof response?.id === "string" && response.id.length > 0) {
518
+ state.lastResponseId = response.id;
519
+ }
520
+ }
347
521
  }
348
522
 
349
523
  interface CodexWhitespaceToolCallArgumentsDeltaState {
@@ -359,12 +533,10 @@ interface CodexWhitespaceToolCallArgumentsDeltaInterruption {
359
533
  message: string;
360
534
  }
361
535
 
362
- interface CodexStreamProcessingContext {
536
+ interface CodexStreamFailureContext {
363
537
  model: Model<"openai-codex-responses">;
364
538
  output: AssistantMessage;
365
- stream: AssistantMessageEventStream;
366
539
  options: OpenAICodexResponsesOptions | undefined;
367
- requestSetup: CodexRequestSetup;
368
540
  requestContext: CodexRequestContext;
369
541
  startTime: number;
370
542
  firstTokenTime?: number;
@@ -374,20 +546,6 @@ interface CodexStreamCompletion {
374
546
  firstTokenTime?: number;
375
547
  }
376
548
 
377
- function parseCodexNonNegativeInteger(value: string | undefined, fallback: number): number {
378
- if (!value) return fallback;
379
- const parsed = Number(value);
380
- if (!Number.isFinite(parsed) || parsed < 0) return fallback;
381
- return Math.trunc(parsed);
382
- }
383
-
384
- function parseCodexPositiveInteger(value: string | undefined, fallback: number): number {
385
- if (!value) return fallback;
386
- const parsed = Number(value);
387
- if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
388
- return Math.trunc(parsed);
389
- }
390
-
391
549
  function createCodexProviderSessionState(): CodexProviderSessionState {
392
550
  const state: CodexProviderSessionState = {
393
551
  webSocketSessions: new Map(),
@@ -639,13 +797,9 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
639
797
  ? AbortSignal.any([options.signal, requestAbortController.signal])
640
798
  : requestAbortController.signal;
641
799
  const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
642
- const websocketIdleTimeoutMs =
643
- options?.streamIdleTimeoutMs ??
644
- parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_IDLE_TIMEOUT_MS, CODEX_WEBSOCKET_IDLE_TIMEOUT_MS);
800
+ const websocketIdleTimeoutMs = options?.streamIdleTimeoutMs ?? CODEX_WEBSOCKET_IDLE_TIMEOUT_MS;
645
801
  const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
646
- const websocketFirstEventTimeoutMs =
647
- options?.streamFirstEventTimeoutMs ??
648
- parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS, CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS);
802
+ const websocketFirstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? CODEX_WEBSOCKET_FIRST_EVENT_TIMEOUT_MS;
649
803
  const wrapCodexSseStream = (
650
804
  source: AsyncGenerator<Record<string, unknown>>,
651
805
  ): AsyncGenerator<Record<string, unknown>> =>
@@ -677,7 +831,7 @@ async function buildCodexRequestContext(
677
831
  ): Promise<CodexRequestContext> {
678
832
  const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
679
833
  if (!apiKey) {
680
- throw new Error(`No API key for provider: ${model.provider}`);
834
+ throw new AIError.MissingApiKeyError(model.provider);
681
835
  }
682
836
 
683
837
  const accountId = getAccountId(apiKey);
@@ -791,10 +945,7 @@ async function openInitialCodexEventStream(
791
945
  }> {
792
946
  const { transformedBody, websocketState } = requestContext;
793
947
  if (websocketState && shouldUseCodexWebSocket(model, websocketState, options?.preferWebsockets)) {
794
- const websocketRetryBudget = parseCodexNonNegativeInteger(
795
- $env.PI_CODEX_WEBSOCKET_RETRY_BUDGET,
796
- CODEX_WEBSOCKET_RETRY_BUDGET,
797
- );
948
+ const websocketRetryBudget = CODEX_WEBSOCKET_RETRY_BUDGET;
798
949
  let websocketRetries = 0;
799
950
  while (true) {
800
951
  try {
@@ -825,13 +976,9 @@ async function openInitialCodexEventStream(
825
976
  });
826
977
  if (!activateFallback) {
827
978
  websocketRetries += 1;
828
- await scheduler.wait(
829
- parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS) *
830
- Math.max(1, websocketRetries),
831
- {
832
- signal: requestSetup.requestSignal,
833
- },
834
- );
979
+ await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, websocketRetries), {
980
+ signal: requestSetup.requestSignal,
981
+ });
835
982
  continue;
836
983
  }
837
984
  break;
@@ -893,7 +1040,7 @@ async function openCodexWebSocketTransport(
893
1040
  sentModelsEtagHeader: websocketHeaders.has(X_MODELS_ETAG_HEADER),
894
1041
  requestType: websocketRequest.type,
895
1042
  retry,
896
- retryBudget: parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET),
1043
+ retryBudget: CODEX_WEBSOCKET_RETRY_BUDGET,
897
1044
  });
898
1045
  const websocketConnection = await getOrCreateCodexWebSocketConnection(
899
1046
  websocketState,
@@ -973,131 +1120,6 @@ async function openCodexSseTransport(
973
1120
  return { eventStream: await open(wireBody), requestBodyForState: structuredCloneJSON(wireBody), transport: "sse" };
974
1121
  }
975
1122
 
976
- async function reopenCodexWebSocketRuntimeStream(
977
- context: CodexStreamProcessingContext,
978
- runtime: CodexStreamRuntime,
979
- state: CodexWebSocketSessionState,
980
- ): Promise<void> {
981
- try {
982
- const next = await openCodexWebSocketTransport(
983
- context.model,
984
- context.options,
985
- context.requestContext,
986
- context.requestSetup,
987
- state,
988
- runtime.websocketStreamRetries,
989
- context.options ? event => context.options?.onSseEvent?.(event, context.model) : undefined,
990
- );
991
- runtime.eventStream = next.eventStream;
992
- runtime.requestBodyForState = next.requestBodyForState;
993
- runtime.transport = next.transport;
994
- state.lastTransport = next.transport;
995
- } catch (error) {
996
- if (!(error instanceof CodexWebSocketTransportError)) throw error;
997
- // Reopen failed at the websocket layer (handshake refused, connect timeout, etc.).
998
- // Activate fallback so subsequent turns use SSE, and replay this turn over SSE
999
- // instead of surfacing a raw transport error to the caller.
1000
- recordCodexWebSocketFailure(state, true);
1001
- CODEX_DEBUG &&
1002
- logger.debug("[codex] codex websocket reopen failed, falling back to SSE", {
1003
- error: error.message,
1004
- retry: runtime.websocketStreamRetries,
1005
- });
1006
- await reopenCodexSseRuntimeStream(context, runtime, state);
1007
- }
1008
- }
1009
-
1010
- async function reopenCodexSseRuntimeStream(
1011
- context: CodexStreamProcessingContext,
1012
- runtime: CodexStreamRuntime,
1013
- state: CodexWebSocketSessionState | undefined,
1014
- ): Promise<void> {
1015
- const next = await openCodexSseTransport(
1016
- context.model,
1017
- context.requestContext,
1018
- context.requestSetup,
1019
- context.options,
1020
- state,
1021
- );
1022
- runtime.eventStream = next.eventStream;
1023
- runtime.requestBodyForState = next.requestBodyForState;
1024
- runtime.transport = next.transport;
1025
- if (state) {
1026
- state.lastTransport = next.transport;
1027
- }
1028
- }
1029
-
1030
- function createCodexStreamRuntime(initial: {
1031
- eventStream: AsyncGenerator<Record<string, unknown>>;
1032
- requestBodyForState: RequestBody;
1033
- transport: CodexTransport;
1034
- websocketState?: CodexWebSocketSessionState;
1035
- }): CodexStreamRuntime {
1036
- return {
1037
- eventStream: initial.eventStream,
1038
- requestBodyForState: initial.requestBodyForState,
1039
- transport: initial.transport,
1040
- websocketState: initial.websocketState,
1041
- openItems: new Map(),
1042
- openItemsByOutputIndex: new Map(),
1043
- currentEntry: null,
1044
- currentItem: null,
1045
- currentBlock: null,
1046
- nativeOutputItems: [],
1047
- websocketStreamRetries: 0,
1048
- providerRetryAttempt: 0,
1049
- whitespaceLoopRetries: 0,
1050
- sawTerminalEvent: false,
1051
- canSafelyReplayWebsocketOverSse: true,
1052
- whitespaceToolCallArgumentsDelta: undefined,
1053
- };
1054
- }
1055
-
1056
- /**
1057
- * Wipe per-attempt accumulator state before a recovery path replays the turn.
1058
- * Keeps {@link CodexStreamRuntime.openItems} and the legacy singleton-current
1059
- * pointers in lockstep with {@link CodexStreamRuntime.nativeOutputItems} so a
1060
- * stale delta from the failed attempt can't bind to a sibling on the retry.
1061
- */
1062
- function resetCodexStreamAccumulators(runtime: CodexStreamRuntime): void {
1063
- runtime.openItems.clear();
1064
- runtime.openItemsByOutputIndex.clear();
1065
- runtime.currentEntry = null;
1066
- runtime.currentItem = null;
1067
- runtime.currentBlock = null;
1068
- runtime.nativeOutputItems.length = 0;
1069
- }
1070
-
1071
- /**
1072
- * Look up the open item a Codex stream event targets. `item_id` wins because it
1073
- * uniquely identifies a response item; `output_index` covers idless function
1074
- * call items. A keyed event whose target is already closed is dropped instead
1075
- * of being routed to a sibling. Only streams that omit both keys fall back to
1076
- * {@link CodexStreamRuntime.currentEntry} — the most recently added item,
1077
- * including fully keyless ones that never reached the keyed maps.
1078
- */
1079
- function openItemForEvent(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): CodexOpenItem | null {
1080
- const itemId = typeof rawEvent.item_id === "string" ? rawEvent.item_id : "";
1081
- if (itemId) return runtime.openItems.get(itemId) ?? null;
1082
- const outputIndex =
1083
- typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
1084
- ? Math.trunc(rawEvent.output_index)
1085
- : undefined;
1086
- if (outputIndex !== undefined) return runtime.openItemsByOutputIndex.get(outputIndex) ?? null;
1087
- return runtime.currentEntry;
1088
- }
1089
-
1090
- function closeCodexOpenItem(runtime: CodexStreamRuntime, entry: CodexOpenItem | null | undefined): void {
1091
- if (!entry) return;
1092
- if (entry.itemId) runtime.openItems.delete(entry.itemId);
1093
- if (entry.outputIndex !== undefined) runtime.openItemsByOutputIndex.delete(entry.outputIndex);
1094
- if (runtime.currentEntry === entry) {
1095
- runtime.currentEntry = null;
1096
- runtime.currentItem = null;
1097
- runtime.currentBlock = null;
1098
- }
1099
- }
1100
-
1101
1123
  function isJsonWhitespaceOnly(value: string): boolean {
1102
1124
  for (let index = 0; index < value.length; index += 1) {
1103
1125
  const code = value.charCodeAt(index);
@@ -1108,261 +1130,13 @@ function isJsonWhitespaceOnly(value: string): boolean {
1108
1130
  return true;
1109
1131
  }
1110
1132
 
1111
- function observeWhitespaceToolCallArgumentsDelta(
1112
- runtime: CodexStreamRuntime,
1113
- rawEvent: Record<string, unknown>,
1114
- delta: string,
1115
- ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
1116
- if (!isJsonWhitespaceOnly(delta)) {
1117
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1118
- return undefined;
1119
- }
1120
-
1121
- const itemId =
1122
- typeof rawEvent.item_id === "string" && rawEvent.item_id.length > 0
1123
- ? rawEvent.item_id
1124
- : (runtime.currentItem?.id ?? "");
1125
- const outputIndex =
1126
- typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
1127
- ? Math.trunc(rawEvent.output_index)
1128
- : undefined;
1129
- const sequenceNumber =
1130
- typeof rawEvent.sequence_number === "number" && Number.isFinite(rawEvent.sequence_number)
1131
- ? Math.trunc(rawEvent.sequence_number)
1132
- : undefined;
1133
- let state = runtime.whitespaceToolCallArgumentsDelta;
1134
- if (!state || state.itemId !== itemId || state.outputIndex !== outputIndex) {
1135
- state = {
1136
- itemId,
1137
- outputIndex,
1138
- consecutiveEvents: 0,
1139
- consecutiveChars: 0,
1140
- firstSequenceNumber: sequenceNumber,
1141
- };
1142
- runtime.whitespaceToolCallArgumentsDelta = state;
1143
- }
1144
-
1145
- state.consecutiveEvents += 1;
1146
- state.consecutiveChars += delta.length;
1147
- state.lastSequenceNumber = sequenceNumber;
1148
- if (
1149
- state.consecutiveEvents < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_EVENT_LIMIT &&
1150
- state.consecutiveChars < CODEX_WHITESPACE_TOOL_CALL_ARGUMENT_DELTA_CHAR_LIMIT
1151
- ) {
1152
- return undefined;
1153
- }
1154
-
1155
- const itemLabel = itemId ? ` for item ${itemId}` : "";
1156
- const sequenceLabel =
1157
- state.firstSequenceNumber === undefined || state.lastSequenceNumber === undefined
1158
- ? ""
1159
- : `, sequence ${state.firstSequenceNumber}..${state.lastSequenceNumber}`;
1160
- return {
1161
- message: `Interrupted OpenAI Codex response after ${state.consecutiveEvents} consecutive whitespace-only tool-call argument delta events (${state.consecutiveChars} chars${sequenceLabel})${itemLabel}.`,
1162
- };
1163
- }
1164
-
1165
- async function processCodexResponseStream(
1166
- context: CodexStreamProcessingContext,
1167
- runtime: CodexStreamRuntime,
1168
- ): Promise<CodexStreamCompletion> {
1169
- const { output, stream } = context;
1170
- stream.push({ type: "start", partial: output });
1171
-
1172
- while (true) {
1173
- try {
1174
- let firstTokenTime = context.firstTokenTime;
1175
- for await (const rawEvent of runtime.eventStream) {
1176
- firstTokenTime = handleCodexStreamEvent(context, runtime, rawEvent, firstTokenTime);
1177
- if (runtime.sawTerminalEvent) break;
1178
- }
1179
- return { firstTokenTime };
1180
- } catch (error) {
1181
- const recovered = await recoverCodexStreamError(context, runtime, error);
1182
- if (!recovered) {
1183
- throw error;
1184
- }
1185
- stream.push({ type: "start", partial: output });
1186
- }
1187
- }
1188
- }
1189
-
1190
- function handleCodexStreamEvent(
1191
- context: CodexStreamProcessingContext,
1192
- runtime: CodexStreamRuntime,
1193
- rawEvent: Record<string, unknown>,
1194
- firstTokenTime: number | undefined,
1195
- ): number | undefined {
1196
- const { model, output, stream } = context;
1197
- const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
1198
- if (!eventType) return firstTokenTime;
1199
-
1200
- if (eventType === "response.output_item.added") {
1201
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1202
- if (!firstTokenTime) firstTokenTime = Date.now();
1203
- const item = rawEvent.item as CodexEventItem;
1204
- runtime.currentItem = item;
1205
- runtime.currentBlock = createOutputBlockForItem(item);
1206
- let contentIndex = -1;
1207
- if (runtime.currentBlock) {
1208
- output.content.push(runtime.currentBlock);
1209
- contentIndex = output.content.length - 1;
1210
- }
1211
- // Track every open item by every stable key the wire gives us. `item.id`
1212
- // is best; `output_index` preserves idless function/custom tool calls and
1213
- // keeps their final args authoritative when only `output_item.done`
1214
- // carries the full payload.
1215
- const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : undefined;
1216
- const outputIndex =
1217
- typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
1218
- ? Math.trunc(rawEvent.output_index)
1219
- : undefined;
1220
- const entry: CodexOpenItem = { item, block: runtime.currentBlock, contentIndex, itemId, outputIndex };
1221
- runtime.currentEntry = entry;
1222
- if (itemId) runtime.openItems.set(itemId, entry);
1223
- if (outputIndex !== undefined) runtime.openItemsByOutputIndex.set(outputIndex, entry);
1224
- if (!runtime.currentBlock) return firstTokenTime;
1225
- stream.push({
1226
- type: getOutputBlockStartEventType(runtime.currentBlock),
1227
- contentIndex,
1228
- partial: output,
1229
- });
1230
- return firstTokenTime;
1231
- }
1232
-
1233
- if (eventType === "response.reasoning_summary_part.added") {
1234
- if (runtime.currentItem?.type === "reasoning") {
1235
- appendReasoningSummaryPart(
1236
- runtime.currentItem,
1237
- (rawEvent as { part: ResponseReasoningItem["summary"][number] }).part,
1238
- );
1239
- }
1240
- return firstTokenTime;
1241
- }
1242
-
1243
- if (eventType === "response.reasoning_summary_text.delta") {
1244
- if (runtime.currentItem?.type === "reasoning" && runtime.currentBlock?.type === "thinking") {
1245
- appendReasoningSummaryTextDelta(
1246
- runtime.currentItem,
1247
- runtime.currentBlock,
1248
- (rawEvent as { delta?: string }).delta || "",
1249
- stream,
1250
- output,
1251
- output.content.length - 1,
1252
- );
1253
- }
1254
- return firstTokenTime;
1255
- }
1256
-
1257
- if (eventType === "response.reasoning_summary_part.done") {
1258
- if (runtime.currentItem?.type === "reasoning" && runtime.currentBlock?.type === "thinking") {
1259
- appendReasoningSummaryPartDone(
1260
- runtime.currentItem,
1261
- runtime.currentBlock,
1262
- stream,
1263
- output,
1264
- output.content.length - 1,
1265
- );
1266
- }
1267
- return firstTokenTime;
1268
- }
1269
-
1270
- if (eventType === "response.content_part.added") {
1271
- if (runtime.currentItem?.type === "message") {
1272
- appendMessageContentPart(
1273
- runtime.currentItem,
1274
- (rawEvent as { part?: ResponseOutputMessage["content"][number] }).part,
1275
- );
1276
- }
1277
- return firstTokenTime;
1278
- }
1279
-
1280
- if (eventType === "response.output_text.delta" || eventType === "response.refusal.delta") {
1281
- if (runtime.currentItem?.type === "message" && runtime.currentBlock?.type === "text") {
1282
- appendMessageTextDelta(
1283
- runtime.currentItem,
1284
- runtime.currentBlock,
1285
- (rawEvent as { delta?: string }).delta || "",
1286
- stream,
1287
- output,
1288
- output.content.length - 1,
1289
- eventType === "response.refusal.delta" ? "refusal" : "output_text",
1290
- );
1291
- }
1292
- return firstTokenTime;
1293
- }
1294
-
1295
- if (eventType === "response.function_call_arguments.delta") {
1296
- const interruption = handleToolCallArgumentsDelta(runtime, rawEvent, stream, output);
1297
- if (interruption) {
1298
- runtime.websocketState?.connection?.close("degenerate-tool-call");
1299
- throw new CodexWhitespaceToolCallLoopError(interruption.message);
1300
- }
1301
- return firstTokenTime;
1302
- }
1303
-
1304
- if (eventType === "response.function_call_arguments.done") {
1305
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1306
- handleToolCallArgumentsDone(runtime, rawEvent);
1307
- return firstTokenTime;
1308
- }
1309
-
1310
- if (eventType === "response.custom_tool_call_input.delta") {
1311
- const interruption = handleCustomToolCallInputDelta(runtime, rawEvent, stream, output);
1312
- if (interruption) {
1313
- runtime.websocketState?.connection?.close("degenerate-tool-call");
1314
- throw new CodexWhitespaceToolCallLoopError(interruption.message);
1315
- }
1316
- return firstTokenTime;
1317
- }
1318
-
1319
- if (eventType === "response.custom_tool_call_input.done") {
1320
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1321
- handleCustomToolCallInputDone(runtime, rawEvent);
1322
- return firstTokenTime;
1323
- }
1324
-
1325
- if (eventType === "response.output_item.done") {
1326
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1327
- handleOutputItemDone(model, output, stream, runtime, rawEvent);
1328
- return firstTokenTime;
1329
- }
1330
-
1331
- if (eventType === "response.created") {
1332
- handleResponseCreated(runtime, rawEvent);
1333
- return firstTokenTime;
1334
- }
1335
-
1336
- if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
1337
- handleResponseCompleted(model, output, runtime, rawEvent);
1338
- return firstTokenTime;
1339
- }
1340
-
1341
- if (eventType === "response.metadata") {
1342
- const moderation = asRecord(rawEvent.metadata)?.[CODEX_MODERATION_METADATA_KEY];
1343
- if (moderation !== undefined) {
1344
- try {
1345
- context.options?.onModerationMetadata?.(moderation);
1346
- } catch {
1347
- // Diagnostic observer: failures must not disturb the stream.
1348
- }
1349
- }
1350
- return firstTokenTime;
1351
- }
1352
-
1353
- if (eventType === "error" || eventType === "response.failed") {
1354
- throw createCodexProviderStreamError(rawEvent);
1355
- }
1356
-
1357
- return firstTokenTime;
1358
- }
1359
-
1360
1133
  function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null {
1361
1134
  if (item.type === "reasoning") {
1362
1135
  return { type: "thinking", thinking: "" };
1363
1136
  }
1364
1137
  if (item.type === "message") {
1365
- return { type: "text", text: "" };
1138
+ const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
1139
+ return { type: "text", text: "", textSignature: encodeTextSignatureV1(item.id, phase) };
1366
1140
  }
1367
1141
  if (item.type === "function_call") {
1368
1142
  return {
@@ -1370,7 +1144,7 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1370
1144
  id: encodeResponsesToolCallId(item.call_id, item.id),
1371
1145
  name: item.name,
1372
1146
  arguments: {},
1373
- partialJson: item.arguments || "",
1147
+ [kStreamingPartialJson]: item.arguments || "",
1374
1148
  };
1375
1149
  }
1376
1150
  if (item.type === "custom_tool_call") {
@@ -1383,7 +1157,7 @@ function createOutputBlockForItem(item: CodexEventItem): CodexOutputBlock | null
1383
1157
  name: item.name,
1384
1158
  arguments: { input: item.input ?? "" },
1385
1159
  customWireName: item.name,
1386
- partialJson: item.input ?? "",
1160
+ [kStreamingPartialJson]: item.input ?? "",
1387
1161
  };
1388
1162
  }
1389
1163
  return null;
@@ -1395,619 +1169,778 @@ function getOutputBlockStartEventType(block: CodexOutputBlock): "thinking_start"
1395
1169
  return "toolcall_start";
1396
1170
  }
1397
1171
 
1398
- function handleToolCallArgumentsDelta(
1399
- runtime: CodexStreamRuntime,
1400
- rawEvent: Record<string, unknown>,
1401
- stream: AssistantMessageEventStream,
1402
- output: AssistantMessage,
1403
- ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
1404
- const delta = (rawEvent as { delta?: string }).delta || "";
1405
- // Observe BEFORE the item/block guard: degenerate whitespace frames can keep
1406
- // arriving after the item closed (entry detached) and still count as
1407
- // progress for the idle watchdogs — dropping them unobserved would reopen
1408
- // the infinite-loop hole the breaker exists for.
1409
- const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta);
1410
- if (interruption) return interruption;
1411
- // Route to the entry the event keys to; a delta whose item already closed
1412
- // is dropped instead of leaking into a sibling tool call (#2619).
1413
- const entry = openItemForEvent(runtime, rawEvent);
1414
- if (!entry) return undefined;
1415
- if (entry.item.type !== "function_call" || entry.block?.type !== "toolCall") return undefined;
1416
- accumulateToolCallArgumentsDelta(entry.block, delta, stream, output, entry.contentIndex);
1417
- return undefined;
1172
+ function isCodexStalePreviousResponseError(error: unknown): boolean {
1173
+ if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
1174
+ if (!(error instanceof Error)) return false;
1175
+ if ((error as { code?: string }).code === "previous_response_not_found") return true;
1176
+ // "unsupported": the backend intermittently rejects the parameter outright
1177
+ // with `{"detail":"Unsupported parameter: previous_response_id"}` (no
1178
+ // `error.code`); treat it like a stale chain so the turn replays with full
1179
+ // context instead of surfacing the 400.
1180
+ return (
1181
+ /previous[ _]?response/i.test(error.message) &&
1182
+ /not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
1183
+ );
1418
1184
  }
1419
1185
 
1420
- function handleToolCallArgumentsDone(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): void {
1421
- const entry = openItemForEvent(runtime, rawEvent);
1422
- if (entry?.item.type !== "function_call" || entry.block?.type !== "toolCall") return;
1423
- const args = (rawEvent as { arguments?: string }).arguments;
1424
- if (typeof args === "string") finalizeToolCallArgumentsDone(entry.block, args);
1186
+ async function handleCodexStreamFailure(context: CodexStreamFailureContext, error: unknown): Promise<AssistantMessage> {
1187
+ const { output } = context;
1188
+ if (context.requestContext.websocketState) {
1189
+ resetCodexWebSocketAppendState(context.requestContext.websocketState);
1190
+ context.requestContext.websocketState.turnState = undefined;
1191
+ context.requestContext.websocketState.modelsEtag = undefined;
1192
+ }
1193
+ const result = await AIError.finalize(error, {
1194
+ api: context.model.api,
1195
+ signal: context.options?.signal,
1196
+ rawRequestDump: context.requestContext.rawRequestDump,
1197
+ });
1198
+ output.stopReason = result.stopReason;
1199
+ output.errorStatus = result.status;
1200
+ output.errorId = result.id;
1201
+ output.errorMessage = result.message;
1202
+ output.duration = performance.now() - context.startTime;
1203
+ if (context.firstTokenTime) {
1204
+ output.ttft = context.firstTokenTime - context.startTime;
1205
+ }
1206
+ return output;
1425
1207
  }
1426
1208
 
1427
- function handleCustomToolCallInputDelta(
1428
- runtime: CodexStreamRuntime,
1429
- rawEvent: Record<string, unknown>,
1430
- stream: AssistantMessageEventStream,
1431
- output: AssistantMessage,
1432
- ): CodexWhitespaceToolCallArgumentsDeltaInterruption | undefined {
1433
- const delta = (rawEvent as { delta?: string }).delta || "";
1434
- // Observe BEFORE the item/block guard — see handleToolCallArgumentsDelta.
1435
- const interruption = observeWhitespaceToolCallArgumentsDelta(runtime, rawEvent, delta);
1436
- if (interruption) return interruption;
1437
- const entry = openItemForEvent(runtime, rawEvent);
1438
- if (!entry) return undefined;
1439
- if (entry.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return undefined;
1440
- accumulateCustomToolCallInputDelta(entry.block, delta, stream, output, entry.contentIndex);
1441
- return undefined;
1442
- }
1209
+ /**
1210
+ * Owns one `streamOpenAICodexResponses` call: the request scaffolding
1211
+ * (model/output/stream/options/request context) plus the per-attempt
1212
+ * {@link CodexStreamRuntime}. Drives the event loop in {@link process}, applies
1213
+ * the transport-fallback / retry recovery ladder, and emits the final message
1214
+ * in {@link finalize}. The runtime object is mutated in place across retries
1215
+ * (event stream and accumulators are swapped/reset), never reassigned.
1216
+ */
1217
+ class CodexStreamProcessor {
1218
+ runtime: CodexStreamRuntime;
1219
+ model: Model<"openai-codex-responses">;
1220
+ output: AssistantMessage;
1221
+ stream: AssistantMessageEventStream;
1222
+ options: OpenAICodexResponsesOptions | undefined;
1223
+ requestSetup: CodexRequestSetup;
1224
+ requestContext: CodexRequestContext;
1225
+ startTime: number;
1226
+ firstTokenTime?: number;
1443
1227
 
1444
- function handleCustomToolCallInputDone(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): void {
1445
- const entry = openItemForEvent(runtime, rawEvent);
1446
- if (entry?.item.type !== "custom_tool_call" || entry.block?.type !== "toolCall") return;
1447
- const input = (rawEvent as { input?: string }).input;
1448
- if (typeof input === "string") finalizeCustomToolCallInputDone(entry.block, input);
1449
- }
1228
+ constructor(init: {
1229
+ runtime: CodexStreamRuntime;
1230
+ model: Model<"openai-codex-responses">;
1231
+ output: AssistantMessage;
1232
+ stream: AssistantMessageEventStream;
1233
+ options: OpenAICodexResponsesOptions | undefined;
1234
+ requestSetup: CodexRequestSetup;
1235
+ requestContext: CodexRequestContext;
1236
+ startTime: number;
1237
+ }) {
1238
+ this.runtime = init.runtime;
1239
+ this.model = init.model;
1240
+ this.output = init.output;
1241
+ this.stream = init.stream;
1242
+ this.options = init.options;
1243
+ this.requestSetup = init.requestSetup;
1244
+ this.requestContext = init.requestContext;
1245
+ this.startTime = init.startTime;
1246
+ }
1247
+
1248
+ async process(): Promise<CodexStreamCompletion> {
1249
+ const { output, stream } = this;
1250
+ stream.push({ type: "start", partial: output });
1450
1251
 
1451
- function handleOutputItemDone(
1452
- model: Model<"openai-codex-responses">,
1453
- output: AssistantMessage,
1454
- stream: AssistantMessageEventStream,
1455
- runtime: CodexStreamRuntime,
1456
- rawEvent: Record<string, unknown>,
1457
- ): void {
1458
- const rawItem = rawEvent.item;
1459
- if (!rawItem || typeof rawItem !== "object") return;
1460
- const item = structuredCloneJSON(rawItem) as CodexEventItem;
1461
- runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
1462
-
1463
- // Match the finalization to the OPEN ITEM that started this block, not the
1464
- // singleton current interleaved items can finish out of order, so the
1465
- // most-recently-added block may belong to a sibling (#2619). Some Codex
1466
- // function/custom tool items omit `id`; in that case `output_index` still
1467
- // routes `output_item.done` to the block that received `output_item.added`.
1468
- const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
1469
- const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? openItemForEvent(runtime, rawEvent);
1470
- const block = entry?.block ?? null;
1471
- const contentIndex = entry?.contentIndex ?? output.content.length - 1;
1472
-
1473
- if (item.type === "reasoning" && block?.type === "thinking") {
1474
- block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
1475
- block.thinkingSignature = JSON.stringify(item);
1476
- stream.push({
1477
- type: "thinking_end",
1478
- contentIndex,
1479
- content: block.thinking,
1480
- partial: output,
1481
- });
1482
- closeCodexOpenItem(runtime, entry);
1483
- return;
1252
+ while (true) {
1253
+ try {
1254
+ let firstTokenTime = this.firstTokenTime;
1255
+ for await (const rawEvent of this.runtime.eventStream) {
1256
+ firstTokenTime = this.#handleStreamEvent(rawEvent, firstTokenTime);
1257
+ if (this.runtime.sawTerminalEvent) break;
1258
+ }
1259
+ return { firstTokenTime };
1260
+ } catch (error) {
1261
+ const recovered = await this.#recoverStreamError(error);
1262
+ if (!recovered) {
1263
+ throw error;
1264
+ }
1265
+ stream.push({ type: "start", partial: output });
1266
+ }
1267
+ }
1484
1268
  }
1485
1269
 
1486
- if (item.type === "message" && block?.type === "text") {
1487
- block.text = item.content
1488
- .map(content => (content.type === "output_text" ? content.text : content.refusal))
1489
- .join("");
1490
- const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
1491
- block.textSignature = encodeTextSignatureV1(item.id, phase);
1492
- stream.push({
1493
- type: "text_end",
1494
- contentIndex,
1495
- content: block.text,
1496
- partial: output,
1497
- });
1498
- closeCodexOpenItem(runtime, entry);
1499
- return;
1500
- }
1270
+ #handleStreamEvent(rawEvent: Record<string, unknown>, firstTokenTime: number | undefined): number | undefined {
1271
+ const { output, stream } = this;
1272
+ const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
1273
+ if (!eventType) return firstTokenTime;
1274
+
1275
+ if (eventType === "response.output_item.added") {
1276
+ this.runtime.whitespaceToolCallArgumentsDelta = undefined;
1277
+ if (!firstTokenTime) firstTokenTime = performance.now();
1278
+ const item = rawEvent.item as CodexEventItem;
1279
+ this.runtime.currentItem = item;
1280
+ this.runtime.currentBlock = createOutputBlockForItem(item);
1281
+ let contentIndex = -1;
1282
+ if (this.runtime.currentBlock) {
1283
+ output.content.push(this.runtime.currentBlock);
1284
+ contentIndex = output.content.length - 1;
1285
+ }
1286
+ // Track every open item by every stable key the wire gives us. `item.id`
1287
+ // is best; `output_index` preserves idless function/custom tool calls and
1288
+ // keeps their final args authoritative when only `output_item.done`
1289
+ // carries the full payload.
1290
+ const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : undefined;
1291
+ const outputIndex =
1292
+ typeof rawEvent.output_index === "number" && Number.isFinite(rawEvent.output_index)
1293
+ ? Math.trunc(rawEvent.output_index)
1294
+ : undefined;
1295
+ const entry: CodexOpenItem = { item, block: this.runtime.currentBlock, contentIndex, itemId, outputIndex };
1296
+ this.runtime.currentEntry = entry;
1297
+ if (itemId) this.runtime.openItems.set(itemId, entry);
1298
+ if (outputIndex !== undefined) this.runtime.openItemsByOutputIndex.set(outputIndex, entry);
1299
+ if (!this.runtime.currentBlock) return firstTokenTime;
1300
+ stream.push({
1301
+ type: getOutputBlockStartEventType(this.runtime.currentBlock),
1302
+ contentIndex,
1303
+ partial: output,
1304
+ });
1305
+ return firstTokenTime;
1306
+ }
1307
+
1308
+ if (eventType === "response.reasoning_summary_part.added") {
1309
+ if (this.runtime.currentItem?.type === "reasoning") {
1310
+ appendReasoningSummaryPart(
1311
+ this.runtime.currentItem,
1312
+ (rawEvent as { part: ResponseReasoningItem["summary"][number] }).part,
1313
+ );
1314
+ }
1315
+ return firstTokenTime;
1316
+ }
1317
+
1318
+ if (eventType === "response.reasoning_summary_text.delta") {
1319
+ if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1320
+ appendReasoningSummaryTextDelta(
1321
+ this.runtime.currentItem,
1322
+ this.runtime.currentBlock,
1323
+ (rawEvent as { delta?: string }).delta || "",
1324
+ stream,
1325
+ output,
1326
+ output.content.length - 1,
1327
+ );
1328
+ }
1329
+ return firstTokenTime;
1330
+ }
1331
+
1332
+ if (eventType === "response.reasoning_summary_part.done") {
1333
+ if (this.runtime.currentItem?.type === "reasoning" && this.runtime.currentBlock?.type === "thinking") {
1334
+ appendReasoningSummaryPartDone(
1335
+ this.runtime.currentItem,
1336
+ this.runtime.currentBlock,
1337
+ stream,
1338
+ output,
1339
+ output.content.length - 1,
1340
+ );
1341
+ }
1342
+ return firstTokenTime;
1343
+ }
1344
+
1345
+ if (eventType === "response.content_part.added") {
1346
+ if (this.runtime.currentItem?.type === "message") {
1347
+ appendMessageContentPart(
1348
+ this.runtime.currentItem,
1349
+ (rawEvent as { part?: ResponseOutputMessage["content"][number] }).part,
1350
+ );
1351
+ }
1352
+ return firstTokenTime;
1353
+ }
1354
+
1355
+ if (eventType === "response.output_text.delta" || eventType === "response.refusal.delta") {
1356
+ if (this.runtime.currentItem?.type === "message" && this.runtime.currentBlock?.type === "text") {
1357
+ appendMessageTextDelta(
1358
+ this.runtime.currentItem,
1359
+ this.runtime.currentBlock,
1360
+ (rawEvent as { delta?: string }).delta || "",
1361
+ stream,
1362
+ output,
1363
+ output.content.length - 1,
1364
+ eventType === "response.refusal.delta" ? "refusal" : "output_text",
1365
+ );
1366
+ }
1367
+ return firstTokenTime;
1368
+ }
1501
1369
 
1502
- if (item.type === "function_call") {
1503
- const toolCall: ToolCall = {
1504
- type: "toolCall",
1505
- id: encodeResponsesToolCallId(item.call_id, item.id),
1506
- name: item.name,
1507
- arguments: parseStreamingJson(item.arguments || "{}"),
1508
- };
1509
- if (block?.type === "toolCall") {
1510
- // Persist the authoritative final args on the stored block; the throttled
1511
- // delta parser may have left block.arguments stale (often `{}`).
1512
- block.arguments = toolCall.arguments;
1513
- delete (block as { partialJson?: string }).partialJson;
1514
- delete (block as { lastParseLen?: number }).lastParseLen;
1515
- }
1516
- // Detach so a late/duplicate arguments.delta cannot append to the
1517
- // finished block or trip the whitespace-loop guard against it.
1518
- closeCodexOpenItem(runtime, entry);
1519
- runtime.canSafelyReplayWebsocketOverSse = false;
1520
- stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
1521
- return;
1522
- }
1370
+ if (eventType === "response.function_call_arguments.delta") {
1371
+ const interruption = this.runtime.handleToolCallArgumentsDelta(rawEvent, stream, output);
1372
+ if (interruption) {
1373
+ this.runtime.websocketState?.connection?.close("degenerate-tool-call");
1374
+ throw new CodexWhitespaceToolCallLoopError(interruption.message);
1375
+ }
1376
+ return firstTokenTime;
1377
+ }
1523
1378
 
1524
- if (item.type === "custom_tool_call") {
1525
- const partial =
1526
- block?.type === "toolCall" ? (block as ToolCall & { partialJson?: string }).partialJson : undefined;
1527
- const rawInput = partial && partial.length > 0 ? partial : (item.input ?? "");
1528
- const toolCall: ToolCall = {
1529
- type: "toolCall",
1530
- id: encodeResponsesToolCallId(item.call_id, item.id),
1531
- name: item.name,
1532
- arguments: { input: rawInput },
1533
- customWireName: item.name,
1534
- };
1535
- if (block?.type === "toolCall") {
1536
- block.arguments = { input: rawInput };
1537
- delete (block as { partialJson?: string }).partialJson;
1379
+ if (eventType === "response.function_call_arguments.done") {
1380
+ this.runtime.whitespaceToolCallArgumentsDelta = undefined;
1381
+ this.runtime.handleToolCallArgumentsDone(rawEvent);
1382
+ return firstTokenTime;
1538
1383
  }
1539
- closeCodexOpenItem(runtime, entry);
1540
- runtime.canSafelyReplayWebsocketOverSse = false;
1541
- stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
1542
- return;
1543
- }
1544
1384
 
1545
- void model;
1546
- }
1385
+ if (eventType === "response.custom_tool_call_input.delta") {
1386
+ const interruption = this.runtime.handleCustomToolCallInputDelta(rawEvent, stream, output);
1387
+ if (interruption) {
1388
+ this.runtime.websocketState?.connection?.close("degenerate-tool-call");
1389
+ throw new CodexWhitespaceToolCallLoopError(interruption.message);
1390
+ }
1391
+ return firstTokenTime;
1392
+ }
1547
1393
 
1548
- function handleResponseCreated(runtime: CodexStreamRuntime, rawEvent: Record<string, unknown>): void {
1549
- const response = (rawEvent as { response?: { id?: string } }).response;
1550
- const state = runtime.websocketState;
1551
- if (state && runtime.transport === "websocket" && typeof response?.id === "string" && response.id.length > 0) {
1552
- state.lastResponseId = response.id;
1553
- }
1554
- }
1394
+ if (eventType === "response.custom_tool_call_input.done") {
1395
+ this.runtime.whitespaceToolCallArgumentsDelta = undefined;
1396
+ this.runtime.handleCustomToolCallInputDone(rawEvent);
1397
+ return firstTokenTime;
1398
+ }
1555
1399
 
1556
- function handleResponseCompleted(
1557
- model: Model<"openai-codex-responses">,
1558
- output: AssistantMessage,
1559
- runtime: CodexStreamRuntime,
1560
- rawEvent: Record<string, unknown>,
1561
- ): void {
1562
- runtime.sawTerminalEvent = true;
1563
- const response = (
1564
- rawEvent as {
1565
- response?: {
1566
- id?: string;
1567
- usage?: {
1568
- input_tokens?: number;
1569
- output_tokens?: number;
1570
- total_tokens?: number;
1571
- input_tokens_details?: { cached_tokens?: number };
1572
- output_tokens_details?: { reasoning_tokens?: number };
1573
- };
1574
- status?: string;
1575
- service_tier?: ServiceTier | "default";
1576
- end_turn?: boolean;
1577
- };
1400
+ if (eventType === "response.output_item.done") {
1401
+ this.runtime.whitespaceToolCallArgumentsDelta = undefined;
1402
+ this.#handleOutputItemDone(rawEvent);
1403
+ return firstTokenTime;
1578
1404
  }
1579
- ).response;
1580
1405
 
1581
- populateResponsesUsageFromResponse(output, response?.usage);
1582
- if (typeof response?.id === "string" && response.id.length > 0) {
1583
- output.responseId = response.id;
1584
- }
1406
+ if (eventType === "response.created") {
1407
+ this.runtime.handleResponseCreated(rawEvent);
1408
+ return firstTokenTime;
1409
+ }
1585
1410
 
1586
- const state = runtime.websocketState;
1587
- if (state) {
1588
- if (runtime.transport !== "websocket") {
1589
- // SSE turns never chain (previous_response_id is websocket-only on this
1590
- // endpoint); a completed SSE turn also invalidates any websocket append
1591
- // baseline, which no longer matches the transcript.
1592
- resetCodexWebSocketAppendState(state);
1593
- } else {
1594
- state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
1595
- if (typeof response?.id === "string" && response.id.length > 0) {
1596
- state.lastResponseId = response.id;
1597
- state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
1598
- state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
1599
- } else {
1600
- // Without a response id the append baseline cannot be trusted.
1601
- state.canAppend = false;
1602
- }
1411
+ if (eventType === "response.completed" || eventType === "response.done" || eventType === "response.incomplete") {
1412
+ this.#handleResponseCompleted(rawEvent);
1413
+ return firstTokenTime;
1603
1414
  }
1604
- }
1605
1415
 
1606
- finalizePendingResponsesToolCalls(output);
1416
+ if (eventType === "response.metadata") {
1417
+ const moderation = asRecord(rawEvent.metadata)?.[CODEX_MODERATION_METADATA_KEY];
1418
+ if (moderation !== undefined) {
1419
+ try {
1420
+ this.options?.onModerationMetadata?.(moderation);
1421
+ } catch {
1422
+ // Diagnostic observer: failures must not disturb the stream.
1423
+ }
1424
+ }
1425
+ return firstTokenTime;
1426
+ }
1607
1427
 
1608
- calculateCost(model, output.usage);
1609
- applyCodexServiceTierPricing(model, output.usage, response?.service_tier, runtime.requestBodyForState.service_tier);
1610
- output.stopReason = mapOpenAIResponsesStopReason(response?.status as ResponseStatus | undefined);
1611
- promoteResponsesToolUseStopReason(output, response?.end_turn);
1612
- }
1428
+ if (eventType === "error" || eventType === "response.failed") {
1429
+ throw createCodexProviderStreamError(rawEvent);
1430
+ }
1613
1431
 
1614
- async function recoverCodexStreamError(
1615
- context: CodexStreamProcessingContext,
1616
- runtime: CodexStreamRuntime,
1617
- error: unknown,
1618
- ): Promise<boolean> {
1619
- if (await tryRecoverCodexWhitespaceToolCallLoop(context, runtime, error)) {
1620
- return true;
1621
- }
1622
- if (await tryReconnectCodexWebSocketOnConnectionLimit(context, runtime, error)) {
1623
- return true;
1624
- }
1625
- if (await tryRecoverCodexPreviousResponseNotFound(context, runtime, error)) {
1626
- return true;
1627
- }
1628
- if (await tryReplayWebsocketFailureOverSse(context, runtime, error)) {
1629
- return true;
1630
- }
1631
- if (await tryRetryCodexProviderError(context, runtime, error)) {
1632
- return true;
1432
+ return firstTokenTime;
1633
1433
  }
1634
- return false;
1635
- }
1636
1434
 
1637
- /**
1638
- * Pop the half-built degenerate tool-call block (the one whose arguments were
1639
- * nothing but whitespace) off the output accumulator so it never surfaces in the
1640
- * caller's message. Any legitimate content produced before it is preserved.
1641
- */
1642
- function dropTrailingDegenerateToolCall(output: AssistantMessage, runtime: CodexStreamRuntime): void {
1643
- const block = runtime.currentBlock;
1644
- if (block && block.type === "toolCall" && output.content[output.content.length - 1] === block) {
1645
- output.content.pop();
1646
- }
1647
- closeCodexOpenItem(runtime, runtime.currentEntry);
1648
- }
1435
+ #handleOutputItemDone(rawEvent: Record<string, unknown>): void {
1436
+ const { runtime, output, stream } = this;
1437
+ const rawItem = rawEvent.item;
1438
+ if (!rawItem || typeof rawItem !== "object") return;
1439
+ const item = structuredCloneJSON(rawItem) as CodexEventItem;
1440
+ runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
1441
+
1442
+ // Match the finalization to the OPEN ITEM that started this block, not the
1443
+ // singleton current — interleaved items can finish out of order, so the
1444
+ // most-recently-added block may belong to a sibling (#2619). Some Codex
1445
+ // function/custom tool items omit `id`; in that case `output_index` still
1446
+ // routes `output_item.done` to the block that received `output_item.added`.
1447
+ const itemId = typeof (item as { id?: string }).id === "string" ? (item as { id: string }).id : "";
1448
+ const entry = (itemId ? runtime.openItems.get(itemId) : null) ?? runtime.openItemForEvent(rawEvent);
1449
+ const block = entry?.block ?? null;
1450
+ const contentIndex = entry?.contentIndex ?? output.content.length - 1;
1451
+
1452
+ if (item.type === "reasoning" && block?.type === "thinking") {
1453
+ block.thinking = item.summary?.map(summary => summary.text).join("\n\n") || "";
1454
+ block.thinkingSignature = JSON.stringify(item);
1455
+ stream.push({
1456
+ type: "thinking_end",
1457
+ contentIndex,
1458
+ content: block.thinking,
1459
+ partial: output,
1460
+ });
1461
+ runtime.closeOpenItem(entry);
1462
+ return;
1463
+ }
1649
1464
 
1650
- /**
1651
- * Recover from the degenerate whitespace-only tool-call argument loop
1652
- * ({@link CodexWhitespaceToolCallLoopError}). The interrupted function call has
1653
- * no usable arguments, so drop the partial turn and replay the request from
1654
- * scratch bounded by {@link CODEX_WHITESPACE_LOOP_RETRY_LIMIT}. Sampling
1655
- * nondeterminism usually breaks the loop on a fresh attempt; once the budget is
1656
- * exhausted the original error is surfaced (now without the junk tool call
1657
- * polluting the message). Replay is refused once any visible content was already
1658
- * delivered to the consumer — a finished tool call (`canSafelyReplayWebsocketOverSse`),
1659
- * or any streamed text/commentary block still in `output.content` after the degenerate
1660
- * tool call is dropped — because replaying re-emits already-streamed deltas.
1661
- */
1662
- async function tryRecoverCodexWhitespaceToolCallLoop(
1663
- context: CodexStreamProcessingContext,
1664
- runtime: CodexStreamRuntime,
1665
- error: unknown,
1666
- ): Promise<boolean> {
1667
- if (!(error instanceof CodexWhitespaceToolCallLoopError)) {
1668
- return false;
1669
- }
1670
- // Drop the half-built degenerate tool call whether or not we retry, so it
1671
- // never reaches the caller's message.
1672
- dropTrailingDegenerateToolCall(context.output, runtime);
1673
- if (
1674
- runtime.whitespaceLoopRetries >= CODEX_WHITESPACE_LOOP_RETRY_LIMIT ||
1675
- !runtime.canSafelyReplayWebsocketOverSse ||
1676
- context.output.content.some(block => block.type !== "thinking") ||
1677
- context.options?.signal?.aborted
1678
- ) {
1679
- return false;
1680
- }
1465
+ if (item.type === "message" && block?.type === "text") {
1466
+ block.text = item.content
1467
+ .map(content => (content.type === "output_text" ? content.text : content.refusal))
1468
+ .join("");
1469
+ const phase = item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
1470
+ block.textSignature = encodeTextSignatureV1(item.id, phase);
1471
+ stream.push({
1472
+ type: "text_end",
1473
+ contentIndex,
1474
+ content: block.text,
1475
+ partial: output,
1476
+ });
1477
+ runtime.closeOpenItem(entry);
1478
+ return;
1479
+ }
1681
1480
 
1682
- runtime.whitespaceLoopRetries += 1;
1683
- const websocketState = context.requestContext.websocketState;
1684
- if (websocketState) {
1685
- resetCodexWebSocketAppendState(websocketState);
1686
- websocketState.turnState = undefined;
1687
- websocketState.modelsEtag = undefined;
1481
+ if (item.type === "function_call") {
1482
+ const toolCall: ToolCall = {
1483
+ type: "toolCall",
1484
+ id: encodeResponsesToolCallId(item.call_id, item.id),
1485
+ name: item.name,
1486
+ arguments: parseStreamingJson(item.arguments || "{}"),
1487
+ };
1488
+ if (block?.type === "toolCall") {
1489
+ // Persist the authoritative final args on the stored block; the throttled
1490
+ // delta parser may have left block.arguments stale (often `{}`).
1491
+ block.arguments = toolCall.arguments;
1492
+ clearStreamingPartialJson(block);
1493
+ }
1494
+ // Detach so a late/duplicate arguments.delta cannot append to the
1495
+ // finished block or trip the whitespace-loop guard against it.
1496
+ runtime.closeOpenItem(entry);
1497
+ runtime.canSafelyReplayWebsocketOverSse = false;
1498
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
1499
+ return;
1500
+ }
1501
+
1502
+ if (item.type === "custom_tool_call") {
1503
+ const partial = block?.type === "toolCall" ? block[kStreamingPartialJson] : undefined;
1504
+ const rawInput = partial && partial.length > 0 ? partial : (item.input ?? "");
1505
+ const toolCall: ToolCall = {
1506
+ type: "toolCall",
1507
+ id: encodeResponsesToolCallId(item.call_id, item.id),
1508
+ name: item.name,
1509
+ arguments: { input: rawInput },
1510
+ customWireName: item.name,
1511
+ };
1512
+ if (block?.type === "toolCall") {
1513
+ block.arguments = { input: rawInput };
1514
+ clearStreamingPartialJson(block);
1515
+ }
1516
+ runtime.closeOpenItem(entry);
1517
+ runtime.canSafelyReplayWebsocketOverSse = false;
1518
+ stream.push({ type: "toolcall_end", contentIndex, toolCall, partial: output });
1519
+ return;
1520
+ }
1688
1521
  }
1689
1522
 
1690
- CODEX_DEBUG &&
1691
- logger.debug("[codex] retrying codex turn after whitespace-only tool-call argument loop", {
1692
- retry: runtime.whitespaceLoopRetries,
1693
- retryBudget: CODEX_WHITESPACE_LOOP_RETRY_LIMIT,
1694
- transport: runtime.transport,
1695
- });
1523
+ #handleResponseCompleted(rawEvent: Record<string, unknown>): void {
1524
+ const { runtime, model, output } = this;
1525
+ runtime.sawTerminalEvent = true;
1526
+ const response = (
1527
+ rawEvent as {
1528
+ response?: {
1529
+ id?: string;
1530
+ usage?: {
1531
+ input_tokens?: number;
1532
+ output_tokens?: number;
1533
+ total_tokens?: number;
1534
+ input_tokens_details?: { cached_tokens?: number };
1535
+ output_tokens_details?: { reasoning_tokens?: number };
1536
+ };
1537
+ status?: string;
1538
+ service_tier?: ServiceTier | "default";
1539
+ end_turn?: boolean;
1540
+ };
1541
+ }
1542
+ ).response;
1696
1543
 
1697
- resetCodexStreamAccumulators(runtime);
1698
- runtime.sawTerminalEvent = false;
1699
- runtime.whitespaceToolCallArgumentsDelta = undefined;
1700
- resetOutputState(context.output);
1701
- context.firstTokenTime = undefined;
1702
- await scheduler.wait(CODEX_WHITESPACE_LOOP_RETRY_DELAY_MS * runtime.whitespaceLoopRetries, {
1703
- signal: context.requestSetup.requestSignal,
1704
- });
1544
+ populateResponsesUsageFromResponse(output, response?.usage);
1545
+ if (typeof response?.id === "string" && response.id.length > 0) {
1546
+ output.responseId = response.id;
1547
+ }
1705
1548
 
1706
- if (runtime.transport === "websocket" && websocketState) {
1707
- await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
1708
- return true;
1709
- }
1549
+ const state = runtime.websocketState;
1550
+ if (state) {
1551
+ if (runtime.transport !== "websocket") {
1552
+ // SSE turns never chain (previous_response_id is websocket-only on this
1553
+ // endpoint); a completed SSE turn also invalidates any websocket append
1554
+ // baseline, which no longer matches the transcript.
1555
+ resetCodexWebSocketAppendState(state);
1556
+ } else {
1557
+ state.lastRequest = structuredCloneJSON(runtime.requestBodyForState);
1558
+ if (typeof response?.id === "string" && response.id.length > 0) {
1559
+ state.lastResponseId = response.id;
1560
+ state.lastResponseItems = stripInputItemIds(structuredCloneJSON(runtime.nativeOutputItems));
1561
+ state.canAppend = rawEvent.type === "response.done" || rawEvent.type === "response.completed";
1562
+ } else {
1563
+ // Without a response id the append baseline cannot be trusted.
1564
+ state.canAppend = false;
1565
+ }
1566
+ }
1567
+ }
1710
1568
 
1711
- await reopenCodexSseRuntimeStream(context, runtime, websocketState);
1712
- return true;
1713
- }
1569
+ finalizePendingResponsesToolCalls(output);
1714
1570
 
1715
- /**
1716
- * Handles `websocket_connection_limit_reached` errors by closing the stale connection
1717
- * and opening a fresh websocket. If content has already been emitted to the caller,
1718
- * falls back to SSE replay (same as other WS failures) since we cannot safely
1719
- * continue a partial response on a new connection. If a tool call was already
1720
- * delivered (`canSafelyReplayWebsocketOverSse` is false), the error surfaces
1721
- * instead — replaying would re-emit the same tool calls.
1722
- */
1723
- async function tryReconnectCodexWebSocketOnConnectionLimit(
1724
- context: CodexStreamProcessingContext,
1725
- runtime: CodexStreamRuntime,
1726
- error: unknown,
1727
- ): Promise<boolean> {
1728
- if (!(error instanceof CodexProviderStreamError) || error.code !== "websocket_connection_limit_reached") {
1729
- return false;
1571
+ calculateCost(model, output.usage);
1572
+ applyCodexServiceTierPricing(
1573
+ model,
1574
+ output.usage,
1575
+ response?.service_tier,
1576
+ runtime.requestBodyForState.service_tier,
1577
+ );
1578
+ output.stopReason = mapOpenAIResponsesStopReason(response?.status as ResponseStatus | undefined);
1579
+ promoteResponsesToolUseStopReason(output, response?.end_turn);
1730
1580
  }
1731
- const websocketState = context.requestContext.websocketState;
1732
- if (!websocketState || runtime.transport !== "websocket" || context.options?.signal?.aborted) {
1581
+
1582
+ async #recoverStreamError(error: unknown): Promise<boolean> {
1583
+ if (await this.#tryRecoverWhitespaceToolCallLoop(error)) {
1584
+ return true;
1585
+ }
1586
+ if (await this.#tryReconnectWebSocketOnConnectionLimit(error)) {
1587
+ return true;
1588
+ }
1589
+ if (await this.#tryRecoverPreviousResponseNotFound(error)) {
1590
+ return true;
1591
+ }
1592
+ if (await this.#tryReplayWebsocketFailureOverSse(error)) {
1593
+ return true;
1594
+ }
1595
+ if (await this.#tryRetryProviderError(error)) {
1596
+ return true;
1597
+ }
1733
1598
  return false;
1734
1599
  }
1735
1600
 
1736
- // Close the stale connection so getOrCreateCodexWebSocketConnection creates a fresh one.
1737
- websocketState.connection?.close("connection_limit");
1738
- websocketState.connection = undefined;
1739
- resetCodexWebSocketAppendState(websocketState);
1601
+ /**
1602
+ * Recover from the degenerate whitespace-only tool-call argument loop
1603
+ * ({@link CodexWhitespaceToolCallLoopError}). The interrupted function call has
1604
+ * no usable arguments, so drop the partial turn and replay the request from
1605
+ * scratch — bounded by {@link CODEX_WHITESPACE_LOOP_RETRY_LIMIT}. Sampling
1606
+ * nondeterminism usually breaks the loop on a fresh attempt; once the budget is
1607
+ * exhausted the original error is surfaced (now without the junk tool call
1608
+ * polluting the message). Replay is refused once any visible content was already
1609
+ * delivered to the consumer — a finished tool call (`canSafelyReplayWebsocketOverSse`),
1610
+ * or any streamed text/commentary block still in `output.content` after the degenerate
1611
+ * tool call is dropped — because replaying re-emits already-streamed deltas.
1612
+ */
1613
+ async #tryRecoverWhitespaceToolCallLoop(error: unknown): Promise<boolean> {
1614
+ if (!(error instanceof CodexWhitespaceToolCallLoopError)) {
1615
+ return false;
1616
+ }
1617
+ // Drop the half-built degenerate tool call whether or not we retry, so it
1618
+ // never reaches the caller's message.
1619
+ this.#dropTrailingDegenerateToolCall();
1620
+ if (
1621
+ this.runtime.whitespaceLoopRetries >= CODEX_WHITESPACE_LOOP_RETRY_LIMIT ||
1622
+ !this.runtime.canSafelyReplayWebsocketOverSse ||
1623
+ this.output.content.some(block => block.type !== "thinking") ||
1624
+ this.options?.signal?.aborted
1625
+ ) {
1626
+ return false;
1627
+ }
1628
+
1629
+ this.runtime.whitespaceLoopRetries += 1;
1630
+ const websocketState = this.requestContext.websocketState;
1631
+ if (websocketState) {
1632
+ resetCodexWebSocketAppendState(websocketState);
1633
+ websocketState.turnState = undefined;
1634
+ websocketState.modelsEtag = undefined;
1635
+ }
1740
1636
 
1741
- if (context.output.content.length > 0 && !runtime.canSafelyReplayWebsocketOverSse) {
1742
- // A toolcall_end already reached the consumer; a full replay would emit
1743
- // the same tool calls a second time. Let the error surface instead.
1744
- return false;
1745
- }
1637
+ CODEX_DEBUG &&
1638
+ logger.debug("[codex] retrying codex turn after whitespace-only tool-call argument loop", {
1639
+ retry: this.runtime.whitespaceLoopRetries,
1640
+ retryBudget: CODEX_WHITESPACE_LOOP_RETRY_LIMIT,
1641
+ transport: this.runtime.transport,
1642
+ });
1746
1643
 
1747
- CODEX_DEBUG &&
1748
- logger.debug("[codex] codex websocket connection limit reached, reconnecting", {
1749
- hadContent: context.output.content.length > 0,
1750
- retry: runtime.websocketStreamRetries,
1644
+ this.runtime.resetAccumulators();
1645
+ this.runtime.sawTerminalEvent = false;
1646
+ this.runtime.whitespaceToolCallArgumentsDelta = undefined;
1647
+ resetOutputState(this.output);
1648
+ this.firstTokenTime = undefined;
1649
+ await scheduler.wait(CODEX_WHITESPACE_LOOP_RETRY_DELAY_MS * this.runtime.whitespaceLoopRetries, {
1650
+ signal: this.requestSetup.requestSignal,
1751
1651
  });
1752
1652
 
1753
- if (context.output.content.length > 0) {
1754
- // Content already emitted to the caller — cannot safely continue on a new WS.
1755
- // Reset and replay the full request over SSE.
1756
- resetCodexStreamAccumulators(runtime);
1757
- resetOutputState(context.output);
1758
- context.firstTokenTime = undefined;
1759
- recordCodexWebSocketFailure(websocketState, true);
1760
- await reopenCodexSseRuntimeStream(context, runtime, websocketState);
1653
+ if (this.runtime.transport === "websocket" && websocketState) {
1654
+ await this.#reopenWebSocketStream(websocketState);
1655
+ return true;
1656
+ }
1657
+
1658
+ await this.#reopenSseStream(websocketState);
1761
1659
  return true;
1762
1660
  }
1763
1661
 
1764
- // No content emitted yet — clear accumulator state from the failed attempt
1765
- // (blockless native items can exist even with empty content) and reconnect
1766
- // over websocket, bounded by the shared retry budget: an account-scoped
1767
- // limit can reject every fresh connection, and an unbounded loop would
1768
- // hammer the endpoint with zero backoff.
1769
- resetCodexStreamAccumulators(runtime);
1770
- context.firstTokenTime = undefined;
1771
- if (
1772
- runtime.websocketStreamRetries >=
1773
- parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET)
1774
- ) {
1775
- recordCodexWebSocketFailure(websocketState, true);
1776
- await reopenCodexSseRuntimeStream(context, runtime, websocketState);
1777
- return true;
1662
+ /**
1663
+ * Pop the half-built degenerate tool-call block (the one whose arguments were
1664
+ * nothing but whitespace) off the output accumulator so it never surfaces in the
1665
+ * caller's message. Any legitimate content produced before it is preserved.
1666
+ */
1667
+ #dropTrailingDegenerateToolCall(): void {
1668
+ const { runtime, output } = this;
1669
+ const block = runtime.currentBlock;
1670
+ if (block && block.type === "toolCall" && output.content[output.content.length - 1] === block) {
1671
+ output.content.pop();
1672
+ }
1673
+ runtime.closeOpenItem(runtime.currentEntry);
1778
1674
  }
1779
- runtime.websocketStreamRetries += 1;
1780
- await scheduler.wait(
1781
- parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS) *
1782
- Math.max(1, runtime.websocketStreamRetries),
1783
- {
1784
- signal: context.requestSetup.requestSignal,
1785
- },
1786
- );
1787
- await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
1788
- return true;
1789
- }
1790
1675
 
1791
- function isCodexStalePreviousResponseError(error: unknown): boolean {
1792
- if (error instanceof CodexProviderStreamError) return error.code === "previous_response_not_found";
1793
- if (!(error instanceof Error)) return false;
1794
- if ((error as { code?: string }).code === "previous_response_not_found") return true;
1795
- // "unsupported": the backend intermittently rejects the parameter outright
1796
- // with `{"detail":"Unsupported parameter: previous_response_id"}` (no
1797
- // `error.code`); treat it like a stale chain so the turn replays with full
1798
- // context instead of surfacing the 400.
1799
- return (
1800
- /previous[ _]?response/i.test(error.message) &&
1801
- /not[ _]?found|invalid|expired|stale|unsupported/i.test(error.message)
1802
- );
1803
- }
1676
+ /**
1677
+ * Handles `websocket_connection_limit_reached` errors by closing the stale connection
1678
+ * and opening a fresh websocket. If content has already been emitted to the caller,
1679
+ * falls back to SSE replay (same as other WS failures) since we cannot safely
1680
+ * continue a partial response on a new connection. If a tool call was already
1681
+ * delivered (`canSafelyReplayWebsocketOverSse` is false), the error surfaces
1682
+ * instead replaying would re-emit the same tool calls.
1683
+ */
1684
+ async #tryReconnectWebSocketOnConnectionLimit(error: unknown): Promise<boolean> {
1685
+ if (!(error instanceof CodexProviderStreamError) || error.code !== "websocket_connection_limit_reached") {
1686
+ return false;
1687
+ }
1688
+ const websocketState = this.requestContext.websocketState;
1689
+ if (!websocketState || this.runtime.transport !== "websocket" || this.options?.signal?.aborted) {
1690
+ return false;
1691
+ }
1804
1692
 
1805
- async function tryRecoverCodexPreviousResponseNotFound(
1806
- context: CodexStreamProcessingContext,
1807
- runtime: CodexStreamRuntime,
1808
- error: unknown,
1809
- ): Promise<boolean> {
1810
- const websocketState = context.requestContext.websocketState;
1811
- if (
1812
- !isCodexStalePreviousResponseError(error) ||
1813
- !websocketState ||
1814
- context.output.content.length > 0 ||
1815
- context.options?.signal?.aborted ||
1816
- runtime.providerRetryAttempt >= CODEX_MAX_RETRIES
1817
- ) {
1818
- return false;
1819
- }
1820
- if (runtime.transport !== "websocket") {
1821
- // SSE never sends previous_response_id; let other recovery handle it.
1822
- return false;
1823
- }
1693
+ // Close the stale connection so getOrCreateCodexWebSocketConnection creates a fresh one.
1694
+ websocketState.connection?.close("connection_limit");
1695
+ websocketState.connection = undefined;
1696
+ resetCodexWebSocketAppendState(websocketState);
1824
1697
 
1825
- runtime.providerRetryAttempt += 1;
1826
- resetCodexWebSocketAppendState(websocketState);
1827
- websocketState.turnState = undefined;
1828
- websocketState.modelsEtag = undefined;
1829
- resetCodexStreamAccumulators(runtime);
1830
- runtime.sawTerminalEvent = false;
1831
- resetOutputState(context.output);
1832
- context.firstTokenTime = undefined;
1698
+ if (this.output.content.length > 0 && !this.runtime.canSafelyReplayWebsocketOverSse) {
1699
+ // A toolcall_end already reached the consumer; a full replay would emit
1700
+ // the same tool calls a second time. Let the error surface instead.
1701
+ return false;
1702
+ }
1833
1703
 
1834
- CODEX_DEBUG &&
1835
- logger.debug("[codex] codex previous_response_id expired; retrying with full context", {
1836
- retry: runtime.providerRetryAttempt,
1837
- });
1838
- await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
1839
- return true;
1840
- }
1704
+ CODEX_DEBUG &&
1705
+ logger.debug("[codex] codex websocket connection limit reached, reconnecting", {
1706
+ hadContent: this.output.content.length > 0,
1707
+ retry: this.runtime.websocketStreamRetries,
1708
+ });
1841
1709
 
1842
- async function tryReplayWebsocketFailureOverSse(
1843
- context: CodexStreamProcessingContext,
1844
- runtime: CodexStreamRuntime,
1845
- error: unknown,
1846
- ): Promise<boolean> {
1847
- const websocketState = context.requestContext.websocketState;
1848
- const canReplay =
1849
- runtime.transport === "websocket" &&
1850
- websocketState &&
1851
- isCodexWebSocketRetryableStreamError(error) &&
1852
- runtime.canSafelyReplayWebsocketOverSse &&
1853
- !runtime.sawTerminalEvent &&
1854
- !context.options?.signal?.aborted;
1855
- if (!canReplay) return false;
1856
-
1857
- const state = websocketState;
1858
- const streamError = error instanceof Error ? error : new Error(String(error));
1859
- const replayingBufferedOutputOverSse = context.output.content.length > 0;
1860
- const fatalWebSocketMessage = streamError.message.toLowerCase();
1861
- const isFatal = CODEX_WEBSOCKET_FATAL_PATTERNS.some(pattern =>
1862
- fatalWebSocketMessage.includes(pattern.toLowerCase()),
1863
- );
1864
- const activateFallback =
1865
- replayingBufferedOutputOverSse ||
1866
- isFatal ||
1867
- runtime.websocketStreamRetries >=
1868
- parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET);
1869
- recordCodexWebSocketFailure(state, activateFallback);
1870
- CODEX_DEBUG &&
1871
- logger.debug("[codex] codex websocket stream fallback", {
1872
- error: streamError.message,
1873
- retry: runtime.websocketStreamRetries,
1874
- retryBudget: parseCodexNonNegativeInteger($env.PI_CODEX_WEBSOCKET_RETRY_BUDGET, CODEX_WEBSOCKET_RETRY_BUDGET),
1875
- activated: activateFallback,
1876
- fatal: isFatal,
1877
- replayedBufferedOutput: replayingBufferedOutputOverSse,
1878
- });
1710
+ if (this.output.content.length > 0) {
1711
+ // Content already emitted to the caller — cannot safely continue on a new WS.
1712
+ // Reset and replay the full request over SSE.
1713
+ this.runtime.resetAccumulators();
1714
+ resetOutputState(this.output);
1715
+ this.firstTokenTime = undefined;
1716
+ recordCodexWebSocketFailure(websocketState, true);
1717
+ await this.#reopenSseStream(websocketState);
1718
+ return true;
1719
+ }
1879
1720
 
1880
- if (!activateFallback) {
1881
- runtime.websocketStreamRetries += 1;
1882
- // Full re-send on a fresh socket: clear accumulator state from the failed
1883
- // attempt. Content is empty here, but blockless native items (e.g.
1884
- // web_search_call) may already have accumulated.
1885
- resetCodexStreamAccumulators(runtime);
1886
- context.firstTokenTime = undefined;
1887
- await scheduler.wait(
1888
- parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_RETRY_DELAY_MS, CODEX_RETRY_DELAY_MS) *
1889
- Math.max(1, runtime.websocketStreamRetries),
1890
- {
1891
- signal: context.requestSetup.requestSignal,
1892
- },
1893
- );
1894
- await reopenCodexWebSocketRuntimeStream(context, runtime, state);
1721
+ // No content emitted yet — clear accumulator state from the failed attempt
1722
+ // (blockless native items can exist even with empty content) and reconnect
1723
+ // over websocket, bounded by the shared retry budget: an account-scoped
1724
+ // limit can reject every fresh connection, and an unbounded loop would
1725
+ // hammer the endpoint with zero backoff.
1726
+ this.runtime.resetAccumulators();
1727
+ this.firstTokenTime = undefined;
1728
+ if (this.runtime.websocketStreamRetries >= CODEX_WEBSOCKET_RETRY_BUDGET) {
1729
+ recordCodexWebSocketFailure(websocketState, true);
1730
+ await this.#reopenSseStream(websocketState);
1731
+ return true;
1732
+ }
1733
+ this.runtime.websocketStreamRetries += 1;
1734
+ await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, this.runtime.websocketStreamRetries), {
1735
+ signal: this.requestSetup.requestSignal,
1736
+ });
1737
+ await this.#reopenWebSocketStream(websocketState);
1895
1738
  return true;
1896
1739
  }
1897
1740
 
1898
- resetCodexStreamAccumulators(runtime);
1899
- resetOutputState(context.output);
1900
- context.firstTokenTime = undefined;
1901
-
1902
- await reopenCodexSseRuntimeStream(context, runtime, state);
1903
- return true;
1904
- }
1905
-
1906
- async function tryRetryCodexProviderError(
1907
- context: CodexStreamProcessingContext,
1908
- runtime: CodexStreamRuntime,
1909
- error: unknown,
1910
- ): Promise<boolean> {
1911
- if (
1912
- !(error instanceof CodexProviderStreamError && error.retryable) ||
1913
- context.output.content.length > 0 ||
1914
- runtime.providerRetryAttempt >= CODEX_MAX_RETRIES ||
1915
- context.options?.signal?.aborted
1916
- ) {
1917
- return false;
1918
- }
1741
+ async #tryRecoverPreviousResponseNotFound(error: unknown): Promise<boolean> {
1742
+ const websocketState = this.requestContext.websocketState;
1743
+ if (
1744
+ !isCodexStalePreviousResponseError(error) ||
1745
+ !websocketState ||
1746
+ this.output.content.length > 0 ||
1747
+ this.options?.signal?.aborted ||
1748
+ this.runtime.providerRetryAttempt >= CODEX_MAX_RETRIES
1749
+ ) {
1750
+ return false;
1751
+ }
1752
+ if (this.runtime.transport !== "websocket") {
1753
+ // SSE never sends previous_response_id; let other recovery handle it.
1754
+ return false;
1755
+ }
1919
1756
 
1920
- runtime.providerRetryAttempt += 1;
1921
- const websocketState = context.requestContext.websocketState;
1922
- if (websocketState) {
1757
+ this.runtime.providerRetryAttempt += 1;
1923
1758
  resetCodexWebSocketAppendState(websocketState);
1924
1759
  websocketState.turnState = undefined;
1925
1760
  websocketState.modelsEtag = undefined;
1761
+ this.runtime.resetAccumulators();
1762
+ this.runtime.sawTerminalEvent = false;
1763
+ resetOutputState(this.output);
1764
+ this.firstTokenTime = undefined;
1765
+
1766
+ CODEX_DEBUG &&
1767
+ logger.debug("[codex] codex previous_response_id expired; retrying with full context", {
1768
+ retry: this.runtime.providerRetryAttempt,
1769
+ });
1770
+ await this.#reopenWebSocketStream(websocketState);
1771
+ return true;
1926
1772
  }
1927
1773
 
1928
- CODEX_DEBUG &&
1929
- logger.debug("[codex] retrying codex provider stream error", {
1930
- error: error instanceof Error ? error.message : String(error),
1931
- retry: runtime.providerRetryAttempt,
1932
- retryBudget: CODEX_MAX_RETRIES,
1933
- transport: runtime.transport,
1934
- });
1774
+ async #tryReplayWebsocketFailureOverSse(error: unknown): Promise<boolean> {
1775
+ const websocketState = this.requestContext.websocketState;
1776
+ const canReplay =
1777
+ this.runtime.transport === "websocket" &&
1778
+ websocketState &&
1779
+ isCodexWebSocketRetryableStreamError(error) &&
1780
+ this.runtime.canSafelyReplayWebsocketOverSse &&
1781
+ !this.runtime.sawTerminalEvent &&
1782
+ !this.options?.signal?.aborted;
1783
+ if (!canReplay) return false;
1784
+
1785
+ const state = websocketState;
1786
+ const streamError = error instanceof Error ? error : new Error(String(error));
1787
+ const replayingBufferedOutputOverSse = this.output.content.length > 0;
1788
+ const fatalWebSocketMessage = streamError.message.toLowerCase();
1789
+ const isFatal = CODEX_WEBSOCKET_FATAL_PATTERNS.some(pattern =>
1790
+ fatalWebSocketMessage.includes(pattern.toLowerCase()),
1791
+ );
1792
+ const activateFallback =
1793
+ replayingBufferedOutputOverSse ||
1794
+ isFatal ||
1795
+ this.runtime.websocketStreamRetries >= CODEX_WEBSOCKET_RETRY_BUDGET;
1796
+ recordCodexWebSocketFailure(state, activateFallback);
1797
+ CODEX_DEBUG &&
1798
+ logger.debug("[codex] codex websocket stream fallback", {
1799
+ error: streamError.message,
1800
+ retry: this.runtime.websocketStreamRetries,
1801
+ retryBudget: CODEX_WEBSOCKET_RETRY_BUDGET,
1802
+ activated: activateFallback,
1803
+ fatal: isFatal,
1804
+ replayedBufferedOutput: replayingBufferedOutputOverSse,
1805
+ });
1935
1806
 
1936
- resetCodexStreamAccumulators(runtime);
1937
- runtime.sawTerminalEvent = false;
1938
- resetOutputState(context.output);
1939
- context.firstTokenTime = undefined;
1940
- await scheduler.wait(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, {
1941
- signal: context.requestSetup.requestSignal,
1942
- });
1807
+ if (!activateFallback) {
1808
+ this.runtime.websocketStreamRetries += 1;
1809
+ // Full re-send on a fresh socket: clear accumulator state from the failed
1810
+ // attempt. Content is empty here, but blockless native items (e.g.
1811
+ // web_search_call) may already have accumulated.
1812
+ this.runtime.resetAccumulators();
1813
+ this.firstTokenTime = undefined;
1814
+ await scheduler.wait(CODEX_WEBSOCKET_RETRY_DELAY_MS * Math.max(1, this.runtime.websocketStreamRetries), {
1815
+ signal: this.requestSetup.requestSignal,
1816
+ });
1817
+ await this.#reopenWebSocketStream(state);
1818
+ return true;
1819
+ }
1943
1820
 
1944
- if (runtime.transport === "websocket" && websocketState) {
1945
- await reopenCodexWebSocketRuntimeStream(context, runtime, websocketState);
1821
+ this.runtime.resetAccumulators();
1822
+ resetOutputState(this.output);
1823
+ this.firstTokenTime = undefined;
1824
+
1825
+ await this.#reopenSseStream(state);
1946
1826
  return true;
1947
1827
  }
1948
1828
 
1949
- await reopenCodexSseRuntimeStream(context, runtime, websocketState);
1950
- return true;
1951
- }
1829
+ async #tryRetryProviderError(error: unknown): Promise<boolean> {
1830
+ if (
1831
+ !(error instanceof CodexProviderStreamError && error.retryable) ||
1832
+ this.output.content.length > 0 ||
1833
+ this.runtime.providerRetryAttempt >= CODEX_MAX_RETRIES ||
1834
+ this.options?.signal?.aborted
1835
+ ) {
1836
+ return false;
1837
+ }
1952
1838
 
1953
- function finalizeCodexResponse(
1954
- context: CodexStreamProcessingContext,
1955
- runtime: CodexStreamRuntime,
1956
- completion: CodexStreamCompletion,
1957
- ): AssistantMessage {
1958
- const { output } = context;
1959
- if (context.options?.signal?.aborted) {
1960
- throw new Error("Request was aborted");
1961
- }
1962
- if (!runtime.sawTerminalEvent) {
1963
- if (context.requestContext.websocketState) {
1964
- resetCodexWebSocketAppendState(context.requestContext.websocketState);
1965
- context.requestContext.websocketState.turnState = undefined;
1966
- context.requestContext.websocketState.modelsEtag = undefined;
1839
+ this.runtime.providerRetryAttempt += 1;
1840
+ const websocketState = this.requestContext.websocketState;
1841
+ if (websocketState) {
1842
+ resetCodexWebSocketAppendState(websocketState);
1843
+ websocketState.turnState = undefined;
1844
+ websocketState.modelsEtag = undefined;
1967
1845
  }
1846
+
1968
1847
  CODEX_DEBUG &&
1969
- logger.debug("[codex] codex stream ended unexpectedly", {
1970
- transport: runtime.transport,
1971
- terminalEventSeen: runtime.sawTerminalEvent,
1972
- unexpectedStreamEnd: true,
1973
- sentTurnStateHeader: Boolean(context.requestContext.websocketState?.turnState),
1974
- sentModelsEtagHeader: Boolean(context.requestContext.websocketState?.modelsEtag),
1848
+ logger.debug("[codex] retrying codex provider stream error", {
1849
+ error: error instanceof Error ? error.message : String(error),
1850
+ retry: this.runtime.providerRetryAttempt,
1851
+ retryBudget: CODEX_MAX_RETRIES,
1852
+ transport: this.runtime.transport,
1975
1853
  });
1976
- throw new Error("Codex stream ended before terminal completion event");
1977
- }
1978
- if (output.stopReason === "aborted" || output.stopReason === "error") {
1979
- throw new Error("Codex response failed");
1980
- }
1981
1854
 
1982
- output.providerPayload = createOpenAIResponsesHistoryPayload(context.model.provider, runtime.nativeOutputItems);
1983
- output.duration = Date.now() - context.startTime;
1984
- if (completion.firstTokenTime) {
1985
- output.ttft = completion.firstTokenTime - context.startTime;
1855
+ this.runtime.resetAccumulators();
1856
+ this.runtime.sawTerminalEvent = false;
1857
+ resetOutputState(this.output);
1858
+ this.firstTokenTime = undefined;
1859
+ await scheduler.wait(CODEX_RETRY_DELAY_MS * this.runtime.providerRetryAttempt, {
1860
+ signal: this.requestSetup.requestSignal,
1861
+ });
1862
+
1863
+ if (this.runtime.transport === "websocket" && websocketState) {
1864
+ await this.#reopenWebSocketStream(websocketState);
1865
+ return true;
1866
+ }
1867
+
1868
+ await this.#reopenSseStream(websocketState);
1869
+ return true;
1986
1870
  }
1987
- return output;
1988
- }
1989
1871
 
1990
- async function handleCodexStreamFailure(
1991
- context: CodexStreamProcessingContext,
1992
- error: unknown,
1993
- ): Promise<AssistantMessage> {
1994
- const { output } = context;
1995
- for (const block of output.content) {
1996
- delete (block as { index?: number }).index;
1872
+ async #reopenWebSocketStream(state: CodexWebSocketSessionState): Promise<void> {
1873
+ try {
1874
+ const next = await openCodexWebSocketTransport(
1875
+ this.model,
1876
+ this.options,
1877
+ this.requestContext,
1878
+ this.requestSetup,
1879
+ state,
1880
+ this.runtime.websocketStreamRetries,
1881
+ this.options ? event => this.options?.onSseEvent?.(event, this.model) : undefined,
1882
+ );
1883
+ this.runtime.eventStream = next.eventStream;
1884
+ this.runtime.requestBodyForState = next.requestBodyForState;
1885
+ this.runtime.transport = next.transport;
1886
+ state.lastTransport = next.transport;
1887
+ } catch (error) {
1888
+ if (!(error instanceof CodexWebSocketTransportError)) throw error;
1889
+ // Reopen failed at the websocket layer (handshake refused, connect timeout, etc.).
1890
+ // Activate fallback so subsequent turns use SSE, and replay this turn over SSE
1891
+ // instead of surfacing a raw transport error to the caller.
1892
+ recordCodexWebSocketFailure(state, true);
1893
+ CODEX_DEBUG &&
1894
+ logger.debug("[codex] codex websocket reopen failed, falling back to SSE", {
1895
+ error: error.message,
1896
+ retry: this.runtime.websocketStreamRetries,
1897
+ });
1898
+ await this.#reopenSseStream(state);
1899
+ }
1997
1900
  }
1998
- if (context.requestContext.websocketState) {
1999
- resetCodexWebSocketAppendState(context.requestContext.websocketState);
2000
- context.requestContext.websocketState.turnState = undefined;
2001
- context.requestContext.websocketState.modelsEtag = undefined;
1901
+
1902
+ async #reopenSseStream(state: CodexWebSocketSessionState | undefined): Promise<void> {
1903
+ const next = await openCodexSseTransport(this.model, this.requestContext, this.requestSetup, this.options, state);
1904
+ this.runtime.eventStream = next.eventStream;
1905
+ this.runtime.requestBodyForState = next.requestBodyForState;
1906
+ this.runtime.transport = next.transport;
1907
+ if (state) {
1908
+ state.lastTransport = next.transport;
1909
+ }
2002
1910
  }
2003
- output.stopReason = context.options?.signal?.aborted ? "aborted" : "error";
2004
- output.errorStatus = extractHttpStatusFromError(error);
2005
- output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
2006
- output.duration = Date.now() - context.startTime;
2007
- if (context.firstTokenTime) {
2008
- output.ttft = context.firstTokenTime - context.startTime;
1911
+
1912
+ finalize(completion: CodexStreamCompletion): AssistantMessage {
1913
+ const { output } = this;
1914
+ if (this.options?.signal?.aborted) {
1915
+ throw new AIError.AbortError();
1916
+ }
1917
+ if (!this.runtime.sawTerminalEvent) {
1918
+ if (this.requestContext.websocketState) {
1919
+ resetCodexWebSocketAppendState(this.requestContext.websocketState);
1920
+ this.requestContext.websocketState.turnState = undefined;
1921
+ this.requestContext.websocketState.modelsEtag = undefined;
1922
+ }
1923
+ CODEX_DEBUG &&
1924
+ logger.debug("[codex] codex stream ended unexpectedly", {
1925
+ transport: this.runtime.transport,
1926
+ terminalEventSeen: this.runtime.sawTerminalEvent,
1927
+ unexpectedStreamEnd: true,
1928
+ sentTurnStateHeader: Boolean(this.requestContext.websocketState?.turnState),
1929
+ sentModelsEtagHeader: Boolean(this.requestContext.websocketState?.modelsEtag),
1930
+ });
1931
+ throw new CodexProviderStreamError("Codex stream ended before terminal completion event", false);
1932
+ }
1933
+ if (output.stopReason === "aborted" || output.stopReason === "error") {
1934
+ throw new CodexProviderStreamError("Codex response failed", false);
1935
+ }
1936
+
1937
+ output.providerPayload = createOpenAIResponsesHistoryPayload(this.model.provider, this.runtime.nativeOutputItems);
1938
+ output.duration = performance.now() - this.startTime;
1939
+ if (completion.firstTokenTime) {
1940
+ output.ttft = completion.firstTokenTime - this.startTime;
1941
+ }
1942
+ return output;
2009
1943
  }
2010
- return output;
2011
1944
  }
2012
1945
 
2013
1946
  export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"> = (
@@ -2018,7 +1951,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2018
1951
  const stream = new AssistantMessageEventStream();
2019
1952
 
2020
1953
  (async () => {
2021
- const startTime = Date.now();
1954
+ const startTime = performance.now();
2022
1955
  const output: AssistantMessage = {
2023
1956
  role: "assistant",
2024
1957
  content: [],
@@ -2037,13 +1970,13 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2037
1970
  timestamp: Date.now(),
2038
1971
  };
2039
1972
  const requestSetup = createRequestSetup(options);
2040
- let processingContext: CodexStreamProcessingContext | undefined;
1973
+ let processingContext: CodexStreamProcessor | undefined;
2041
1974
  let requestContext: CodexRequestContext | undefined;
2042
1975
 
2043
1976
  try {
2044
1977
  requestContext = await buildCodexRequestContext(model, context, options, output);
2045
1978
  const initialTransport = await openInitialCodexEventStream(model, options, requestSetup, requestContext);
2046
- const runtime = createCodexStreamRuntime({
1979
+ const runtime = new CodexStreamRuntime({
2047
1980
  ...initialTransport,
2048
1981
  websocketState: requestContext.websocketState,
2049
1982
  });
@@ -2051,7 +1984,8 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2051
1984
  requestContext.websocketState.lastTransport = initialTransport.transport;
2052
1985
  }
2053
1986
 
2054
- processingContext = {
1987
+ processingContext = new CodexStreamProcessor({
1988
+ runtime,
2055
1989
  model,
2056
1990
  output,
2057
1991
  stream,
@@ -2059,11 +1993,11 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2059
1993
  requestSetup,
2060
1994
  requestContext,
2061
1995
  startTime,
2062
- };
1996
+ });
2063
1997
 
2064
- const completion = await processCodexResponseStream(processingContext, runtime);
1998
+ const completion = await processingContext.process();
2065
1999
  processingContext.firstTokenTime = completion.firstTokenTime;
2066
- const message = finalizeCodexResponse(processingContext, runtime, completion);
2000
+ const message = processingContext.finalize(completion);
2067
2001
  stream.push({ type: "done", reason: message.stopReason as "stop" | "length" | "toolUse", message });
2068
2002
  stream.end();
2069
2003
  } catch (error) {
@@ -2072,9 +2006,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2072
2006
  ({
2073
2007
  model,
2074
2008
  output,
2075
- stream,
2076
2009
  options,
2077
- requestSetup,
2078
2010
  requestContext: requestContext ?? {
2079
2011
  apiKey: "",
2080
2012
  accountId: "",
@@ -2093,7 +2025,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
2093
2025
  },
2094
2026
  },
2095
2027
  startTime,
2096
- } satisfies CodexStreamProcessingContext);
2028
+ } satisfies CodexStreamFailureContext);
2097
2029
  try {
2098
2030
  const failure = await handleCodexStreamFailure(failureContext, error);
2099
2031
  stream.push({ type: "error", reason: failure.stopReason as "error" | "aborted", error: failure });
@@ -2342,7 +2274,7 @@ function buildCodexChainedRequestBody(
2342
2274
  ): RequestBody {
2343
2275
  const chainable = state?.canAppend === true;
2344
2276
  const appendInput = chainable
2345
- ? buildResponsesDeltaInput<InputItem>(state.lastRequest, state.lastResponseItems, requestBody)
2277
+ ? buildResponsesDeltaInput(state.lastRequest, state.lastResponseItems, requestBody)
2346
2278
  : null;
2347
2279
  if (appendInput && appendInput.length > 0 && state?.lastResponseId) {
2348
2280
  const body: RequestBody = { ...requestBody, previous_response_id: state.lastResponseId, input: appendInput };
@@ -2451,10 +2383,7 @@ class CodexWebSocketConnection {
2451
2383
  */
2452
2384
  isHealthyForReuse(): boolean {
2453
2385
  if (!this.isOpen()) return false;
2454
- const maxIdleMs = parseCodexNonNegativeInteger(
2455
- $env.PI_CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS,
2456
- CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS,
2457
- );
2386
+ const maxIdleMs = CODEX_WEBSOCKET_MAX_IDLE_REUSE_MS;
2458
2387
  if (maxIdleMs <= 0) return true;
2459
2388
  // Initial connect sets #lastInboundAt; any later message or pong refreshes
2460
2389
  // it. A zero value means the field was never initialized, which itself is
@@ -2618,7 +2547,7 @@ class CodexWebSocketConnection {
2618
2547
  this.#activeRequest = true;
2619
2548
  this.#streamObserver = onSseEvent;
2620
2549
  // Drain any non-error frames left over from a prior request before sending.
2621
- // `processCodexResponseStream` breaks its `for-await` on the terminal event,
2550
+ // `CodexStreamProcessor.process` breaks its `for-await` on the terminal event,
2622
2551
  // which interrupts our generator at `yield next` (the post-yield `break`
2623
2552
  // never runs). Any frame that landed between the consumer's break and the
2624
2553
  // generator's `finally` lingers in `#queue` and would otherwise become the
@@ -2815,10 +2744,7 @@ class CodexWebSocketConnection {
2815
2744
 
2816
2745
  #startHeartbeat(socket: Bun.WebSocket): void {
2817
2746
  this.#stopHeartbeat();
2818
- const intervalMs = parseCodexNonNegativeInteger(
2819
- $env.PI_CODEX_WEBSOCKET_PING_INTERVAL_MS,
2820
- CODEX_WEBSOCKET_PING_INTERVAL_MS,
2821
- );
2747
+ const intervalMs = CODEX_WEBSOCKET_PING_INTERVAL_MS;
2822
2748
  if (intervalMs <= 0) return;
2823
2749
 
2824
2750
  this.#lastPingAt = 0;
@@ -2849,10 +2775,7 @@ class CodexWebSocketConnection {
2849
2775
  // finally fired. Instead, trigger on inbound silence: if we sent a
2850
2776
  // ping at least `pongTimeoutMs` ago and have received no traffic of
2851
2777
  // any kind (data frame or pong) since, the socket is unhealthy.
2852
- const pongTimeoutMs = parseCodexNonNegativeInteger(
2853
- $env.PI_CODEX_WEBSOCKET_PONG_TIMEOUT_MS,
2854
- CODEX_WEBSOCKET_PONG_TIMEOUT_MS,
2855
- );
2778
+ const pongTimeoutMs = CODEX_WEBSOCKET_PONG_TIMEOUT_MS;
2856
2779
  if (
2857
2780
  pongTimeoutMs > 0 &&
2858
2781
  this.#lastPingAt > 0 &&
@@ -2939,17 +2862,10 @@ class CodexWebSocketConnection {
2939
2862
  this.#wakeWaiters();
2940
2863
  return;
2941
2864
  }
2942
- if (
2943
- item !== null &&
2944
- this.#queue.length >=
2945
- parseCodexPositiveInteger(
2946
- $env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY,
2947
- CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY,
2948
- )
2949
- ) {
2865
+ if (item !== null && this.#queue.length >= CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY) {
2950
2866
  this.#failQueue(
2951
2867
  new CodexWebSocketTransportError(
2952
- `websocket message queue exceeded ${parseCodexPositiveInteger($env.PI_CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY, CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY)} items`,
2868
+ `websocket message queue exceeded ${CODEX_WEBSOCKET_MESSAGE_QUEUE_CAPACITY} items`,
2953
2869
  ),
2954
2870
  "queue-overflow",
2955
2871
  );
@@ -3101,7 +3017,7 @@ async function openCodexSseEventStream(
3101
3017
  }
3102
3018
  updateCodexSessionMetadataFromHeaders(state, response.headers);
3103
3019
  if (!response.body) {
3104
- throw new Error("No response body");
3020
+ throw new CodexProviderStreamError("No response body", false);
3105
3021
  }
3106
3022
  return readSseJson<Record<string, unknown>>(response.body, signal, event =>
3107
3023
  onSseEvent?.({ event: event.event, data: event.data, raw: [...event.raw] }, undefined),
@@ -3198,7 +3114,10 @@ function resolveCodexResponsesUrl(baseUrl: string | undefined): string {
3198
3114
  function getAccountId(accessToken: string): string {
3199
3115
  const accountId = getCodexAccountId(accessToken);
3200
3116
  if (!accountId) {
3201
- throw new Error("Failed to extract accountId from token");
3117
+ throw new AIError.OAuthError("Failed to extract accountId from token", {
3118
+ kind: "validation",
3119
+ provider: "openai",
3120
+ });
3202
3121
  }
3203
3122
  return accountId;
3204
3123
  }