@f5-sales-demo/pi-ai 22.6.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 +2 -2
- package/src/providers/openai-codex-responses.ts +279 -20
- package/src/utils/retry.ts +27 -1
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.
|
|
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.
|
|
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,8 +48,9 @@ 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
|
|
51
|
+
import { getOpenAIStreamIdleTimeoutMs } from "../utils/idle-iterator";
|
|
51
52
|
import { parseStreamingJson } from "../utils/json-parse";
|
|
53
|
+
import { isTransportClosureError } from "../utils/retry";
|
|
52
54
|
import { adaptSchemaForStrict, NO_STRICT } from "../utils/schema";
|
|
53
55
|
import {
|
|
54
56
|
CODEX_BASE_URL,
|
|
@@ -184,7 +186,6 @@ interface CodexRequestContext {
|
|
|
184
186
|
|
|
185
187
|
interface CodexRequestSetup {
|
|
186
188
|
requestSignal: AbortSignal;
|
|
187
|
-
wrapCodexSseStream: (source: AsyncGenerator<Record<string, unknown>>) => AsyncGenerator<Record<string, unknown>>;
|
|
188
189
|
requestAbortController: AbortController;
|
|
189
190
|
}
|
|
190
191
|
|
|
@@ -194,6 +195,8 @@ interface CodexWebSocketRequestState {
|
|
|
194
195
|
}
|
|
195
196
|
|
|
196
197
|
interface CodexStreamRuntime {
|
|
198
|
+
stagedEvents: AssistantMessageEvent[];
|
|
199
|
+
firstTransportFailure?: unknown;
|
|
197
200
|
websocketRequestState?: CodexWebSocketRequestState;
|
|
198
201
|
continuationRecoveryAttempt: number;
|
|
199
202
|
sawResponseAccepted: boolean;
|
|
@@ -211,6 +214,8 @@ interface CodexStreamRuntime {
|
|
|
211
214
|
nativeOutputItems: Array<Record<string, unknown>>;
|
|
212
215
|
websocketStreamRetries: number;
|
|
213
216
|
providerRetryAttempt: number;
|
|
217
|
+
transportRestartAttempt: number;
|
|
218
|
+
sawReplayUnsafeActivity: boolean;
|
|
214
219
|
sawTerminalEvent: boolean;
|
|
215
220
|
canSafelyReplayWebsocketOverSse: boolean;
|
|
216
221
|
}
|
|
@@ -450,15 +455,7 @@ function createRequestSetup(options: OpenAICodexResponsesOptions | undefined): C
|
|
|
450
455
|
const requestSignal = options?.signal
|
|
451
456
|
? AbortSignal.any([options.signal, requestAbortController.signal])
|
|
452
457
|
: requestAbortController.signal;
|
|
453
|
-
|
|
454
|
-
source: AsyncGenerator<Record<string, unknown>>,
|
|
455
|
-
): AsyncGenerator<Record<string, unknown>> =>
|
|
456
|
-
iterateWithIdleTimeout(source, {
|
|
457
|
-
idleTimeoutMs: getOpenAIStreamIdleTimeoutMs(),
|
|
458
|
-
errorMessage: "OpenAI Codex SSE stream stalled while waiting for the next event",
|
|
459
|
-
onIdle: () => requestAbortController.abort(),
|
|
460
|
-
});
|
|
461
|
-
return { requestAbortController, requestSignal, wrapCodexSseStream };
|
|
458
|
+
return { requestAbortController, requestSignal };
|
|
462
459
|
}
|
|
463
460
|
|
|
464
461
|
async function buildCodexRequestContext(
|
|
@@ -763,20 +760,83 @@ async function openCodexWebSocketTransport(
|
|
|
763
760
|
return { eventStream, requestBodyForState, transport: "websocket", websocketRequestState };
|
|
764
761
|
}
|
|
765
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
|
+
|
|
766
817
|
async function openCodexSseTransport(
|
|
767
818
|
requestContext: CodexRequestContext,
|
|
768
819
|
requestSetup: CodexRequestSetup,
|
|
769
820
|
options: OpenAICodexResponsesOptions | undefined,
|
|
770
821
|
state: CodexWebSocketSessionState | undefined,
|
|
771
822
|
body = requestContext.transformedBody,
|
|
823
|
+
retryFetch = true,
|
|
772
824
|
): Promise<{
|
|
773
825
|
eventStream: AsyncGenerator<Record<string, unknown>>;
|
|
774
826
|
requestBodyForState: RequestBody;
|
|
775
827
|
transport: CodexTransport;
|
|
776
828
|
websocketRequestState?: CodexWebSocketRequestState;
|
|
777
829
|
}> {
|
|
778
|
-
const
|
|
779
|
-
|
|
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(
|
|
780
840
|
requestContext.url,
|
|
781
841
|
requestContext.requestHeaders,
|
|
782
842
|
requestContext.accountId,
|
|
@@ -784,9 +844,16 @@ async function openCodexSseTransport(
|
|
|
784
844
|
options?.sessionId,
|
|
785
845
|
body,
|
|
786
846
|
state,
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
847
|
+
attempt.signal,
|
|
848
|
+
retryFetch,
|
|
849
|
+
);
|
|
850
|
+
} catch (error) {
|
|
851
|
+
attempt.abort();
|
|
852
|
+
cleanup();
|
|
853
|
+
throw error;
|
|
854
|
+
}
|
|
855
|
+
const eventStream = iterateCodexSseAttempt(source, attempt, cleanup);
|
|
856
|
+
|
|
790
857
|
return { eventStream, requestBodyForState: structuredCloneJSON(body), transport: "sse" };
|
|
791
858
|
}
|
|
792
859
|
|
|
@@ -832,6 +899,7 @@ function createCodexStreamRuntime(initial: {
|
|
|
832
899
|
websocketRequestState?: CodexWebSocketRequestState;
|
|
833
900
|
}): CodexStreamRuntime {
|
|
834
901
|
return {
|
|
902
|
+
stagedEvents: [],
|
|
835
903
|
websocketRequestState: initial.websocketRequestState,
|
|
836
904
|
continuationRecoveryAttempt: 0,
|
|
837
905
|
sawResponseAccepted: false,
|
|
@@ -848,11 +916,37 @@ function createCodexStreamRuntime(initial: {
|
|
|
848
916
|
nativeOutputItems: [],
|
|
849
917
|
websocketStreamRetries: 0,
|
|
850
918
|
providerRetryAttempt: 0,
|
|
919
|
+
transportRestartAttempt: 0,
|
|
920
|
+
sawReplayUnsafeActivity: false,
|
|
851
921
|
sawTerminalEvent: false,
|
|
852
922
|
canSafelyReplayWebsocketOverSse: true,
|
|
853
923
|
};
|
|
854
924
|
}
|
|
855
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
|
+
|
|
856
950
|
async function processCodexResponseStream(
|
|
857
951
|
context: CodexStreamProcessingContext,
|
|
858
952
|
runtime: CodexStreamRuntime,
|
|
@@ -860,6 +954,7 @@ async function processCodexResponseStream(
|
|
|
860
954
|
const { output, stream } = context;
|
|
861
955
|
runtime.grammarToolInputProperties = context.requestContext.grammarToolInputProperties;
|
|
862
956
|
stream.push({ type: "start", partial: output });
|
|
957
|
+
const attemptStream = new CodexAttemptEventStream(stream, runtime);
|
|
863
958
|
|
|
864
959
|
while (true) {
|
|
865
960
|
try {
|
|
@@ -868,10 +963,17 @@ async function processCodexResponseStream(
|
|
|
868
963
|
await context.options?.onProviderStreamEvent?.(rawEvent, context.model);
|
|
869
964
|
firstTokenTime = handleCodexStreamEvent({
|
|
870
965
|
...context,
|
|
966
|
+
stream: attemptStream,
|
|
871
967
|
runtime,
|
|
872
968
|
rawEvent,
|
|
873
969
|
firstTokenTime,
|
|
874
970
|
});
|
|
971
|
+
if (runtime.sawReplayUnsafeActivity || runtime.sawTerminalEvent) attemptStream.flush();
|
|
972
|
+
}
|
|
973
|
+
if (!runtime.sawTerminalEvent) {
|
|
974
|
+
throw Object.assign(new Error("Codex stream ended before terminal completion event"), {
|
|
975
|
+
code: "ERR_STREAM_PREMATURE_CLOSE",
|
|
976
|
+
});
|
|
875
977
|
}
|
|
876
978
|
return { firstTokenTime };
|
|
877
979
|
} catch (error) {
|
|
@@ -894,6 +996,27 @@ function handleCodexStreamEvent(args: {
|
|
|
894
996
|
const { model, output, stream, runtime, rawEvent } = args;
|
|
895
997
|
const eventType = typeof rawEvent.type === "string" ? rawEvent.type : "";
|
|
896
998
|
if (!eventType) return args.firstTokenTime;
|
|
999
|
+
// Tool activity is unsafe from its first event, even if no arguments or tool result exists yet.
|
|
1000
|
+
const item = asRecord(rawEvent.item);
|
|
1001
|
+
const part = asRecord(rawEvent.part);
|
|
1002
|
+
const hasText = (value: unknown): boolean => typeof value === "string" && value.length > 0;
|
|
1003
|
+
if (
|
|
1004
|
+
/^response\.(?:function_call|custom_tool_call|.*_call\.)/.test(eventType) ||
|
|
1005
|
+
(item && typeof item.type === "string" && item.type !== "message" && item.type !== "reasoning") ||
|
|
1006
|
+
hasText(rawEvent.delta) ||
|
|
1007
|
+
hasText(rawEvent.text) ||
|
|
1008
|
+
hasText(part?.text) ||
|
|
1009
|
+
hasText(part?.refusal) ||
|
|
1010
|
+
(item &&
|
|
1011
|
+
[item.content, item.summary].some(
|
|
1012
|
+
parts =>
|
|
1013
|
+
Array.isArray(parts) &&
|
|
1014
|
+
parts.some(value => hasText(asRecord(value)?.text) || hasText(asRecord(value)?.refusal)),
|
|
1015
|
+
))
|
|
1016
|
+
) {
|
|
1017
|
+
runtime.sawReplayUnsafeActivity = true;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
897
1020
|
if (
|
|
898
1021
|
eventType === "response.created" ||
|
|
899
1022
|
eventType === "response.in_progress" ||
|
|
@@ -1376,6 +1499,9 @@ async function recoverCodexStreamError(
|
|
|
1376
1499
|
runtime: CodexStreamRuntime,
|
|
1377
1500
|
error: unknown,
|
|
1378
1501
|
): Promise<boolean> {
|
|
1502
|
+
if (runtime.transport === "sse" && (error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
|
|
1503
|
+
return tryRestartCodexSseTransport(context, runtime, error);
|
|
1504
|
+
}
|
|
1379
1505
|
if (isCodexRejectedContinuation(error)) {
|
|
1380
1506
|
return tryRecoverCodexContinuation(context, runtime);
|
|
1381
1507
|
}
|
|
@@ -1391,6 +1517,101 @@ async function recoverCodexStreamError(
|
|
|
1391
1517
|
return false;
|
|
1392
1518
|
}
|
|
1393
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
|
+
|
|
1535
|
+
async function tryRestartCodexSseTransport(
|
|
1536
|
+
context: CodexStreamProcessingContext,
|
|
1537
|
+
runtime: CodexStreamRuntime,
|
|
1538
|
+
originalError: unknown,
|
|
1539
|
+
): Promise<boolean> {
|
|
1540
|
+
runtime.firstTransportFailure ??= originalError;
|
|
1541
|
+
let error = originalError;
|
|
1542
|
+
while (true) {
|
|
1543
|
+
const replayEligible =
|
|
1544
|
+
!runtime.sawReplayUnsafeActivity &&
|
|
1545
|
+
!runtime.sawTerminalEvent &&
|
|
1546
|
+
!context.requestSetup.requestSignal.aborted &&
|
|
1547
|
+
context.output.content.every(
|
|
1548
|
+
block =>
|
|
1549
|
+
(block.type === "text" && block.text.length === 0) ||
|
|
1550
|
+
(block.type === "thinking" && block.thinking.length === 0),
|
|
1551
|
+
);
|
|
1552
|
+
logCodexDebug("codex SSE transport recovery", {
|
|
1553
|
+
transport: runtime.transport,
|
|
1554
|
+
model: context.model.id,
|
|
1555
|
+
elapsedMs: Date.now() - context.startTime,
|
|
1556
|
+
attempt: runtime.transportRestartAttempt,
|
|
1557
|
+
terminalEventSeen: runtime.sawTerminalEvent,
|
|
1558
|
+
replayEligible,
|
|
1559
|
+
});
|
|
1560
|
+
if (!replayEligible || !(error instanceof CodexSseIdleTimeoutError || isTransportClosureError(error))) {
|
|
1561
|
+
if (error !== originalError) throw error;
|
|
1562
|
+
return false;
|
|
1563
|
+
}
|
|
1564
|
+
if (runtime.transportRestartAttempt >= 2) throw runtime.firstTransportFailure;
|
|
1565
|
+
runtime.transportRestartAttempt += 1;
|
|
1566
|
+
try {
|
|
1567
|
+
await runtime.eventStream.return(undefined);
|
|
1568
|
+
} catch {
|
|
1569
|
+
/* Preserve the recovery failure. */
|
|
1570
|
+
}
|
|
1571
|
+
runtime.currentItem = null;
|
|
1572
|
+
runtime.currentBlock = null;
|
|
1573
|
+
runtime.currentIndex = -1;
|
|
1574
|
+
runtime.outputSlots.clear();
|
|
1575
|
+
runtime.completedItemIds.clear();
|
|
1576
|
+
runtime.nativeOutputItems.length = 0;
|
|
1577
|
+
runtime.sawResponseAccepted = false;
|
|
1578
|
+
runtime.sawOutputEvent = false;
|
|
1579
|
+
runtime.sawTerminalEvent = false;
|
|
1580
|
+
runtime.stagedEvents.length = 0;
|
|
1581
|
+
resetOutputState(context.output);
|
|
1582
|
+
delete context.output.responseId;
|
|
1583
|
+
delete context.output.providerPayload;
|
|
1584
|
+
delete context.output.duration;
|
|
1585
|
+
delete context.output.ttft;
|
|
1586
|
+
delete context.output.errorMessage;
|
|
1587
|
+
context.output.responseAttribution = { requestedModel: context.model.id };
|
|
1588
|
+
context.firstTokenTime = undefined;
|
|
1589
|
+
const state = context.requestContext.websocketState;
|
|
1590
|
+
if (state) {
|
|
1591
|
+
resetCodexWebSocketAppendState(state);
|
|
1592
|
+
resetCodexSessionMetadata(state);
|
|
1593
|
+
}
|
|
1594
|
+
await sleepBeforeCodexSseRestart(500 * runtime.transportRestartAttempt, context.requestSetup.requestSignal);
|
|
1595
|
+
try {
|
|
1596
|
+
const next = await openCodexSseTransport(
|
|
1597
|
+
context.requestContext,
|
|
1598
|
+
context.requestSetup,
|
|
1599
|
+
context.options,
|
|
1600
|
+
state,
|
|
1601
|
+
context.requestContext.transformedBody,
|
|
1602
|
+
false,
|
|
1603
|
+
);
|
|
1604
|
+
runtime.eventStream = next.eventStream;
|
|
1605
|
+
runtime.requestBodyForState = next.requestBodyForState;
|
|
1606
|
+
runtime.websocketRequestState = undefined;
|
|
1607
|
+
return true;
|
|
1608
|
+
} catch (reopenError) {
|
|
1609
|
+
if (context.requestSetup.requestSignal.aborted) throw reopenError;
|
|
1610
|
+
error = reopenError;
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1394
1615
|
function isCodexRejectedContinuation(error: unknown): boolean {
|
|
1395
1616
|
return (
|
|
1396
1617
|
error instanceof CodexProviderStreamError &&
|
|
@@ -1479,6 +1700,7 @@ async function tryReconnectCodexWebSocketOnConnectionLimit(
|
|
|
1479
1700
|
runtime.outputSlots.clear();
|
|
1480
1701
|
runtime.completedItemIds.clear();
|
|
1481
1702
|
runtime.nativeOutputItems.length = 0;
|
|
1703
|
+
runtime.stagedEvents.length = 0;
|
|
1482
1704
|
resetOutputState(context.output);
|
|
1483
1705
|
context.firstTokenTime = undefined;
|
|
1484
1706
|
recordCodexWebSocketFailure(websocketState, true);
|
|
@@ -1540,6 +1762,7 @@ async function tryReplayWebsocketFailureOverSse(
|
|
|
1540
1762
|
runtime.outputSlots.clear();
|
|
1541
1763
|
runtime.completedItemIds.clear();
|
|
1542
1764
|
runtime.nativeOutputItems.length = 0;
|
|
1765
|
+
runtime.stagedEvents.length = 0;
|
|
1543
1766
|
resetOutputState(context.output);
|
|
1544
1767
|
context.firstTokenTime = undefined;
|
|
1545
1768
|
}
|
|
@@ -1581,6 +1804,7 @@ async function tryRetryCodexProviderError(
|
|
|
1581
1804
|
runtime.outputSlots.clear();
|
|
1582
1805
|
runtime.completedItemIds.clear();
|
|
1583
1806
|
runtime.sawTerminalEvent = false;
|
|
1807
|
+
runtime.stagedEvents.length = 0;
|
|
1584
1808
|
resetOutputState(context.output);
|
|
1585
1809
|
context.firstTokenTime = undefined;
|
|
1586
1810
|
await abortableSleep(CODEX_RETRY_DELAY_MS * runtime.providerRetryAttempt, context.requestSetup.requestSignal);
|
|
@@ -2314,6 +2538,7 @@ async function openCodexSseEventStream(
|
|
|
2314
2538
|
body: RequestBody,
|
|
2315
2539
|
state: CodexWebSocketSessionState | undefined,
|
|
2316
2540
|
signal?: AbortSignal,
|
|
2541
|
+
retryFetch = true,
|
|
2317
2542
|
): Promise<AsyncGenerator<Record<string, unknown>>> {
|
|
2318
2543
|
const headers = createCodexHeaders(requestHeaders, accountId, apiKey, sessionId, "sse", state);
|
|
2319
2544
|
logCodexDebug("codex request", {
|
|
@@ -2332,6 +2557,7 @@ async function openCodexSseEventStream(
|
|
|
2332
2557
|
body: JSON.stringify(body),
|
|
2333
2558
|
},
|
|
2334
2559
|
signal,
|
|
2560
|
+
retryFetch,
|
|
2335
2561
|
);
|
|
2336
2562
|
logCodexDebug("codex response", {
|
|
2337
2563
|
url: response.url,
|
|
@@ -2353,7 +2579,39 @@ async function openCodexSseEventStream(
|
|
|
2353
2579
|
if (!response.body) {
|
|
2354
2580
|
throw new Error("No response body");
|
|
2355
2581
|
}
|
|
2356
|
-
|
|
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();
|
|
2357
2615
|
}
|
|
2358
2616
|
|
|
2359
2617
|
function createCodexHeaders(
|
|
@@ -2437,13 +2695,14 @@ function getRetryDelayMs(
|
|
|
2437
2695
|
return { delay: CODEX_RETRY_DELAY_MS * (attempt + 1), serverProvided: false };
|
|
2438
2696
|
}
|
|
2439
2697
|
|
|
2440
|
-
async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal): Promise<Response> {
|
|
2698
|
+
async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSignal, retry = true): Promise<Response> {
|
|
2441
2699
|
let attempt = 0;
|
|
2442
2700
|
let rateLimitTimeSpent = 0;
|
|
2443
2701
|
while (true) {
|
|
2444
2702
|
try {
|
|
2703
|
+
signal?.throwIfAborted();
|
|
2445
2704
|
const response = await fetch(url, { ...init, signal: signal ?? init.signal });
|
|
2446
|
-
if (!CODEX_RETRYABLE_STATUS.has(response.status)) {
|
|
2705
|
+
if (!retry || !CODEX_RETRYABLE_STATUS.has(response.status)) {
|
|
2447
2706
|
return response;
|
|
2448
2707
|
}
|
|
2449
2708
|
if (signal?.aborted) return response;
|
|
@@ -2465,7 +2724,7 @@ async function fetchWithRetry(url: string, init: RequestInit, signal?: AbortSign
|
|
|
2465
2724
|
}
|
|
2466
2725
|
await abortableSleep(delay, signal);
|
|
2467
2726
|
} catch (error) {
|
|
2468
|
-
if (attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
|
|
2727
|
+
if (!retry || attempt >= CODEX_MAX_RETRIES || signal?.aborted) {
|
|
2469
2728
|
throw error;
|
|
2470
2729
|
}
|
|
2471
2730
|
const delay = CODEX_RETRY_DELAY_MS * (attempt + 1);
|
package/src/utils/retry.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
type ErrorLike = {
|
|
2
2
|
message?: string;
|
|
3
3
|
name?: string;
|
|
4
|
+
code?: string;
|
|
4
5
|
status?: number;
|
|
5
6
|
statusCode?: number;
|
|
6
7
|
response?: { status?: number };
|
|
@@ -13,6 +14,31 @@ const TRANSIENT_MESSAGE_PATTERN =
|
|
|
13
14
|
const VALIDATION_MESSAGE_PATTERN =
|
|
14
15
|
/invalid|validation|bad request|unsupported|schema|missing required|not found|unauthorized|forbidden/i;
|
|
15
16
|
|
|
17
|
+
const BUN_SOCKET_CLOSE_MESSAGE =
|
|
18
|
+
"The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch()";
|
|
19
|
+
|
|
20
|
+
/** Recognize transport closures without interpreting arbitrary provider failure text as replay-safe. */
|
|
21
|
+
export function isTransportClosureError(error: unknown, depth = 0): boolean {
|
|
22
|
+
if (!error || typeof error !== "object" || depth > 2) return false;
|
|
23
|
+
const info = error as ErrorLike;
|
|
24
|
+
const message = info.message ?? "";
|
|
25
|
+
const status = extractHttpStatusFromError(error);
|
|
26
|
+
if (
|
|
27
|
+
info.name === "AbortError" ||
|
|
28
|
+
/aborted|cancelled|canceled/i.test(message) ||
|
|
29
|
+
VALIDATION_MESSAGE_PATTERN.test(message) ||
|
|
30
|
+
(status !== undefined && status >= 400 && status < 500)
|
|
31
|
+
)
|
|
32
|
+
return false;
|
|
33
|
+
return (
|
|
34
|
+
message === BUN_SOCKET_CLOSE_MESSAGE ||
|
|
35
|
+
info.code === "ECONNRESET" ||
|
|
36
|
+
info.code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
37
|
+
/\b(?:ECONNRESET|ERR_STREAM_PREMATURE_CLOSE)\b/.test(message) ||
|
|
38
|
+
isTransportClosureError(info.cause, depth + 1)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
/**
|
|
17
43
|
* Identify errors that should be retried (timeouts, 5xx, 408, 429, transient network failures).
|
|
18
44
|
*/
|
|
@@ -31,7 +57,7 @@ export function isRetryableError(error: unknown): boolean {
|
|
|
31
57
|
|
|
32
58
|
if (VALIDATION_MESSAGE_PATTERN.test(message)) return false;
|
|
33
59
|
|
|
34
|
-
return TRANSIENT_MESSAGE_PATTERN.test(message);
|
|
60
|
+
return isTransportClosureError(error) || TRANSIENT_MESSAGE_PATTERN.test(message);
|
|
35
61
|
}
|
|
36
62
|
|
|
37
63
|
export function extractHttpStatusFromError(error: unknown): number | undefined {
|