@f5-sales-demo/pi-ai 22.7.0 → 22.7.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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-ai",
4
- "version": "22.7.0",
4
+ "version": "22.7.2",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,7 +46,7 @@
46
46
  "@anthropic-ai/sdk": "0.124.0",
47
47
  "@aws-sdk/client-bedrock-runtime": "3.1127.0",
48
48
  "@bufbuild/protobuf": "2.14.1",
49
- "@f5-sales-demo/pi-utils": "22.7.0",
49
+ "@f5-sales-demo/pi-utils": "22.7.2",
50
50
  "@google/genai": "2.21.0",
51
51
  "@sinclair/typebox": "0.34.52",
52
52
  "@smithy/node-http-handler": "4.12.1",
@@ -192,7 +192,7 @@ function getCacheControl(
192
192
  }
193
193
 
194
194
  // Stealth mode: Mimic Claude Code headers and tool prefixing.
195
- export const claudeCodeVersion = "2.1.286";
195
+ export const claudeCodeVersion = "2.1.287";
196
196
  export const claudeToolPrefix: string = "proxy_";
197
197
  export const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
198
198
 
@@ -26,6 +26,7 @@ import { getEnvApiKey } from "../stream";
26
26
  import {
27
27
  type Api,
28
28
  type AssistantMessage,
29
+ type AssistantMessageEvent,
29
30
  type Context,
30
31
  isSpecialServiceTier,
31
32
  type Model,
@@ -47,7 +48,7 @@ import {
47
48
  } from "../utils";
48
49
  import { AssistantMessageEventStream } from "../utils/event-stream";
49
50
  import { finalizeErrorMessage, type RawHttpRequestDump } from "../utils/http-inspector";
50
- import { getOpenAIStreamIdleTimeoutMs, iterateWithIdleTimeout } from "../utils/idle-iterator";
51
+ import { getOpenAIStreamIdleTimeoutMs } from "../utils/idle-iterator";
51
52
  import { parseStreamingJson } from "../utils/json-parse";
52
53
  import { isTransportClosureError } from "../utils/retry";
53
54
  import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
@@ -185,7 +186,6 @@ interface CodexRequestContext {
185
186
 
186
187
  interface CodexRequestSetup {
187
188
  requestSignal: AbortSignal;
188
- wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
189
189
  requestAbortController: AbortController;
190
190
  }
191
191
 
@@ -195,6 +195,8 @@ interface CodexWebSocketRequestState {
195
195
  }
196
196
 
197
197
  interface CodexStreamRuntime {
198
+ stagedEvents: AssistantMessageEvent[];
199
+ firstTransportFailure?: unknown;
198
200
  websocketRequestState?: CodexWebSocketRequestState;
199
201
  continuationRecoveryAttempt: number;
200
202
  sawResponseAccepted: boolean;
@@ -208,6 +210,7 @@ interface CodexStreamRuntime {
208
210
  outputSlots: Map<number, { item: CodexEventItem | null; block: CodexOutputBlock | null; index: number }>;
209
211
  currentIndex: number;
210
212
  completedItemIds: Set<string>;
213
+ completedContentIndices: Set<number>;
211
214
  currentBlock: CodexOutputBlock | null;
212
215
  nativeOutputItems: Array<Record<string, unknown>>;
213
216
  websocketStreamRetries: number;
@@ -219,6 +222,7 @@ interface CodexStreamRuntime {
219
222
  }
220
223
 
221
224
  interface CodexStreamProcessingContext {
225
+ runtime?: CodexStreamRuntime;
222
226
  model: Model<"openai-codex-responses">;
223
227
  output: AssistantMessage;
224
228
  stream: AssistantMessageEventStream;
@@ -298,7 +302,13 @@ function getCodexProviderSessionState(
298
302
  }
299
303
 
300
304
  function createCodexWebSocketTransportError(message: string): Error {
301
- return new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`);
305
+ return Object.assign(new Error(`${CODEX_WEBSOCKET_TRANSPORT_ERROR_PREFIX}: ${message}`), {
306
+ codexTransportInterruption: message.startsWith("websocket closed")
307
+ ? "socket_closed"
308
+ : message === "idle timeout waiting for websocket" || message === "timeout waiting for first websocket event"
309
+ ? "idle_timeout"
310
+ : undefined,
311
+ });
302
312
  }
303
313
 
304
314
  function isCodexWebSocketFatalError(error: Error): boolean {
@@ -453,15 +463,7 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
453
463
  const requestSignal = options?.signal
454
464
  ? AbortSignal.any([options.signal, requestAbortController.signal])
455
465
  : requestAbortController.signal;
456
- const wrapCodexSseStream = (
457
- source: AsyncGenerator<Record<string, unknown>>,
458
- ): AsyncGenerator<Record<string, unknown>> =>
459
- iterateWithIdleTimeout(source, {
460
- idleTimeoutMs: getOpenAIStreamIdleTimeoutMs(),
461
- errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
462
- onIdle: () => requestAbortController.abort(),
463
- });
464
- return { requestAbortController, requestSignal, wrapCodexSseStream };
466
+ return { requestAbortController, requestSignal };
465
467
  }
466
468
 
467
469
  async function buildCodexRequestContext(
@@ -766,6 +768,60 @@ async function openCodexWebSocketTransport(
766
768
  return { eventStream, requestBodyForState, transport: "websocket", websocketRequestState };
767
769
  }
768
770
 
771
+ class CodexSseIdleTimeoutError extends Error {
772
+ constructor() {
773
+ super("OpenAI Codex SSE stream stalled while waiting for the next event");
774
+ this.name = "CodexSseIdleTimeoutError";
775
+ }
776
+ }
777
+
778
+ async function* iterateCodexSseAttempt(
779
+ source: AsyncGenerator<Record<string, unknown>>,
780
+ attempt: AbortController,
781
+ cleanup: () => void,
782
+ ): AsyncGenerator<Record<string, unknown>> {
783
+ const timeoutMs = getOpenAIStreamIdleTimeoutMs();
784
+ try {
785
+ while (true) {
786
+ attempt.signal.throwIfAborted();
787
+ let timer: NodeJS.Timeout | undefined;
788
+ try {
789
+ const next = source.next();
790
+ const result =
791
+ timeoutMs === undefined
792
+ ? await next
793
+ : await Promise.race([
794
+ next,
795
+ new Promise<never>((_resolve, reject) => {
796
+ timer = setTimeout(() => {
797
+ const error = new CodexSseIdleTimeoutError();
798
+ reject(error);
799
+ attempt.abort(error);
800
+ }, timeoutMs);
801
+ }),
802
+ ]);
803
+ if (timer !== undefined) {
804
+ clearTimeout(timer);
805
+ timer = undefined;
806
+ }
807
+ attempt.signal.throwIfAborted();
808
+ if (result.done) return;
809
+ yield result.value;
810
+ } finally {
811
+ if (timer !== undefined) clearTimeout(timer);
812
+ }
813
+ }
814
+ } finally {
815
+ attempt.abort();
816
+ cleanup();
817
+ try {
818
+ await source.return(undefined);
819
+ } catch {
820
+ /* Preserve the original stream failure. */
821
+ }
822
+ }
823
+ }
824
+
769
825
  async function openCodexSseTransport(
770
826
  requestContext: CodexRequestContext,
771
827
  requestSetup: CodexRequestSetup,
@@ -779,8 +835,16 @@ async function openCodexSseTransport(
779
835
  transport: CodexTransport;
780
836
  websocketRequestState?: CodexWebSocketRequestState;
781
837
  }> {
782
- const eventStream = requestSetup.wrapCodexSseStream(
783
- await openCodexSseEventStream(
838
+ const attempt = new AbortController();
839
+ const signal = requestSetup.requestSignal;
840
+ const onAbort = () => attempt.abort(signal.reason);
841
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
842
+ signal.addEventListener("abort", onAbort, { once: true });
843
+ if (signal.aborted) onAbort();
844
+ let source: AsyncGenerator<Record<string, unknown>>;
845
+ try {
846
+ attempt.signal.throwIfAborted();
847
+ source = await openCodexSseEventStream(
784
848
  requestContext.url,
785
849
  requestContext.requestHeaders,
786
850
  requestContext.accountId,
@@ -788,10 +852,16 @@ async function openCodexSseTransport(
788
852
  options?.sessionId,
789
853
  body,
790
854
  state,
791
- requestSetup.requestSignal,
855
+ attempt.signal,
792
856
  retryFetch,
793
- ),
794
- );
857
+ );
858
+ } catch (error) {
859
+ attempt.abort();
860
+ cleanup();
861
+ throw error;
862
+ }
863
+ const eventStream = iterateCodexSseAttempt(source, attempt, cleanup);
864
+
795
865
  return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
796
866
  }
797
867
 
@@ -837,6 +907,7 @@ function createCodexStreamRuntime(initial: {
837
907
  websocketRequestState?: CodexWebSocketRequestState;
838
908
  }): CodexStreamRuntime {
839
909
  return {
910
+ stagedEvents: [],
840
911
  websocketRequestState: initial.websocketRequestState,
841
912
  continuationRecoveryAttempt: 0,
842
913
  sawResponseAccepted: false,
@@ -850,6 +921,7 @@ function createCodexStreamRuntime(initial: {
850
921
  outputSlots: new Map(),
851
922
  currentIndex: -1,
852
923
  completedItemIds: new Set(),
924
+ completedContentIndices: new Set(),
853
925
  nativeOutputItems: [],
854
926
  websocketStreamRetries: 0,
855
927
  providerRetryAttempt: 0,
@@ -860,6 +932,30 @@ function createCodexStreamRuntime(initial: {
860
932
  };
861
933
  }
862
934
 
935
+ // Empty normalized blocks belong to the attempt until replay becomes unsafe.
936
+ class CodexAttemptEventStream extends AssistantMessageEventStream {
937
+ constructor(
938
+ private readonly target: AssistantMessageEventStream,
939
+ private readonly runtime: CodexStreamRuntime,
940
+ ) {
941
+ super();
942
+ }
943
+
944
+ override push(event: AssistantMessageEvent): void {
945
+ if (this.runtime.sawReplayUnsafeActivity || this.runtime.sawTerminalEvent) {
946
+ this.flush();
947
+ this.target.push(event);
948
+ } else {
949
+ this.runtime.stagedEvents.push(structuredCloneJSON(event));
950
+ }
951
+ }
952
+
953
+ flush(): void {
954
+ for (const event of this.runtime.stagedEvents) this.target.push(event);
955
+ this.runtime.stagedEvents.length = 0;
956
+ }
957
+ }
958
+
863
959
  async function processCodexResponseStream(
864
960
  context: CodexStreamProcessingContext,
865
961
  runtime: CodexStreamRuntime,
@@ -867,6 +963,7 @@ async function processCodexResponseStream(
867
963
  const { output, stream } = context;
868
964
  runtime.grammarToolInputProperties = context.requestContext.grammarToolInputProperties;
869
965
  stream.push({ type: "start", partial: output });
966
+ const attemptStream = new CodexAttemptEventStream(stream, runtime);
870
967
 
871
968
  while (true) {
872
969
  try {
@@ -875,10 +972,12 @@ async function processCodexResponseStream(
875
972
  await context.options?.onProviderStreamEvent?.(rawEvent, context.model);
876
973
  firstTokenTime = handleCodexStreamEvent({
877
974
  ...context,
975
+ stream: attemptStream,
878
976
  runtime,
879
977
  rawEvent,
880
978
  firstTokenTime,
881
979
  });
980
+ if (runtime.sawReplayUnsafeActivity || runtime.sawTerminalEvent) attemptStream.flush();
882
981
  }
883
982
  if (!runtime.sawTerminalEvent) {
884
983
  throw Object.assign(new Error("Codex stream ended before terminal completion event"), {
@@ -1047,6 +1146,7 @@ function handleCodexStreamEvent(args: {
1047
1146
  if (itemId && runtime.completedItemIds.has(itemId)) return firstTokenTime;
1048
1147
  if (itemId) runtime.completedItemIds.add(itemId);
1049
1148
  handleOutputItemDone(model, output, stream, runtime, rawEvent, blockIndex);
1149
+ if (runtime.currentIndex >= 0) runtime.completedContentIndices.add(runtime.currentIndex);
1050
1150
  runtime.outputSlots.delete(
1051
1151
  typeof rawEvent.output_index === "number" ? rawEvent.output_index : runtime.currentIndex,
1052
1152
  );
@@ -1215,6 +1315,7 @@ function handleOutputItemDone(
1215
1315
  ): void {
1216
1316
  const item = structuredCloneJSON(rawEvent.item) as CodexEventItem;
1217
1317
  runtime.nativeOutputItems.push(item as unknown as Record<string, unknown>);
1318
+ output.providerPayload = createOpenAIResponsesHistoryPayload(model.provider, runtime.nativeOutputItems);
1218
1319
  if (item.type === "custom_tool_call" && runtime.currentBlock?.type === "toolCall") {
1219
1320
  const property = runtime.grammarToolInputProperties?.get(item.name) ?? "input";
1220
1321
  Object.assign(runtime.currentBlock, {
@@ -1270,7 +1371,7 @@ function handleOutputItemDone(
1270
1371
  type: "toolCall",
1271
1372
  id: `${item.call_id}|${item.id}`,
1272
1373
  name: item.name,
1273
- arguments: parseStreamingJson(item.arguments || "{}"),
1374
+ arguments: JSON.parse(item.arguments || "{}"),
1274
1375
  };
1275
1376
  if (runtime.currentBlock?.type === "toolCall") {
1276
1377
  Object.assign(runtime.currentBlock, toolCall);
@@ -1409,7 +1510,7 @@ async function recoverCodexStreamError(
1409
1510
  runtime: CodexStreamRuntime,
1410
1511
  error: unknown,
1411
1512
  ): Promise<boolean> {
1412
- if (runtime.transport === "sse" && isTransportClosureError(error)) {
1513
+ if (runtime.transport === "sse" && (error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1413
1514
  return tryRestartCodexSseTransport(context, runtime, error);
1414
1515
  }
1415
1516
  if (isCodexRejectedContinuation(error)) {
@@ -1427,11 +1528,27 @@ async function recoverCodexStreamError(
1427
1528
  return false;
1428
1529
  }
1429
1530
 
1531
+ async function sleepBeforeCodexSseRestart(delayMs: number, signal: AbortSignal): Promise<void> {
1532
+ signal.throwIfAborted();
1533
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1534
+ const timer = setTimeout(resolve, delayMs);
1535
+ const onAbort = () => reject(signal.reason);
1536
+ signal.addEventListener("abort", onAbort, { once: true });
1537
+ try {
1538
+ await promise;
1539
+ signal.throwIfAborted();
1540
+ } finally {
1541
+ clearTimeout(timer);
1542
+ signal.removeEventListener("abort", onAbort);
1543
+ }
1544
+ }
1545
+
1430
1546
  async function tryRestartCodexSseTransport(
1431
1547
  context: CodexStreamProcessingContext,
1432
1548
  runtime: CodexStreamRuntime,
1433
1549
  originalError: unknown,
1434
1550
  ): Promise<boolean> {
1551
+ runtime.firstTransportFailure ??= originalError;
1435
1552
  let error = originalError;
1436
1553
  while (true) {
1437
1554
  const replayEligible =
@@ -1451,22 +1568,29 @@ async function tryRestartCodexSseTransport(
1451
1568
  terminalEventSeen: runtime.sawTerminalEvent,
1452
1569
  replayEligible,
1453
1570
  });
1454
- if (!replayEligible || !isTransportClosureError(error)) {
1571
+ if (!replayEligible || !(error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1455
1572
  if (error !== originalError) throw error;
1456
1573
  return false;
1457
1574
  }
1458
- if (runtime.transportRestartAttempt >= 2) throw originalError;
1575
+ if (runtime.transportRestartAttempt >= Math.max(0, context.options?.maxRetries ?? 2))
1576
+ throw runtime.firstTransportFailure;
1459
1577
  runtime.transportRestartAttempt += 1;
1460
- await runtime.eventStream.return(undefined);
1578
+ try {
1579
+ await runtime.eventStream.return(undefined);
1580
+ } catch {
1581
+ /* Preserve the recovery failure. */
1582
+ }
1461
1583
  runtime.currentItem = null;
1462
1584
  runtime.currentBlock = null;
1463
1585
  runtime.currentIndex = -1;
1464
1586
  runtime.outputSlots.clear();
1465
1587
  runtime.completedItemIds.clear();
1588
+ runtime.completedContentIndices.clear();
1466
1589
  runtime.nativeOutputItems.length = 0;
1467
1590
  runtime.sawResponseAccepted = false;
1468
1591
  runtime.sawOutputEvent = false;
1469
1592
  runtime.sawTerminalEvent = false;
1593
+ runtime.stagedEvents.length = 0;
1470
1594
  resetOutputState(context.output);
1471
1595
  delete context.output.responseId;
1472
1596
  delete context.output.providerPayload;
@@ -1480,7 +1604,10 @@ async function tryRestartCodexSseTransport(
1480
1604
  resetCodexWebSocketAppendState(state);
1481
1605
  resetCodexSessionMetadata(state);
1482
1606
  }
1483
- await abortableSleep(500 * runtime.transportRestartAttempt, context.requestSetup.requestSignal);
1607
+ await sleepBeforeCodexSseRestart(
1608
+ 500 * (runtime.transportRestartAttempt + Math.max(0, 2 - (context.options?.maxRetries ?? 2))),
1609
+ context.requestSetup.requestSignal,
1610
+ );
1484
1611
  try {
1485
1612
  const next = await openCodexSseTransport(
1486
1613
  context.requestContext,
@@ -1529,6 +1656,14 @@ async function tryRecoverCodexContinuation(
1529
1656
  resetCodexWebSocketAppendState(state);
1530
1657
  resetCodexSessionMetadata(state);
1531
1658
  if (!eligible) return false;
1659
+ if (context.options?.maxRetries !== undefined) {
1660
+ if (runtime.transportRestartAttempt >= context.options.maxRetries) return false;
1661
+ runtime.transportRestartAttempt += 1;
1662
+ await sleepBeforeCodexSseRestart(
1663
+ 500 * (runtime.transportRestartAttempt + Math.max(0, 2 - (context.options?.maxRetries ?? 2))),
1664
+ context.requestSetup.requestSignal,
1665
+ );
1666
+ }
1532
1667
  runtime.continuationRecoveryAttempt += 1;
1533
1668
  logCodexDebug("codex continuation recovery", {
1534
1669
  reason: "previous_response_unavailable",
@@ -1570,6 +1705,14 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1570
1705
  }
1571
1706
 
1572
1707
  // Close the stale connection so getOrCreateCodexWebSocketConnection creates a fresh one.
1708
+ if (context.options?.maxRetries !== undefined) {
1709
+ if (runtime.transportRestartAttempt >= context.options.maxRetries) return false;
1710
+ runtime.transportRestartAttempt += 1;
1711
+ await sleepBeforeCodexSseRestart(
1712
+ 500 * (runtime.transportRestartAttempt + Math.max(0, 2 - (context.options?.maxRetries ?? 2))),
1713
+ context.requestSetup.requestSignal,
1714
+ );
1715
+ }
1573
1716
  websocketState.connection?.close("connection_limit");
1574
1717
  websocketState.connection = undefined;
1575
1718
  resetCodexWebSocketAppendState(websocketState);
@@ -1588,7 +1731,9 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1588
1731
  runtime.currentBlock = null;
1589
1732
  runtime.outputSlots.clear();
1590
1733
  runtime.completedItemIds.clear();
1734
+ runtime.completedContentIndices.clear();
1591
1735
  runtime.nativeOutputItems.length = 0;
1736
+ runtime.stagedEvents.length = 0;
1592
1737
  resetOutputState(context.output);
1593
1738
  context.firstTokenTime = undefined;
1594
1739
  recordCodexWebSocketFailure(websocketState, true);
@@ -1616,6 +1761,14 @@ async function tryReplayWebsocketFailureOverSse(
1616
1761
  !runtime.sawTerminalEvent &&
1617
1762
  !context.options?.signal?.aborted;
1618
1763
  if (!canReplay) return false;
1764
+ if (context.options?.maxRetries !== undefined) {
1765
+ if (runtime.transportRestartAttempt >= context.options.maxRetries) return false;
1766
+ runtime.transportRestartAttempt += 1;
1767
+ await sleepBeforeCodexSseRestart(
1768
+ 500 * (runtime.transportRestartAttempt + Math.max(0, 2 - (context.options?.maxRetries ?? 2))),
1769
+ context.requestSetup.requestSignal,
1770
+ );
1771
+ }
1619
1772
 
1620
1773
  const state = websocketState;
1621
1774
  const streamError = error instanceof Error ? error : new Error(String(error));
@@ -1649,7 +1802,9 @@ async function tryReplayWebsocketFailureOverSse(
1649
1802
  runtime.currentBlock = null;
1650
1803
  runtime.outputSlots.clear();
1651
1804
  runtime.completedItemIds.clear();
1805
+ runtime.completedContentIndices.clear();
1652
1806
  runtime.nativeOutputItems.length = 0;
1807
+ runtime.stagedEvents.length = 0;
1653
1808
  resetOutputState(context.output);
1654
1809
  context.firstTokenTime = undefined;
1655
1810
  }
@@ -1690,7 +1845,9 @@ async function tryRetryCodexProviderError(
1690
1845
  runtime.currentBlock = null;
1691
1846
  runtime.outputSlots.clear();
1692
1847
  runtime.completedItemIds.clear();
1848
+ runtime.completedContentIndices.clear();
1693
1849
  runtime.sawTerminalEvent = false;
1850
+ runtime.stagedEvents.length = 0;
1694
1851
  resetOutputState(context.output);
1695
1852
  context.firstTokenTime = undefined;
1696
1853
  await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal);
@@ -1752,6 +1909,37 @@ async function handleCodexStreamFailure(
1752
1909
  resetCodexWebSocketAppendState(context.requestContext.websocketState);
1753
1910
  resetCodexSessionMetadata(context.requestContext.websocketState);
1754
1911
  }
1912
+ const runtime = context.runtime;
1913
+ const interrupted =
1914
+ !context.requestSetup.requestSignal.aborted &&
1915
+ runtime &&
1916
+ !runtime.sawTerminalEvent &&
1917
+ !(error instanceof CodexProviderStreamError) &&
1918
+ (error instanceof CodexSseIdleTimeoutError ||
1919
+ isTransportClosureError(error) ||
1920
+ (error as { codexTransportInterruption?: string })?.codexTransportInterruption);
1921
+ if (interrupted) {
1922
+ output.interruption = {
1923
+ transport: runtime.transport,
1924
+ classification:
1925
+ error instanceof CodexSseIdleTimeoutError ||
1926
+ (error as { codexTransportInterruption?: string }).codexTransportInterruption === "idle_timeout"
1927
+ ? "idle_timeout"
1928
+ : (error as { code?: string }).code === "ERR_STREAM_PREMATURE_CLOSE"
1929
+ ? "premature_eof"
1930
+ : "socket_closed",
1931
+ providerRetriesConsumed: runtime.transportRestartAttempt,
1932
+ completedContentIndices: [...runtime.completedContentIndices].sort((a, b) => a - b),
1933
+ };
1934
+ output.providerPayload = createOpenAIResponsesHistoryPayload(context.model.provider, runtime.nativeOutputItems);
1935
+ }
1936
+ if (runtime) {
1937
+ try {
1938
+ await runtime.eventStream.return(undefined);
1939
+ } catch {
1940
+ /* Preserve the original failure. */
1941
+ }
1942
+ }
1755
1943
  output.stopReason = context.options?.signal?.aborted ? "aborted" : "error";
1756
1944
  output.errorMessage = await finalizeErrorMessage(error, context.requestContext.rawRequestDump);
1757
1945
  output.providerFailureCode = sanitizeProviderFailureCode(
@@ -1791,6 +1979,7 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses"
1791
1979
  }
1792
1980
 
1793
1981
  processingContext = {
1982
+ runtime,
1794
1983
  model,
1795
1984
  output,
1796
1985
  stream,
@@ -2465,7 +2654,39 @@ async function openCodexSseEventStream(
2465
2654
  if (!response.body) {
2466
2655
  throw new Error("No response body");
2467
2656
  }
2468
- return readSseJson<Record<string, unknown>>(response.body, signal);
2657
+ signal?.throwIfAborted();
2658
+ const reader = response.body.getReader();
2659
+ let closed = false;
2660
+ const close = () => {
2661
+ if (closed) return;
2662
+ closed = true;
2663
+ // Cancel settles pending reads synchronously; do not await an upstream cancel hook.
2664
+ void reader.cancel().catch(() => {});
2665
+ reader.releaseLock();
2666
+ };
2667
+ const onAbort = () => close();
2668
+ signal?.addEventListener("abort", onAbort, { once: true });
2669
+ const owned = new ReadableStream<Uint8Array>({
2670
+ async pull(controller) {
2671
+ try {
2672
+ const result = await reader.read();
2673
+ if (result.done) controller.close();
2674
+ else controller.enqueue(result.value);
2675
+ } catch (error) {
2676
+ controller.error(error);
2677
+ }
2678
+ },
2679
+ cancel: close,
2680
+ });
2681
+ async function* events(): AsyncGenerator<Record<string, unknown>> {
2682
+ try {
2683
+ yield* readSseJson<Record<string, unknown>>(owned);
2684
+ } finally {
2685
+ close();
2686
+ signal?.removeEventListener("abort", onAbort);
2687
+ }
2688
+ }
2689
+ return events();
2469
2690
  }
2470
2691
 
2471
2692
  function createCodexHeaders(
@@ -2554,6 +2775,7 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
2554
2775
  let rateLimitTimeSpent = 0;
2555
2776
  while (true) {
2556
2777
  try {
2778
+ signal?.throwIfAborted();
2557
2779
  const response = await fetch(url, { ...init, signal: signal ?? init.signal });
2558
2780
  if (!retry || !CODEX_RETRYABLE_STATUS.has(response.status)) {
2559
2781
  return response;
@@ -2681,7 +2903,7 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
2681
2903
 
2682
2904
  const outputItems: ResponseInput = [];
2683
2905
  for (const block of msg.content) {
2684
- if (block.type === "thinking" && msg.stopReason !== "error") {
2906
+ if (block.type === "thinking" && (msg.stopReason !== "error" || !!assistantMsg.interruption)) {
2685
2907
  if (block.thinkingSignature) {
2686
2908
  outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
2687
2909
  }
@@ -135,7 +135,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
135
135
  assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api;
136
136
 
137
137
  for (const block of assistantMsg.content) {
138
- if (block.type === "thinking" && assistantMsg.stopReason !== "error") {
138
+ if (block.type === "thinking" && (assistantMsg.stopReason !== "error" || !!assistantMsg.interruption)) {
139
139
  if (!includeThinkingSignatures) {
140
140
  continue;
141
141
  }
@@ -69,7 +69,9 @@ export function transformMessages<TApi extends Api>(
69
69
  // Aborted/errored messages may have partially-streamed thinking signatures.
70
70
  // A partial signature is invalid and will be rejected by the API, so we must
71
71
  // strip signatures from thinking blocks in these messages.
72
- const hasInvalidSignatures = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
72
+ const hasInvalidSignatures =
73
+ assistantMsg.stopReason === "aborted" ||
74
+ (assistantMsg.stopReason === "error" && !assistantMsg.interruption);
73
75
 
74
76
  const transformedContent = assistantMsg.content.flatMap(block => {
75
77
  if (block.type === "thinking") {
@@ -205,7 +207,10 @@ export function transformMessages<TApi extends Api>(
205
207
  const assistantMsg = msg as AssistantMessage;
206
208
  const toolCalls = assistantMsg.content.filter(b => b.type === "toolCall") as ToolCall[];
207
209
 
208
- if (assistantMsg.stopReason === "error" || assistantMsg.stopReason === "aborted") {
210
+ if (
211
+ (assistantMsg.stopReason === "error" && !assistantMsg.interruption) ||
212
+ assistantMsg.stopReason === "aborted"
213
+ ) {
209
214
  // Keep the assistant message with tool calls intact. If real tool results follow, preserve them;
210
215
  // otherwise synthesize aborted results before the next turn boundary.
211
216
  result.push(msg);
package/src/types.ts CHANGED
@@ -442,6 +442,14 @@ export interface DeveloperMessage {
442
442
  }
443
443
 
444
444
  export interface AssistantMessage {
445
+ /** Completed content retained after a typed transport interruption. */
446
+ interruption?: {
447
+ transport: "sse" | "websocket";
448
+ classification: "socket_closed" | "premature_eof" | "idle_timeout";
449
+ providerRetriesConsumed: number;
450
+ toolChoiceServed?: boolean;
451
+ completedContentIndices: number[];
452
+ };
445
453
  /** A queued background request; poll using the same provider route. */
446
454
  deferred?: DeferredHandle;
447
455
  providerRewrite?: {