@f5-sales-demo/pi-ai 22.7.0 → 22.7.1

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.1",
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.1",
50
50
  "@google/genai": "2.21.0",
51
51
  "@sinclair/typebox": "0.34.52",
52
52
  "@smithy/node-http-handler": "4.12.1",
@@ -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;
@@ -453,15 +455,7 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
453
455
  const requestSignal = options?.signal
454
456
  ? AbortSignal.any([options.signal, requestAbortController.signal])
455
457
  : 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 };
458
+ return { requestAbortController, requestSignal };
465
459
  }
466
460
 
467
461
  async function buildCodexRequestContext(
@@ -766,6 +760,60 @@ async function openCodexWebSocketTransport(
766
760
  return { eventStream, requestBodyForState, transport: "websocket", websocketRequestState };
767
761
  }
768
762
 
763
+ class CodexSseIdleTimeoutError extends Error {
764
+ constructor() {
765
+ super("OpenAI Codex SSE stream stalled while waiting for the next event");
766
+ this.name = "CodexSseIdleTimeoutError";
767
+ }
768
+ }
769
+
770
+ async function* iterateCodexSseAttempt(
771
+ source: AsyncGenerator<Record<string, unknown>>,
772
+ attempt: AbortController,
773
+ cleanup: () => void,
774
+ ): AsyncGenerator<Record<string, unknown>> {
775
+ const timeoutMs = getOpenAIStreamIdleTimeoutMs();
776
+ try {
777
+ while (true) {
778
+ attempt.signal.throwIfAborted();
779
+ let timer: NodeJS.Timeout | undefined;
780
+ try {
781
+ const next = source.next();
782
+ const result =
783
+ timeoutMs === undefined
784
+ ? await next
785
+ : await Promise.race([
786
+ next,
787
+ new Promise<never>((_resolve, reject) => {
788
+ timer = setTimeout(() => {
789
+ const error = new CodexSseIdleTimeoutError();
790
+ reject(error);
791
+ attempt.abort(error);
792
+ }, timeoutMs);
793
+ }),
794
+ ]);
795
+ if (timer !== undefined) {
796
+ clearTimeout(timer);
797
+ timer = undefined;
798
+ }
799
+ attempt.signal.throwIfAborted();
800
+ if (result.done) return;
801
+ yield result.value;
802
+ } finally {
803
+ if (timer !== undefined) clearTimeout(timer);
804
+ }
805
+ }
806
+ } finally {
807
+ attempt.abort();
808
+ cleanup();
809
+ try {
810
+ await source.return(undefined);
811
+ } catch {
812
+ /* Preserve the original stream failure. */
813
+ }
814
+ }
815
+ }
816
+
769
817
  async function openCodexSseTransport(
770
818
  requestContext: CodexRequestContext,
771
819
  requestSetup: CodexRequestSetup,
@@ -779,8 +827,16 @@ async function openCodexSseTransport(
779
827
  transport: CodexTransport;
780
828
  websocketRequestState?: CodexWebSocketRequestState;
781
829
  }> {
782
- const eventStream = requestSetup.wrapCodexSseStream(
783
- await openCodexSseEventStream(
830
+ const attempt = new AbortController();
831
+ const signal = requestSetup.requestSignal;
832
+ const onAbort = () => attempt.abort(signal.reason);
833
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
834
+ signal.addEventListener("abort", onAbort, { once: true });
835
+ if (signal.aborted) onAbort();
836
+ let source: AsyncGenerator<Record<string, unknown>>;
837
+ try {
838
+ attempt.signal.throwIfAborted();
839
+ source = await openCodexSseEventStream(
784
840
  requestContext.url,
785
841
  requestContext.requestHeaders,
786
842
  requestContext.accountId,
@@ -788,10 +844,16 @@ async function openCodexSseTransport(
788
844
  options?.sessionId,
789
845
  body,
790
846
  state,
791
- requestSetup.requestSignal,
847
+ attempt.signal,
792
848
  retryFetch,
793
- ),
794
- );
849
+ );
850
+ } catch (error) {
851
+ attempt.abort();
852
+ cleanup();
853
+ throw error;
854
+ }
855
+ const eventStream = iterateCodexSseAttempt(source, attempt, cleanup);
856
+
795
857
  return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
796
858
  }
797
859
 
@@ -837,6 +899,7 @@ function createCodexStreamRuntime(initial: {
837
899
  websocketRequestState?: CodexWebSocketRequestState;
838
900
  }): CodexStreamRuntime {
839
901
  return {
902
+ stagedEvents: [],
840
903
  websocketRequestState: initial.websocketRequestState,
841
904
  continuationRecoveryAttempt: 0,
842
905
  sawResponseAccepted: false,
@@ -860,6 +923,30 @@ function createCodexStreamRuntime(initial: {
860
923
  };
861
924
  }
862
925
 
926
+ // Empty normalized blocks belong to the attempt until replay becomes unsafe.
927
+ class CodexAttemptEventStream extends AssistantMessageEventStream {
928
+ constructor(
929
+ private readonly target: AssistantMessageEventStream,
930
+ private readonly runtime: CodexStreamRuntime,
931
+ ) {
932
+ super();
933
+ }
934
+
935
+ override push(event: AssistantMessageEvent): void {
936
+ if (this.runtime.sawReplayUnsafeActivity || this.runtime.sawTerminalEvent) {
937
+ this.flush();
938
+ this.target.push(event);
939
+ } else {
940
+ this.runtime.stagedEvents.push(structuredCloneJSON(event));
941
+ }
942
+ }
943
+
944
+ flush(): void {
945
+ for (const event of this.runtime.stagedEvents) this.target.push(event);
946
+ this.runtime.stagedEvents.length = 0;
947
+ }
948
+ }
949
+
863
950
  async function processCodexResponseStream(
864
951
  context: CodexStreamProcessingContext,
865
952
  runtime: CodexStreamRuntime,
@@ -867,6 +954,7 @@ async function processCodexResponseStream(
867
954
  const { output, stream } = context;
868
955
  runtime.grammarToolInputProperties = context.requestContext.grammarToolInputProperties;
869
956
  stream.push({ type: "start", partial: output });
957
+ const attemptStream = new CodexAttemptEventStream(stream, runtime);
870
958
 
871
959
  while (true) {
872
960
  try {
@@ -875,10 +963,12 @@ async function processCodexResponseStream(
875
963
  await context.options?.onProviderStreamEvent?.(rawEvent, context.model);
876
964
  firstTokenTime = handleCodexStreamEvent({
877
965
  ...context,
966
+ stream: attemptStream,
878
967
  runtime,
879
968
  rawEvent,
880
969
  firstTokenTime,
881
970
  });
971
+ if (runtime.sawReplayUnsafeActivity || runtime.sawTerminalEvent) attemptStream.flush();
882
972
  }
883
973
  if (!runtime.sawTerminalEvent) {
884
974
  throw Object.assign(new Error("Codex stream ended before terminal completion event"), {
@@ -1409,7 +1499,7 @@ async function recoverCodexStreamError(
1409
1499
  runtime: CodexStreamRuntime,
1410
1500
  error: unknown,
1411
1501
  ): Promise<boolean> {
1412
- if (runtime.transport === "sse" && isTransportClosureError(error)) {
1502
+ if (runtime.transport === "sse" && (error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1413
1503
  return tryRestartCodexSseTransport(context, runtime, error);
1414
1504
  }
1415
1505
  if (isCodexRejectedContinuation(error)) {
@@ -1427,11 +1517,27 @@ async function recoverCodexStreamError(
1427
1517
  return false;
1428
1518
  }
1429
1519
 
1520
+ async function sleepBeforeCodexSseRestart(delayMs: number, signal: AbortSignal): Promise<void> {
1521
+ signal.throwIfAborted();
1522
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
1523
+ const timer = setTimeout(resolve, delayMs);
1524
+ const onAbort = () => reject(signal.reason);
1525
+ signal.addEventListener("abort", onAbort, { once: true });
1526
+ try {
1527
+ await promise;
1528
+ signal.throwIfAborted();
1529
+ } finally {
1530
+ clearTimeout(timer);
1531
+ signal.removeEventListener("abort", onAbort);
1532
+ }
1533
+ }
1534
+
1430
1535
  async function tryRestartCodexSseTransport(
1431
1536
  context: CodexStreamProcessingContext,
1432
1537
  runtime: CodexStreamRuntime,
1433
1538
  originalError: unknown,
1434
1539
  ): Promise<boolean> {
1540
+ runtime.firstTransportFailure ??= originalError;
1435
1541
  let error = originalError;
1436
1542
  while (true) {
1437
1543
  const replayEligible =
@@ -1451,13 +1557,17 @@ async function tryRestartCodexSseTransport(
1451
1557
  terminalEventSeen: runtime.sawTerminalEvent,
1452
1558
  replayEligible,
1453
1559
  });
1454
- if (!replayEligible || !isTransportClosureError(error)) {
1560
+ if (!replayEligible || !(error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
1455
1561
  if (error !== originalError) throw error;
1456
1562
  return false;
1457
1563
  }
1458
- if (runtime.transportRestartAttempt >= 2) throw originalError;
1564
+ if (runtime.transportRestartAttempt >= 2) throw runtime.firstTransportFailure;
1459
1565
  runtime.transportRestartAttempt += 1;
1460
- await runtime.eventStream.return(undefined);
1566
+ try {
1567
+ await runtime.eventStream.return(undefined);
1568
+ } catch {
1569
+ /* Preserve the recovery failure. */
1570
+ }
1461
1571
  runtime.currentItem = null;
1462
1572
  runtime.currentBlock = null;
1463
1573
  runtime.currentIndex = -1;
@@ -1467,6 +1577,7 @@ async function tryRestartCodexSseTransport(
1467
1577
  runtime.sawResponseAccepted = false;
1468
1578
  runtime.sawOutputEvent = false;
1469
1579
  runtime.sawTerminalEvent = false;
1580
+ runtime.stagedEvents.length = 0;
1470
1581
  resetOutputState(context.output);
1471
1582
  delete context.output.responseId;
1472
1583
  delete context.output.providerPayload;
@@ -1480,7 +1591,7 @@ async function tryRestartCodexSseTransport(
1480
1591
  resetCodexWebSocketAppendState(state);
1481
1592
  resetCodexSessionMetadata(state);
1482
1593
  }
1483
- await abortableSleep(500 * runtime.transportRestartAttempt, context.requestSetup.requestSignal);
1594
+ await sleepBeforeCodexSseRestart(500 * runtime.transportRestartAttempt, context.requestSetup.requestSignal);
1484
1595
  try {
1485
1596
  const next = await openCodexSseTransport(
1486
1597
  context.requestContext,
@@ -1589,6 +1700,7 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
1589
1700
  runtime.outputSlots.clear();
1590
1701
  runtime.completedItemIds.clear();
1591
1702
  runtime.nativeOutputItems.length = 0;
1703
+ runtime.stagedEvents.length = 0;
1592
1704
  resetOutputState(context.output);
1593
1705
  context.firstTokenTime = undefined;
1594
1706
  recordCodexWebSocketFailure(websocketState, true);
@@ -1650,6 +1762,7 @@ async function tryReplayWebsocketFailureOverSse(
1650
1762
  runtime.outputSlots.clear();
1651
1763
  runtime.completedItemIds.clear();
1652
1764
  runtime.nativeOutputItems.length = 0;
1765
+ runtime.stagedEvents.length = 0;
1653
1766
  resetOutputState(context.output);
1654
1767
  context.firstTokenTime = undefined;
1655
1768
  }
@@ -1691,6 +1804,7 @@ async function tryRetryCodexProviderError(
1691
1804
  runtime.outputSlots.clear();
1692
1805
  runtime.completedItemIds.clear();
1693
1806
  runtime.sawTerminalEvent = false;
1807
+ runtime.stagedEvents.length = 0;
1694
1808
  resetOutputState(context.output);
1695
1809
  context.firstTokenTime = undefined;
1696
1810
  await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal);
@@ -2465,7 +2579,39 @@ async function openCodexSseEventStream(
2465
2579
  if (!response.body) {
2466
2580
  throw new Error("No response body");
2467
2581
  }
2468
- return readSseJson<Record<string, unknown>>(response.body, signal);
2582
+ signal?.throwIfAborted();
2583
+ const reader = response.body.getReader();
2584
+ let closed = false;
2585
+ const close = () => {
2586
+ if (closed) return;
2587
+ closed = true;
2588
+ // Cancel settles pending reads synchronously; do not await an upstream cancel hook.
2589
+ void reader.cancel().catch(() => {});
2590
+ reader.releaseLock();
2591
+ };
2592
+ const onAbort = () => close();
2593
+ signal?.addEventListener("abort", onAbort, { once: true });
2594
+ const owned = new ReadableStream<Uint8Array>({
2595
+ async pull(controller) {
2596
+ try {
2597
+ const result = await reader.read();
2598
+ if (result.done) controller.close();
2599
+ else controller.enqueue(result.value);
2600
+ } catch (error) {
2601
+ controller.error(error);
2602
+ }
2603
+ },
2604
+ cancel: close,
2605
+ });
2606
+ async function* events(): AsyncGenerator<Record<string, unknown>> {
2607
+ try {
2608
+ yield* readSseJson<Record<string, unknown>>(owned);
2609
+ } finally {
2610
+ close();
2611
+ signal?.removeEventListener("abort", onAbort);
2612
+ }
2613
+ }
2614
+ return events();
2469
2615
  }
2470
2616
 
2471
2617
  function createCodexHeaders(
@@ -2554,6 +2700,7 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
2554
2700
  let rateLimitTimeSpent = 0;
2555
2701
  while (true) {
2556
2702
  try {
2703
+ signal?.throwIfAborted();
2557
2704
  const response = await fetch(url, { ...init, signal: signal ?? init.signal });
2558
2705
  if (!retry || !CODEX_RETRYABLE_STATUS.has(response.status)) {
2559
2706
  return response;