pi-openai-codex-compat 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +86 -38
- package/extensions/openai-codex-compat/apply-patch-diff-render.ts +6 -2
- package/extensions/openai-codex-compat/apply-patch-engine.ts +89 -5
- package/extensions/openai-codex-compat/apply-patch.ts +4 -5
- package/extensions/openai-codex-compat/codex-cache-diagnostics.ts +97 -0
- package/extensions/openai-codex-compat/codex-cache-key.ts +9 -0
- package/extensions/openai-codex-compat/codex-installation.ts +51 -0
- package/extensions/openai-codex-compat/codex-metadata.ts +139 -0
- package/extensions/openai-codex-compat/codex-protocol.ts +4 -2
- package/extensions/openai-codex-compat/codex-provider.ts +708 -128
- package/extensions/openai-codex-compat/codex-stream.ts +137 -40
- package/extensions/openai-codex-compat/codex-thread-lineage.ts +156 -0
- package/extensions/openai-codex-compat/codex-transport.ts +1795 -199
- package/extensions/openai-codex-compat/compaction-checkpoint.ts +2 -2
- package/extensions/openai-codex-compat/config.ts +15 -2
- package/extensions/openai-codex-compat/image-generation-schema.ts +37 -0
- package/extensions/openai-codex-compat/image-generation.ts +25 -48
- package/extensions/openai-codex-compat/index.ts +13 -0
- package/extensions/openai-codex-compat/namespaced-tools.ts +2 -0
- package/extensions/openai-codex-compat/output-limit-continuation.ts +151 -0
- package/extensions/openai-codex-compat/provider-error.ts +79 -0
- package/extensions/openai-codex-compat/remote-compaction.ts +13 -0
- package/extensions/openai-codex-compat/request-options.ts +2 -2
- package/extensions/openai-codex-compat/responses-lite.ts +147 -0
- package/extensions/openai-codex-compat/responses-replay.ts +0 -7
- package/extensions/openai-codex-compat/settings-pane.ts +11 -0
- package/extensions/openai-codex-compat/web-run.ts +7 -0
- package/package.json +2 -1
|
@@ -6,9 +6,19 @@ import {
|
|
|
6
6
|
type OpenAICodexResponsesOptions,
|
|
7
7
|
type ProviderEnv,
|
|
8
8
|
type ProviderHeaders,
|
|
9
|
+
uuidv7,
|
|
9
10
|
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { codexCacheKey } from "./codex-cache-key.ts";
|
|
12
|
+
import type { CodexCacheDiagnosticContext } from "./codex-cache-diagnostics.ts";
|
|
13
|
+
import {
|
|
14
|
+
applyCodexMetadataHeaders,
|
|
15
|
+
CODEX_INSTALLATION_ID_METADATA_KEY,
|
|
16
|
+
CODEX_WINDOW_ID_HEADER,
|
|
17
|
+
type CodexRequestKind,
|
|
18
|
+
} from "./codex-metadata.ts";
|
|
10
19
|
import { isObject, type JsonRecord } from "./codex-protocol.ts";
|
|
11
|
-
import { normalizeReplayItem,
|
|
20
|
+
import { normalizeReplayItem, stableResponsesJson } from "./responses-replay.ts";
|
|
21
|
+
import { applyResponsesLiteHeaders, responsesLiteSsePayload } from "./responses-lite.ts";
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
24
|
* Focused adaptation of @earendil-works/pi-ai@0.83.0
|
|
@@ -17,12 +27,20 @@ import { normalizeReplayItem, replayItemsEqual, stableResponsesJson } from "./re
|
|
|
17
27
|
|
|
18
28
|
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
19
29
|
const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
|
|
30
|
+
export const CODEX_WS_REQUEST_START_METADATA_KEY = "x-codex-ws-stream-request-start-ms";
|
|
20
31
|
const DEFAULT_MAX_RETRIES = 0;
|
|
21
|
-
const BASE_DELAY_MS =
|
|
32
|
+
const BASE_DELAY_MS = 200;
|
|
33
|
+
const DEFAULT_WEBSOCKET_MAX_RETRIES = 5;
|
|
34
|
+
const DEFAULT_WEBSOCKET_RETRY_BASE_DELAY_MS = 200;
|
|
35
|
+
const DEFAULT_SSE_STREAM_MAX_RETRIES = 5;
|
|
36
|
+
const DEFAULT_SSE_STREAM_RETRY_BASE_DELAY_MS = 200;
|
|
37
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
|
22
38
|
const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
|
|
23
39
|
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
|
|
24
|
-
const
|
|
25
|
-
const
|
|
40
|
+
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
|
41
|
+
const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
|
|
42
|
+
const CODEX_TURN_STATE_HEADER = "x-codex-turn-state";
|
|
43
|
+
const CODEX_ROUTING_HINT_HEADER = "x-codex-routing-hint";
|
|
26
44
|
|
|
27
45
|
type ProcessWithBuiltinModules = typeof process & {
|
|
28
46
|
getBuiltinModule?: {
|
|
@@ -31,10 +49,6 @@ type ProcessWithBuiltinModules = typeof process & {
|
|
|
31
49
|
};
|
|
32
50
|
};
|
|
33
51
|
|
|
34
|
-
type CodexTransportOptions = OpenAICodexResponsesOptions & {
|
|
35
|
-
env?: ProviderEnv;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
52
|
export type CodexJsonRequestOptions = {
|
|
39
53
|
apiKey: string;
|
|
40
54
|
headers?: ProviderHeaders;
|
|
@@ -43,6 +57,217 @@ export type CodexJsonRequestOptions = {
|
|
|
43
57
|
fetch?: typeof fetch;
|
|
44
58
|
};
|
|
45
59
|
|
|
60
|
+
export type CodexTransportFailureDiagnostic = {
|
|
61
|
+
type: "provider_transport_failure";
|
|
62
|
+
timestamp: number;
|
|
63
|
+
error: {
|
|
64
|
+
name?: string;
|
|
65
|
+
message: string;
|
|
66
|
+
stack?: string;
|
|
67
|
+
code?: string | number;
|
|
68
|
+
};
|
|
69
|
+
details: {
|
|
70
|
+
configuredTransport: string;
|
|
71
|
+
fallbackTransport?: "sse";
|
|
72
|
+
eventsEmitted: boolean;
|
|
73
|
+
phase: "before_message_stream_start" | "after_message_stream_start";
|
|
74
|
+
requestBytes: number;
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type CodexContinuationBypassReason =
|
|
79
|
+
| "request_template_changed"
|
|
80
|
+
| "non_array_input"
|
|
81
|
+
| "history_prefix_changed";
|
|
82
|
+
|
|
83
|
+
type CodexContinuationHistoryMismatch = {
|
|
84
|
+
index: number;
|
|
85
|
+
baselineInputItems: number;
|
|
86
|
+
currentInputItems: number;
|
|
87
|
+
baselineItem?: unknown;
|
|
88
|
+
currentItem?: unknown;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
type CodexTransportRecoveryAttempt = {
|
|
92
|
+
transport: "websocket" | "sse";
|
|
93
|
+
connection?: "new" | "reused";
|
|
94
|
+
contextMode: "full" | "delta";
|
|
95
|
+
inputItems: number;
|
|
96
|
+
fullInputItems: number;
|
|
97
|
+
fullRequestBytes: number;
|
|
98
|
+
wireRequestBytes: number;
|
|
99
|
+
outcome: "selected" | "previous_response_not_found" | "retry_scheduled";
|
|
100
|
+
turnStateReplayed?: boolean;
|
|
101
|
+
turnStateReplayedValue?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type CodexTransportRecoveryDiagnostic = {
|
|
105
|
+
type: "codex_transport_recovery";
|
|
106
|
+
timestamp: number;
|
|
107
|
+
details: {
|
|
108
|
+
trigger:
|
|
109
|
+
| "previous_response_not_found"
|
|
110
|
+
| "local_continuation_bypass"
|
|
111
|
+
| "websocket_retry"
|
|
112
|
+
| "sse_stream_retry"
|
|
113
|
+
| "sse_after_websocket_failure"
|
|
114
|
+
| "sticky_sse_after_websocket_failure";
|
|
115
|
+
configuredTransport: string;
|
|
116
|
+
previousResponseId?: string;
|
|
117
|
+
continuationBypassReason?: CodexContinuationBypassReason;
|
|
118
|
+
historyMismatch?: CodexContinuationHistoryMismatch;
|
|
119
|
+
error?: CodexTransportFailureDiagnostic["error"];
|
|
120
|
+
attempts: CodexTransportRecoveryAttempt[];
|
|
121
|
+
cacheIdentity: CacheIdentitySnapshot;
|
|
122
|
+
previousCacheIdentity?: CacheIdentitySnapshot;
|
|
123
|
+
cacheAffinityEnabled: boolean;
|
|
124
|
+
cacheIdentityPreserved?: boolean;
|
|
125
|
+
promptKeyAndHeaderAligned: boolean;
|
|
126
|
+
accountIdentityPreserved?: boolean;
|
|
127
|
+
retryNumber?: number;
|
|
128
|
+
maxRetries?: number;
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type CodexTransportPrewarmDiagnostic = {
|
|
133
|
+
type: "codex_transport_prewarm";
|
|
134
|
+
timestamp: number;
|
|
135
|
+
details: {
|
|
136
|
+
outcome: "completed" | "failed" | "skipped";
|
|
137
|
+
continuationReady: boolean;
|
|
138
|
+
reason?: "sse_configured" | "sticky_sse_fallback";
|
|
139
|
+
cache?: CodexCacheDiagnosticContext;
|
|
140
|
+
turnStateAvailableAtStart?: boolean;
|
|
141
|
+
turnStateReceived?: boolean;
|
|
142
|
+
turnStateAtStart?: string;
|
|
143
|
+
turnStateReceivedValue?: string;
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type CodexCacheUsageDiagnostic = {
|
|
148
|
+
inputTokens: number;
|
|
149
|
+
cachedTokens: number;
|
|
150
|
+
cacheWriteTokens: number;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export type CodexTransportRequestDiagnostic = {
|
|
154
|
+
type: "codex_transport_request";
|
|
155
|
+
timestamp: number;
|
|
156
|
+
details: {
|
|
157
|
+
requestKind: CodexRequestKind;
|
|
158
|
+
configuredTransport: string;
|
|
159
|
+
selectedTransport: "websocket" | "sse";
|
|
160
|
+
connection?: "new" | "reused";
|
|
161
|
+
contextMode: "full" | "delta";
|
|
162
|
+
inputItems: number;
|
|
163
|
+
fullInputItems: number;
|
|
164
|
+
fullRequestBytes: number;
|
|
165
|
+
wireRequestBytes: number;
|
|
166
|
+
cacheAffinityEnabled: boolean;
|
|
167
|
+
promptKeyAndHeaderAligned: boolean;
|
|
168
|
+
cacheIdentity: CacheIdentitySnapshot;
|
|
169
|
+
sessionId?: string;
|
|
170
|
+
promptCacheKey?: string;
|
|
171
|
+
accountId: string;
|
|
172
|
+
clientSessionId?: string;
|
|
173
|
+
threadId?: string;
|
|
174
|
+
turnId?: string;
|
|
175
|
+
installationId?: string;
|
|
176
|
+
windowId?: string;
|
|
177
|
+
routingHint?: string;
|
|
178
|
+
turnMetadata?: string;
|
|
179
|
+
responseId?: string;
|
|
180
|
+
previousResponseId?: string;
|
|
181
|
+
turnStateAvailableAtStart: boolean;
|
|
182
|
+
turnStateReplayed: boolean;
|
|
183
|
+
turnStateReceived: boolean;
|
|
184
|
+
turnStateAtStart?: string;
|
|
185
|
+
turnStateReplayedValue?: string;
|
|
186
|
+
turnStateReceivedValue?: string;
|
|
187
|
+
usage?: CodexCacheUsageDiagnostic;
|
|
188
|
+
cache?: CodexCacheDiagnosticContext;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export type CodexTransportDiagnostic =
|
|
193
|
+
| CodexTransportFailureDiagnostic
|
|
194
|
+
| CodexTransportRecoveryDiagnostic
|
|
195
|
+
| CodexTransportPrewarmDiagnostic
|
|
196
|
+
| CodexTransportRequestDiagnostic;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* In-memory, turn-scoped server routing state. Diagnostics read the value
|
|
200
|
+
* explicitly so it is visible only where the transport records it deliberately.
|
|
201
|
+
*/
|
|
202
|
+
export class CodexTurnState {
|
|
203
|
+
#value: string | undefined;
|
|
204
|
+
#revision = 0;
|
|
205
|
+
|
|
206
|
+
get available(): boolean {
|
|
207
|
+
return this.#value !== undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get revision(): number {
|
|
211
|
+
return this.#revision;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
replayValue(): string | undefined {
|
|
215
|
+
return this.#value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
capture(value: string | undefined): boolean {
|
|
219
|
+
if (this.#value !== undefined || !value) return false;
|
|
220
|
+
this.#value = value;
|
|
221
|
+
this.#revision += 1;
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export type CodexContinuationHandle = {
|
|
227
|
+
readonly responseId: string;
|
|
228
|
+
replaceResponseItems(items: readonly JsonRecord[]): boolean;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
export type CodexWebSocketResponseHandle = {
|
|
232
|
+
discard(): boolean;
|
|
233
|
+
failParsing(error: unknown): boolean;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
export interface OpenAICodexWebSocketDebugStats {
|
|
237
|
+
requests: number;
|
|
238
|
+
connectionsCreated: number;
|
|
239
|
+
connectionsReused: number;
|
|
240
|
+
cachedContextRequests: number;
|
|
241
|
+
storeTrueRequests: number;
|
|
242
|
+
fullContextRequests: number;
|
|
243
|
+
deltaRequests: number;
|
|
244
|
+
lastInputItems: number;
|
|
245
|
+
lastDeltaInputItems?: number;
|
|
246
|
+
lastPreviousResponseId?: string;
|
|
247
|
+
websocketFailures: number;
|
|
248
|
+
sseFallbacks: number;
|
|
249
|
+
prewarmRequests: number;
|
|
250
|
+
websocketFallbackActive?: boolean;
|
|
251
|
+
lastWebSocketError?: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
type CodexTransportOptions = OpenAICodexResponsesOptions & {
|
|
255
|
+
accountId?: string;
|
|
256
|
+
env?: ProviderEnv;
|
|
257
|
+
onContinuationReady?(handle: CodexContinuationHandle): void;
|
|
258
|
+
onWebSocketResponseHandle?(handle: CodexWebSocketResponseHandle): void;
|
|
259
|
+
onTransportStart?(): void;
|
|
260
|
+
onTransportDiagnostic?(diagnostic: CodexTransportDiagnostic): void;
|
|
261
|
+
warmup?: boolean;
|
|
262
|
+
requestKind?: CodexRequestKind;
|
|
263
|
+
turnState?: CodexTurnState;
|
|
264
|
+
cacheDiagnostics?: CodexCacheDiagnosticContext;
|
|
265
|
+
websocketMaxRetries?: number;
|
|
266
|
+
websocketRetryBaseDelayMs?: number;
|
|
267
|
+
sseStreamMaxRetries?: number;
|
|
268
|
+
sseStreamRetryBaseDelayMs?: number;
|
|
269
|
+
};
|
|
270
|
+
|
|
46
271
|
type WebSocketEventType = "open" | "message" | "error" | "close";
|
|
47
272
|
type WebSocketListener = (event: unknown) => void;
|
|
48
273
|
|
|
@@ -62,8 +287,6 @@ type WebSocketConstructor = new (
|
|
|
62
287
|
type CachedWebSocket = {
|
|
63
288
|
socket: WebSocketLike;
|
|
64
289
|
busy: boolean;
|
|
65
|
-
createdAt: number;
|
|
66
|
-
idleTimer?: ReturnType<typeof setTimeout>;
|
|
67
290
|
continuation?: {
|
|
68
291
|
lastRequestBody: JsonRecord;
|
|
69
292
|
lastResponseId: string;
|
|
@@ -71,10 +294,167 @@ type CachedWebSocket = {
|
|
|
71
294
|
};
|
|
72
295
|
};
|
|
73
296
|
|
|
74
|
-
|
|
75
|
-
|
|
297
|
+
export type CacheIdentitySnapshot = {
|
|
298
|
+
promptCacheKey: string | undefined;
|
|
299
|
+
sessionHeader: string | null;
|
|
300
|
+
threadHeader: string | null;
|
|
301
|
+
clientRequestHeader: string | null;
|
|
302
|
+
installationId?: string;
|
|
303
|
+
windowId?: string;
|
|
304
|
+
routingHint: string | null;
|
|
305
|
+
accountId: string;
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
type WebSocketFallbackSession = {
|
|
309
|
+
cacheIdentity: CacheIdentitySnapshot;
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const websocketSessions = new Map<string, Map<string, CachedWebSocket>>();
|
|
313
|
+
const websocketFallbackSessions = new Map<string, WebSocketFallbackSession>();
|
|
314
|
+
const websocketDebugStats = new Map<string, OpenAICodexWebSocketDebugStats>();
|
|
315
|
+
|
|
316
|
+
function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats {
|
|
317
|
+
let stats = websocketDebugStats.get(sessionId);
|
|
318
|
+
if (!stats) {
|
|
319
|
+
stats = {
|
|
320
|
+
requests: 0,
|
|
321
|
+
connectionsCreated: 0,
|
|
322
|
+
connectionsReused: 0,
|
|
323
|
+
cachedContextRequests: 0,
|
|
324
|
+
storeTrueRequests: 0,
|
|
325
|
+
fullContextRequests: 0,
|
|
326
|
+
deltaRequests: 0,
|
|
327
|
+
lastInputItems: 0,
|
|
328
|
+
websocketFailures: 0,
|
|
329
|
+
sseFallbacks: 0,
|
|
330
|
+
prewarmRequests: 0,
|
|
331
|
+
};
|
|
332
|
+
websocketDebugStats.set(sessionId, stats);
|
|
333
|
+
}
|
|
334
|
+
return stats;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function getOpenAICodexWebSocketDebugStats(
|
|
338
|
+
sessionId: string,
|
|
339
|
+
): OpenAICodexWebSocketDebugStats | undefined {
|
|
340
|
+
const stats = websocketDebugStats.get(sessionId);
|
|
341
|
+
return stats ? { ...stats } : undefined;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void {
|
|
345
|
+
if (sessionId) {
|
|
346
|
+
websocketDebugStats.delete(sessionId);
|
|
347
|
+
websocketFallbackSessions.delete(sessionId);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
websocketDebugStats.clear();
|
|
351
|
+
websocketFallbackSessions.clear();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function closeOpenAICodexWebSocketSessions(sessionId?: string): void {
|
|
355
|
+
const closeEntry = (entry: CachedWebSocket): void => {
|
|
356
|
+
closeSocket(entry.socket, "debug_close");
|
|
357
|
+
};
|
|
358
|
+
if (sessionId) {
|
|
359
|
+
for (const entry of websocketSessions.get(sessionId)?.values() ?? []) closeEntry(entry);
|
|
360
|
+
websocketSessions.delete(sessionId);
|
|
361
|
+
websocketFallbackSessions.delete(sessionId);
|
|
362
|
+
const stats = websocketDebugStats.get(sessionId);
|
|
363
|
+
if (stats?.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
for (const accountEntries of websocketSessions.values()) {
|
|
367
|
+
for (const entry of accountEntries.values()) closeEntry(entry);
|
|
368
|
+
}
|
|
369
|
+
websocketSessions.clear();
|
|
370
|
+
websocketFallbackSessions.clear();
|
|
371
|
+
for (const stats of websocketDebugStats.values()) {
|
|
372
|
+
if (stats.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean {
|
|
377
|
+
return sessionId ? websocketFallbackSessions.has(sessionId) : false;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function webSocketFallbackSession(
|
|
381
|
+
sessionId: string | undefined,
|
|
382
|
+
): WebSocketFallbackSession | undefined {
|
|
383
|
+
return sessionId ? websocketFallbackSessions.get(sessionId) : undefined;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function recordWebSocketSseFallback(sessionId: string | undefined): void {
|
|
387
|
+
if (!sessionId) return;
|
|
388
|
+
const stats = getOrCreateWebSocketDebugStats(sessionId);
|
|
389
|
+
stats.sseFallbacks += 1;
|
|
390
|
+
stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function recordWebSocketFailure(
|
|
394
|
+
sessionId: string | undefined,
|
|
395
|
+
error: unknown,
|
|
396
|
+
cacheIdentity: CacheIdentitySnapshot,
|
|
397
|
+
): void {
|
|
398
|
+
if (!sessionId) return;
|
|
399
|
+
websocketFallbackSessions.set(sessionId, { cacheIdentity });
|
|
400
|
+
const stats = getOrCreateWebSocketDebugStats(sessionId);
|
|
401
|
+
stats.websocketFailures += 1;
|
|
402
|
+
stats.lastWebSocketError = thrownMessage(error);
|
|
403
|
+
stats.websocketFallbackActive = true;
|
|
404
|
+
}
|
|
76
405
|
|
|
77
|
-
class
|
|
406
|
+
class CodexApiError extends Error {
|
|
407
|
+
readonly code: string | undefined;
|
|
408
|
+
readonly payload: JsonRecord;
|
|
409
|
+
|
|
410
|
+
constructor(message: string, code: string | undefined, payload: JsonRecord) {
|
|
411
|
+
super(message);
|
|
412
|
+
this.name = "CodexApiError";
|
|
413
|
+
this.code = code;
|
|
414
|
+
this.payload = payload;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
class CodexProtocolError extends Error {
|
|
419
|
+
readonly payload: unknown;
|
|
420
|
+
|
|
421
|
+
constructor(message: string, payload: unknown, cause: unknown) {
|
|
422
|
+
super(message, { cause });
|
|
423
|
+
this.name = "CodexProtocolError";
|
|
424
|
+
this.payload = payload;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
class CodexHttpError extends Error {
|
|
429
|
+
readonly retryable: boolean;
|
|
430
|
+
|
|
431
|
+
constructor(message: string, retryable: boolean) {
|
|
432
|
+
super(message);
|
|
433
|
+
this.name = "CodexHttpError";
|
|
434
|
+
this.retryable = retryable;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
class WebSocketCloseError extends Error {
|
|
439
|
+
readonly code: number | undefined;
|
|
440
|
+
readonly reason: string | undefined;
|
|
441
|
+
readonly wasClean: boolean | undefined;
|
|
442
|
+
|
|
443
|
+
constructor(
|
|
444
|
+
message: string,
|
|
445
|
+
options: {
|
|
446
|
+
code: number | undefined;
|
|
447
|
+
reason: string | undefined;
|
|
448
|
+
wasClean: boolean | undefined;
|
|
449
|
+
},
|
|
450
|
+
) {
|
|
451
|
+
super(message);
|
|
452
|
+
this.name = "WebSocketCloseError";
|
|
453
|
+
this.code = options.code;
|
|
454
|
+
this.reason = options.reason;
|
|
455
|
+
this.wasClean = options.wasClean;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
78
458
|
|
|
79
459
|
function nodeOs(): typeof NodeOs | undefined {
|
|
80
460
|
const currentProcess = process as ProcessWithBuiltinModules;
|
|
@@ -86,8 +466,99 @@ function nodeZlib(): typeof NodeZlib | undefined {
|
|
|
86
466
|
return currentProcess.getBuiltinModule?.("node:zlib");
|
|
87
467
|
}
|
|
88
468
|
|
|
89
|
-
function
|
|
90
|
-
|
|
469
|
+
function extractWebSocketError(event: unknown): Error {
|
|
470
|
+
if (isObject(event)) {
|
|
471
|
+
if (typeof event["message"] === "string" && event["message"].length > 0) {
|
|
472
|
+
return new Error(event["message"]);
|
|
473
|
+
}
|
|
474
|
+
const nestedError = event["error"];
|
|
475
|
+
if (nestedError instanceof Error && nestedError.message.length > 0) return nestedError;
|
|
476
|
+
if (
|
|
477
|
+
isObject(nestedError) &&
|
|
478
|
+
typeof nestedError["message"] === "string" &&
|
|
479
|
+
nestedError["message"].length > 0
|
|
480
|
+
) {
|
|
481
|
+
return new Error(nestedError["message"]);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return new Error("WebSocket error");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function extractWebSocketCloseError(
|
|
488
|
+
event: unknown,
|
|
489
|
+
context = "WebSocket closed",
|
|
490
|
+
): WebSocketCloseError {
|
|
491
|
+
const code = isObject(event) && typeof event["code"] === "number" ? event["code"] : undefined;
|
|
492
|
+
let reason =
|
|
493
|
+
isObject(event) && typeof event["reason"] === "string" && event["reason"].length > 0
|
|
494
|
+
? event["reason"]
|
|
495
|
+
: undefined;
|
|
496
|
+
if (reason === undefined && code === 1_009) reason = "message too big";
|
|
497
|
+
const wasClean =
|
|
498
|
+
isObject(event) && typeof event["wasClean"] === "boolean" ? event["wasClean"] : undefined;
|
|
499
|
+
const details = [
|
|
500
|
+
code === undefined ? undefined : `code ${code}`,
|
|
501
|
+
reason === undefined ? undefined : `reason: ${reason}`,
|
|
502
|
+
wasClean === undefined ? undefined : `wasClean: ${String(wasClean)}`,
|
|
503
|
+
].filter((detail): detail is string => detail !== undefined);
|
|
504
|
+
return new WebSocketCloseError(
|
|
505
|
+
details.length > 0 ? `${context} (${details.join(", ")})` : context,
|
|
506
|
+
{ code, reason, wasClean },
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function thrownMessage(error: unknown): string {
|
|
511
|
+
return error instanceof Error ? error.message || error.name : String(error);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function isCodexNonTransportError(error: unknown): boolean {
|
|
515
|
+
return (
|
|
516
|
+
error instanceof CodexApiError ||
|
|
517
|
+
error instanceof CodexHttpError ||
|
|
518
|
+
error instanceof CodexProtocolError
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
|
|
523
|
+
return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function isPreviousResponseNotFoundError(error: unknown): boolean {
|
|
527
|
+
return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function diagnosticError(error: unknown): CodexTransportFailureDiagnostic["error"] {
|
|
531
|
+
if (!(error instanceof Error)) {
|
|
532
|
+
return { name: "ThrownValue", message: thrownMessage(error) };
|
|
533
|
+
}
|
|
534
|
+
const code = (error as Error & { code?: unknown }).code;
|
|
535
|
+
return {
|
|
536
|
+
...(error.name ? { name: error.name } : {}),
|
|
537
|
+
message: error.message || error.name,
|
|
538
|
+
...(error.stack ? { stack: error.stack } : {}),
|
|
539
|
+
...(typeof code === "string" || typeof code === "number" ? { code } : {}),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function transportDiagnostic(
|
|
544
|
+
error: unknown,
|
|
545
|
+
transport: string,
|
|
546
|
+
emitted: boolean,
|
|
547
|
+
requestBytes: number,
|
|
548
|
+
fallbackSelected = !emitted,
|
|
549
|
+
): CodexTransportFailureDiagnostic {
|
|
550
|
+
return {
|
|
551
|
+
type: "provider_transport_failure",
|
|
552
|
+
timestamp: Date.now(),
|
|
553
|
+
error: diagnosticError(error),
|
|
554
|
+
details: {
|
|
555
|
+
configuredTransport: transport,
|
|
556
|
+
...(fallbackSelected ? { fallbackTransport: "sse" as const } : {}),
|
|
557
|
+
eventsEmitted: emitted,
|
|
558
|
+
phase: emitted ? "after_message_stream_start" : "before_message_stream_start",
|
|
559
|
+
requestBytes,
|
|
560
|
+
},
|
|
561
|
+
};
|
|
91
562
|
}
|
|
92
563
|
|
|
93
564
|
function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
@@ -108,6 +579,96 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
|
108
579
|
});
|
|
109
580
|
}
|
|
110
581
|
|
|
582
|
+
function retryBackoffMs(baseDelayMs: number, attempt: number): number {
|
|
583
|
+
if (baseDelayMs <= 0) return 0;
|
|
584
|
+
const exponential = baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
|
585
|
+
const jitter = 0.9 + Math.random() * 0.2;
|
|
586
|
+
return Math.floor(exponential * jitter);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
590
|
+
if (value === undefined) return undefined;
|
|
591
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
592
|
+
throw new Error(`Invalid timeoutMs: ${String(value)}`);
|
|
593
|
+
}
|
|
594
|
+
return Math.floor(value);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function normalizeRetryCount(value: number | undefined, fallback: number, name: string): number {
|
|
598
|
+
const resolved = value ?? fallback;
|
|
599
|
+
if (!Number.isFinite(resolved) || resolved < 0) {
|
|
600
|
+
throw new Error(`Invalid ${name}: ${String(resolved)}`);
|
|
601
|
+
}
|
|
602
|
+
return Math.floor(resolved);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function isTerminalRateLimitError(errorText: string): boolean {
|
|
606
|
+
return /GoUsageLimitError|FreeUsageLimitError|usage_limit_reached|usage_not_included|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
|
|
607
|
+
errorText,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function retryDelayMs(headers: Headers): number | undefined {
|
|
612
|
+
const retryAfterMs = headers.get("retry-after-ms");
|
|
613
|
+
if (retryAfterMs !== null) {
|
|
614
|
+
const milliseconds = Number(retryAfterMs);
|
|
615
|
+
if (Number.isFinite(milliseconds)) return Math.max(0, milliseconds);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const retryAfter = headers.get("retry-after");
|
|
619
|
+
if (!retryAfter) return undefined;
|
|
620
|
+
const seconds = Number(retryAfter);
|
|
621
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
|
|
622
|
+
const date = Date.parse(retryAfter);
|
|
623
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
class RetryDelayExceededError extends Error {}
|
|
627
|
+
class SseStreamIncompleteError extends Error {}
|
|
628
|
+
|
|
629
|
+
function validateRetryDelay(delayMs: number, options: CodexTransportOptions): number {
|
|
630
|
+
const maximum = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
631
|
+
if (maximum > 0 && delayMs > maximum) {
|
|
632
|
+
throw new RetryDelayExceededError(
|
|
633
|
+
`Server requested ${Math.ceil(delayMs / 1_000)}s retry delay (max: ${Math.ceil(maximum / 1_000)}s)`,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return delayMs;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function codexHttpError(status: number, statusText: string, raw: string): CodexHttpError {
|
|
640
|
+
let message = raw || statusText || "Request failed";
|
|
641
|
+
let friendlyMessage: string | undefined;
|
|
642
|
+
const retryable = isRetryable(status, raw);
|
|
643
|
+
try {
|
|
644
|
+
const parsed = JSON.parse(raw) as {
|
|
645
|
+
error?: {
|
|
646
|
+
code?: string;
|
|
647
|
+
type?: string;
|
|
648
|
+
message?: string;
|
|
649
|
+
plan_type?: string;
|
|
650
|
+
resets_at?: number;
|
|
651
|
+
};
|
|
652
|
+
};
|
|
653
|
+
const error = parsed?.error;
|
|
654
|
+
if (!error) return new CodexHttpError(message, retryable);
|
|
655
|
+
const code = error.code || error.type || "";
|
|
656
|
+
if (
|
|
657
|
+
status === 429 ||
|
|
658
|
+
/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code)
|
|
659
|
+
) {
|
|
660
|
+
const plan = error.plan_type ? ` (${error.plan_type.toLowerCase()} plan)` : "";
|
|
661
|
+
const resetMinutes = error.resets_at
|
|
662
|
+
? Math.max(0, Math.round((error.resets_at * 1_000 - Date.now()) / 60_000))
|
|
663
|
+
: undefined;
|
|
664
|
+
const reset = resetMinutes === undefined ? "" : ` Try again in ~${String(resetMinutes)} min.`;
|
|
665
|
+
friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${reset}`.trim();
|
|
666
|
+
}
|
|
667
|
+
message = error.message || friendlyMessage || message;
|
|
668
|
+
} catch {}
|
|
669
|
+
return new CodexHttpError(friendlyMessage || message, retryable);
|
|
670
|
+
}
|
|
671
|
+
|
|
111
672
|
function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
|
|
112
673
|
signal?: AbortSignal;
|
|
113
674
|
cleanup(): void;
|
|
@@ -140,17 +701,291 @@ function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
|
|
|
140
701
|
};
|
|
141
702
|
}
|
|
142
703
|
|
|
704
|
+
function webSocketRecoveryAttempt(
|
|
705
|
+
attempt: CodexWebSocketAttempt,
|
|
706
|
+
outcome: CodexTransportRecoveryAttempt["outcome"],
|
|
707
|
+
): CodexTransportRecoveryAttempt {
|
|
708
|
+
return {
|
|
709
|
+
transport: "websocket",
|
|
710
|
+
connection: attempt.connection,
|
|
711
|
+
contextMode: attempt.contextMode,
|
|
712
|
+
inputItems: attempt.inputItems,
|
|
713
|
+
fullInputItems: attempt.fullInputItems,
|
|
714
|
+
fullRequestBytes: attempt.fullRequestBytes,
|
|
715
|
+
wireRequestBytes: attempt.wireRequestBytes,
|
|
716
|
+
outcome,
|
|
717
|
+
...(attempt.turnStateReplayed === undefined
|
|
718
|
+
? {}
|
|
719
|
+
: { turnStateReplayed: attempt.turnStateReplayed }),
|
|
720
|
+
...(attempt.turnStateReplayedValue
|
|
721
|
+
? { turnStateReplayedValue: attempt.turnStateReplayedValue }
|
|
722
|
+
: {}),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function sseRecoveryAttempt(
|
|
727
|
+
body: JsonRecord,
|
|
728
|
+
requestBytes: number,
|
|
729
|
+
turnStateReplayedValue: string | undefined,
|
|
730
|
+
): CodexTransportRecoveryAttempt {
|
|
731
|
+
const inputItems = requestInputLength(body);
|
|
732
|
+
return {
|
|
733
|
+
transport: "sse",
|
|
734
|
+
contextMode: "full",
|
|
735
|
+
inputItems,
|
|
736
|
+
fullInputItems: inputItems,
|
|
737
|
+
fullRequestBytes: requestBytes,
|
|
738
|
+
wireRequestBytes: requestBytes,
|
|
739
|
+
outcome: "selected",
|
|
740
|
+
turnStateReplayed: turnStateReplayedValue !== undefined,
|
|
741
|
+
...(turnStateReplayedValue ? { turnStateReplayedValue } : {}),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function transportRecoveryDiagnostic(options: {
|
|
746
|
+
trigger: CodexTransportRecoveryDiagnostic["details"]["trigger"];
|
|
747
|
+
configuredTransport: string;
|
|
748
|
+
attempts: CodexTransportRecoveryAttempt[];
|
|
749
|
+
cacheIdentity: CacheIdentitySnapshot;
|
|
750
|
+
previousResponseId?: string;
|
|
751
|
+
continuationBypassReason?: CodexContinuationBypassReason;
|
|
752
|
+
historyMismatch?: CodexContinuationHistoryMismatch;
|
|
753
|
+
error?: unknown;
|
|
754
|
+
previousCacheIdentity?: CacheIdentitySnapshot;
|
|
755
|
+
cacheIdentityPreserved?: boolean;
|
|
756
|
+
accountIdentityPreserved?: boolean;
|
|
757
|
+
retryNumber?: number;
|
|
758
|
+
maxRetries?: number;
|
|
759
|
+
}): CodexTransportRecoveryDiagnostic {
|
|
760
|
+
return {
|
|
761
|
+
type: "codex_transport_recovery",
|
|
762
|
+
timestamp: Date.now(),
|
|
763
|
+
details: {
|
|
764
|
+
trigger: options.trigger,
|
|
765
|
+
configuredTransport: options.configuredTransport,
|
|
766
|
+
...(options.previousResponseId ? { previousResponseId: options.previousResponseId } : {}),
|
|
767
|
+
...(options.continuationBypassReason
|
|
768
|
+
? { continuationBypassReason: options.continuationBypassReason }
|
|
769
|
+
: {}),
|
|
770
|
+
...(options.historyMismatch ? { historyMismatch: options.historyMismatch } : {}),
|
|
771
|
+
...(options.error === undefined ? {} : { error: diagnosticError(options.error) }),
|
|
772
|
+
attempts: options.attempts,
|
|
773
|
+
cacheIdentity: options.cacheIdentity,
|
|
774
|
+
...(options.previousCacheIdentity
|
|
775
|
+
? { previousCacheIdentity: options.previousCacheIdentity }
|
|
776
|
+
: {}),
|
|
777
|
+
cacheAffinityEnabled: cacheAffinityEnabled(options.cacheIdentity),
|
|
778
|
+
...(options.cacheIdentityPreserved !== undefined
|
|
779
|
+
? { cacheIdentityPreserved: options.cacheIdentityPreserved }
|
|
780
|
+
: options.previousCacheIdentity
|
|
781
|
+
? {
|
|
782
|
+
cacheIdentityPreserved: sameCacheIdentity(
|
|
783
|
+
options.previousCacheIdentity,
|
|
784
|
+
options.cacheIdentity,
|
|
785
|
+
),
|
|
786
|
+
}
|
|
787
|
+
: {}),
|
|
788
|
+
...(options.accountIdentityPreserved !== undefined
|
|
789
|
+
? { accountIdentityPreserved: options.accountIdentityPreserved }
|
|
790
|
+
: options.previousCacheIdentity
|
|
791
|
+
? {
|
|
792
|
+
accountIdentityPreserved:
|
|
793
|
+
options.previousCacheIdentity.accountId === options.cacheIdentity.accountId,
|
|
794
|
+
}
|
|
795
|
+
: {}),
|
|
796
|
+
promptKeyAndHeaderAligned: promptKeyAndHeaderAligned(options.cacheIdentity),
|
|
797
|
+
...(options.retryNumber === undefined ? {} : { retryNumber: options.retryNumber }),
|
|
798
|
+
...(options.maxRetries === undefined ? {} : { maxRetries: options.maxRetries }),
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function cacheUsageDiagnostic(event: JsonRecord): CodexCacheUsageDiagnostic | undefined {
|
|
804
|
+
if (!isTerminalEvent(event) || !isObject(event.response) || !isObject(event.response.usage)) {
|
|
805
|
+
return undefined;
|
|
806
|
+
}
|
|
807
|
+
const usage = event.response.usage;
|
|
808
|
+
const details = isObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
|
|
809
|
+
return {
|
|
810
|
+
inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : 0,
|
|
811
|
+
cachedTokens: typeof details?.cached_tokens === "number" ? details.cached_tokens : 0,
|
|
812
|
+
cacheWriteTokens:
|
|
813
|
+
typeof details?.cache_write_tokens === "number" ? details.cache_write_tokens : 0,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function transportRequestDiagnostic(options: {
|
|
818
|
+
requestOptions: CodexTransportOptions;
|
|
819
|
+
body: JsonRecord;
|
|
820
|
+
configuredTransport: string;
|
|
821
|
+
selectedTransport: "websocket" | "sse";
|
|
822
|
+
attempt:
|
|
823
|
+
| CodexWebSocketAttempt
|
|
824
|
+
| {
|
|
825
|
+
contextMode: "full";
|
|
826
|
+
inputItems: number;
|
|
827
|
+
fullInputItems: number;
|
|
828
|
+
fullRequestBytes: number;
|
|
829
|
+
wireRequestBytes: number;
|
|
830
|
+
turnStateReplayed: boolean;
|
|
831
|
+
turnStateReplayedValue?: string;
|
|
832
|
+
};
|
|
833
|
+
cacheIdentity: CacheIdentitySnapshot;
|
|
834
|
+
turnStateAvailableAtStart: boolean;
|
|
835
|
+
turnStateRevisionAtStart: number;
|
|
836
|
+
turnStateValueAtStart?: string;
|
|
837
|
+
responseId?: string;
|
|
838
|
+
usage?: CodexCacheUsageDiagnostic;
|
|
839
|
+
}): CodexTransportRequestDiagnostic {
|
|
840
|
+
const metadata = isObject(options.body.client_metadata)
|
|
841
|
+
? options.body.client_metadata
|
|
842
|
+
: undefined;
|
|
843
|
+
const turnStateReceived =
|
|
844
|
+
(options.requestOptions.turnState?.revision ?? options.turnStateRevisionAtStart) >
|
|
845
|
+
options.turnStateRevisionAtStart;
|
|
846
|
+
const currentTurnStateValue = options.requestOptions.turnState?.replayValue();
|
|
847
|
+
const routingHint = codexRoutingHint(options.body);
|
|
848
|
+
return {
|
|
849
|
+
type: "codex_transport_request",
|
|
850
|
+
timestamp: Date.now(),
|
|
851
|
+
details: {
|
|
852
|
+
requestKind: options.requestOptions.requestKind ?? "turn",
|
|
853
|
+
configuredTransport: options.configuredTransport,
|
|
854
|
+
selectedTransport: options.selectedTransport,
|
|
855
|
+
...("connection" in options.attempt ? { connection: options.attempt.connection } : {}),
|
|
856
|
+
contextMode: options.attempt.contextMode,
|
|
857
|
+
inputItems: options.attempt.inputItems,
|
|
858
|
+
fullInputItems: options.attempt.fullInputItems,
|
|
859
|
+
fullRequestBytes: options.attempt.fullRequestBytes,
|
|
860
|
+
wireRequestBytes: options.attempt.wireRequestBytes,
|
|
861
|
+
cacheAffinityEnabled: cacheAffinityEnabled(options.cacheIdentity),
|
|
862
|
+
promptKeyAndHeaderAligned: promptKeyAndHeaderAligned(options.cacheIdentity),
|
|
863
|
+
cacheIdentity: options.cacheIdentity,
|
|
864
|
+
...(options.requestOptions.sessionId ? { sessionId: options.requestOptions.sessionId } : {}),
|
|
865
|
+
...(typeof options.body.prompt_cache_key === "string"
|
|
866
|
+
? { promptCacheKey: options.body.prompt_cache_key }
|
|
867
|
+
: {}),
|
|
868
|
+
accountId: options.cacheIdentity.accountId,
|
|
869
|
+
...(typeof metadata?.["session_id"] === "string"
|
|
870
|
+
? { clientSessionId: metadata["session_id"] }
|
|
871
|
+
: {}),
|
|
872
|
+
...(typeof metadata?.["thread_id"] === "string" ? { threadId: metadata["thread_id"] } : {}),
|
|
873
|
+
...(typeof metadata?.["turn_id"] === "string" ? { turnId: metadata["turn_id"] } : {}),
|
|
874
|
+
...(typeof metadata?.[CODEX_INSTALLATION_ID_METADATA_KEY] === "string"
|
|
875
|
+
? { installationId: metadata[CODEX_INSTALLATION_ID_METADATA_KEY] }
|
|
876
|
+
: {}),
|
|
877
|
+
...(typeof metadata?.[CODEX_WINDOW_ID_HEADER] === "string"
|
|
878
|
+
? { windowId: metadata[CODEX_WINDOW_ID_HEADER] }
|
|
879
|
+
: {}),
|
|
880
|
+
...(routingHint ? { routingHint } : {}),
|
|
881
|
+
...(typeof metadata?.["x-codex-turn-metadata"] === "string"
|
|
882
|
+
? { turnMetadata: metadata["x-codex-turn-metadata"] }
|
|
883
|
+
: {}),
|
|
884
|
+
...(options.responseId ? { responseId: options.responseId } : {}),
|
|
885
|
+
...("previousResponseId" in options.attempt && options.attempt.previousResponseId
|
|
886
|
+
? { previousResponseId: options.attempt.previousResponseId }
|
|
887
|
+
: {}),
|
|
888
|
+
turnStateAvailableAtStart: options.turnStateAvailableAtStart,
|
|
889
|
+
turnStateReplayed: options.attempt.turnStateReplayed ?? false,
|
|
890
|
+
turnStateReceived,
|
|
891
|
+
...(options.turnStateValueAtStart ? { turnStateAtStart: options.turnStateValueAtStart } : {}),
|
|
892
|
+
...(options.attempt.turnStateReplayedValue
|
|
893
|
+
? { turnStateReplayedValue: options.attempt.turnStateReplayedValue }
|
|
894
|
+
: {}),
|
|
895
|
+
...(turnStateReceived && currentTurnStateValue
|
|
896
|
+
? { turnStateReceivedValue: currentTurnStateValue }
|
|
897
|
+
: {}),
|
|
898
|
+
...(options.usage ? { usage: options.usage } : {}),
|
|
899
|
+
...(options.requestOptions.cacheDiagnostics
|
|
900
|
+
? { cache: options.requestOptions.cacheDiagnostics }
|
|
901
|
+
: {}),
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
function shouldReportRequestDiagnostic(options: CodexTransportOptions): boolean {
|
|
907
|
+
return options.cacheDiagnostics !== undefined || options.turnState !== undefined;
|
|
908
|
+
}
|
|
909
|
+
|
|
143
910
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
144
911
|
return Object.fromEntries(headers.entries());
|
|
145
912
|
}
|
|
146
913
|
|
|
914
|
+
function applyTurnStateHeader(
|
|
915
|
+
headers: Headers,
|
|
916
|
+
turnState: CodexTurnState | undefined,
|
|
917
|
+
): string | undefined {
|
|
918
|
+
if (!turnState) return undefined;
|
|
919
|
+
const value = turnState?.replayValue();
|
|
920
|
+
if (!value) {
|
|
921
|
+
headers.delete(CODEX_TURN_STATE_HEADER);
|
|
922
|
+
return undefined;
|
|
923
|
+
}
|
|
924
|
+
headers.set(CODEX_TURN_STATE_HEADER, value);
|
|
925
|
+
return value;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function codexRoutingHint(body: JsonRecord): string | undefined {
|
|
929
|
+
if (typeof body.model !== "string" || body.model.length === 0) return undefined;
|
|
930
|
+
const tier =
|
|
931
|
+
typeof body.service_tier === "string" && body.service_tier.length > 0
|
|
932
|
+
? `;tier=${body.service_tier}`
|
|
933
|
+
: "";
|
|
934
|
+
return `model=${body.model}${tier}`;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function applyCodexRoutingHint(headers: Headers, body: JsonRecord): void {
|
|
938
|
+
const hint = codexRoutingHint(body);
|
|
939
|
+
if (!hint) {
|
|
940
|
+
headers.delete(CODEX_ROUTING_HINT_HEADER);
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
headers.set(CODEX_ROUTING_HINT_HEADER, hint);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function captureTurnStateHeader(headers: Headers, turnState: CodexTurnState | undefined): boolean {
|
|
947
|
+
return turnState?.capture(headers.get(CODEX_TURN_STATE_HEADER) ?? undefined) ?? false;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function captureTurnStateEvent(event: JsonRecord, turnState: CodexTurnState | undefined): boolean {
|
|
951
|
+
if (event.type !== "response.metadata" || !isObject(event["headers"])) return false;
|
|
952
|
+
for (const [name, value] of Object.entries(event["headers"])) {
|
|
953
|
+
if (name.toLowerCase() === CODEX_TURN_STATE_HEADER && typeof value === "string") {
|
|
954
|
+
return turnState?.capture(value) ?? false;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return false;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function withTurnStateMetadata(
|
|
961
|
+
body: JsonRecord,
|
|
962
|
+
turnState: CodexTurnState | undefined,
|
|
963
|
+
): { body: JsonRecord; replayedValue?: string } {
|
|
964
|
+
const value = turnState?.replayValue();
|
|
965
|
+
if (!value) return { body };
|
|
966
|
+
return {
|
|
967
|
+
body: {
|
|
968
|
+
...body,
|
|
969
|
+
client_metadata: {
|
|
970
|
+
...(isObject(body.client_metadata) ? body.client_metadata : {}),
|
|
971
|
+
[CODEX_TURN_STATE_HEADER]: value,
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
replayedValue: value,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
|
|
147
978
|
function extractAccountId(token: string): string {
|
|
148
979
|
try {
|
|
149
980
|
const parts = token.split(".");
|
|
150
981
|
if (parts.length !== 3) throw new Error("Invalid token");
|
|
151
982
|
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonRecord;
|
|
152
983
|
const authentication = payload["https://api.openai.com/auth"];
|
|
153
|
-
if (
|
|
984
|
+
if (
|
|
985
|
+
!isObject(authentication) ||
|
|
986
|
+
typeof authentication["chatgpt_account_id"] !== "string" ||
|
|
987
|
+
authentication["chatgpt_account_id"].length === 0
|
|
988
|
+
) {
|
|
154
989
|
throw new Error("No account ID");
|
|
155
990
|
}
|
|
156
991
|
return authentication["chatgpt_account_id"];
|
|
@@ -159,6 +994,11 @@ function extractAccountId(token: string): string {
|
|
|
159
994
|
}
|
|
160
995
|
}
|
|
161
996
|
|
|
997
|
+
export function validateCodexAuthentication(model: Model<any>, apiKey: string | undefined): string {
|
|
998
|
+
if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
|
|
999
|
+
return extractAccountId(apiKey);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
162
1002
|
function resolveCodexUrl(baseUrl?: string): string {
|
|
163
1003
|
const raw = baseUrl?.trim() || DEFAULT_CODEX_BASE_URL;
|
|
164
1004
|
const normalized = raw.replace(/\/+$/, "");
|
|
@@ -208,15 +1048,25 @@ function sseHeaders(
|
|
|
208
1048
|
accountId: string,
|
|
209
1049
|
token: string,
|
|
210
1050
|
sessionId: string | undefined,
|
|
1051
|
+
body: JsonRecord,
|
|
211
1052
|
): Headers {
|
|
212
1053
|
const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
|
|
213
|
-
headers.
|
|
1054
|
+
headers.delete("OpenAI-Beta");
|
|
1055
|
+
headers.delete("openai-beta");
|
|
214
1056
|
headers.set("accept", "text/event-stream");
|
|
215
1057
|
headers.set("content-type", "application/json");
|
|
216
1058
|
if (sessionId) {
|
|
217
1059
|
headers.set("session-id", sessionId);
|
|
218
|
-
headers.set("x-client-request-id", sessionId);
|
|
219
1060
|
}
|
|
1061
|
+
applyCodexMetadataHeaders(headers, body);
|
|
1062
|
+
if (sessionId && !headers.has("thread-id")) headers.set("thread-id", sessionId);
|
|
1063
|
+
const threadId = headers.get("thread-id");
|
|
1064
|
+
if (threadId) {
|
|
1065
|
+
// Official Responses HTTP uses the thread identity as x-client-request-id.
|
|
1066
|
+
headers.set("x-client-request-id", threadId);
|
|
1067
|
+
}
|
|
1068
|
+
applyResponsesLiteHeaders(headers, body);
|
|
1069
|
+
applyCodexRoutingHint(headers, body);
|
|
220
1070
|
return headers;
|
|
221
1071
|
}
|
|
222
1072
|
|
|
@@ -242,6 +1092,7 @@ function websocketHeaders(
|
|
|
242
1092
|
accountId: string,
|
|
243
1093
|
token: string,
|
|
244
1094
|
requestId: string,
|
|
1095
|
+
body: JsonRecord,
|
|
245
1096
|
): Headers {
|
|
246
1097
|
const headers = baseHeaders(modelHeaders, additionalHeaders, accountId, token);
|
|
247
1098
|
headers.delete("accept");
|
|
@@ -251,9 +1102,65 @@ function websocketHeaders(
|
|
|
251
1102
|
headers.set("OpenAI-Beta", OPENAI_BETA_RESPONSES_WEBSOCKETS);
|
|
252
1103
|
headers.set("x-client-request-id", requestId);
|
|
253
1104
|
headers.set("session-id", requestId);
|
|
1105
|
+
applyCodexMetadataHeaders(headers, body);
|
|
1106
|
+
if (!headers.has("thread-id")) headers.set("thread-id", requestId);
|
|
1107
|
+
const threadId = headers.get("thread-id");
|
|
1108
|
+
if (threadId) headers.set("x-client-request-id", threadId);
|
|
1109
|
+
applyCodexRoutingHint(headers, body);
|
|
254
1110
|
return headers;
|
|
255
1111
|
}
|
|
256
1112
|
|
|
1113
|
+
function cacheIdentitySnapshot(
|
|
1114
|
+
body: JsonRecord,
|
|
1115
|
+
headers: Headers,
|
|
1116
|
+
accountId: string,
|
|
1117
|
+
): CacheIdentitySnapshot {
|
|
1118
|
+
const metadata = isObject(body.client_metadata) ? body.client_metadata : undefined;
|
|
1119
|
+
return {
|
|
1120
|
+
promptCacheKey: typeof body.prompt_cache_key === "string" ? body.prompt_cache_key : undefined,
|
|
1121
|
+
sessionHeader: headers.get("session-id"),
|
|
1122
|
+
threadHeader: headers.get("thread-id"),
|
|
1123
|
+
clientRequestHeader: headers.get("x-client-request-id"),
|
|
1124
|
+
...(typeof metadata?.[CODEX_INSTALLATION_ID_METADATA_KEY] === "string"
|
|
1125
|
+
? { installationId: metadata[CODEX_INSTALLATION_ID_METADATA_KEY] }
|
|
1126
|
+
: {}),
|
|
1127
|
+
...(typeof metadata?.[CODEX_WINDOW_ID_HEADER] === "string"
|
|
1128
|
+
? { windowId: metadata[CODEX_WINDOW_ID_HEADER] }
|
|
1129
|
+
: {}),
|
|
1130
|
+
routingHint: headers.get(CODEX_ROUTING_HINT_HEADER),
|
|
1131
|
+
accountId,
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function cacheAffinityEnabled(identity: CacheIdentitySnapshot): boolean {
|
|
1136
|
+
return Boolean(identity.promptCacheKey || identity.sessionHeader || identity.clientRequestHeader);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function promptKeyAndHeaderAligned(identity: CacheIdentitySnapshot): boolean {
|
|
1140
|
+
return Boolean(
|
|
1141
|
+
identity.promptCacheKey &&
|
|
1142
|
+
identity.promptCacheKey === identity.sessionHeader &&
|
|
1143
|
+
identity.promptCacheKey === identity.threadHeader &&
|
|
1144
|
+
identity.promptCacheKey === identity.clientRequestHeader,
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
function sameCacheIdentity(a: CacheIdentitySnapshot, b: CacheIdentitySnapshot): boolean {
|
|
1149
|
+
return (
|
|
1150
|
+
a.promptCacheKey === b.promptCacheKey &&
|
|
1151
|
+
a.sessionHeader === b.sessionHeader &&
|
|
1152
|
+
a.threadHeader === b.threadHeader &&
|
|
1153
|
+
a.clientRequestHeader === b.clientRequestHeader &&
|
|
1154
|
+
a.installationId === b.installationId &&
|
|
1155
|
+
a.windowId === b.windowId &&
|
|
1156
|
+
a.routingHint === b.routingHint
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
function serializedBytes(value: string): number {
|
|
1161
|
+
return new TextEncoder().encode(value).byteLength;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
257
1164
|
function compressBody(body: string): Uint8Array | undefined {
|
|
258
1165
|
const zlib = nodeZlib();
|
|
259
1166
|
if (!zlib || typeof zlib.zstdCompressSync !== "function") return undefined;
|
|
@@ -268,13 +1175,13 @@ function compressBody(body: string): Uint8Array | undefined {
|
|
|
268
1175
|
}
|
|
269
1176
|
|
|
270
1177
|
function isRetryable(status: number, text: string): boolean {
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
) {
|
|
275
|
-
return false;
|
|
1178
|
+
if (status === 429 && isTerminalRateLimitError(text)) return false;
|
|
1179
|
+
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
|
|
1180
|
+
return true;
|
|
276
1181
|
}
|
|
277
|
-
return
|
|
1182
|
+
return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(
|
|
1183
|
+
text,
|
|
1184
|
+
);
|
|
278
1185
|
}
|
|
279
1186
|
|
|
280
1187
|
async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerator<JsonRecord> {
|
|
@@ -289,6 +1196,7 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
289
1196
|
while (true) {
|
|
290
1197
|
if (signal?.aborted) throw new Error("Request was aborted");
|
|
291
1198
|
const { done, value } = await reader.read();
|
|
1199
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
292
1200
|
if (done) break;
|
|
293
1201
|
buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
|
|
294
1202
|
let boundary = buffer.indexOf("\n\n");
|
|
@@ -302,7 +1210,16 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
302
1210
|
.join("\n")
|
|
303
1211
|
.trim();
|
|
304
1212
|
if (data && data !== "[DONE]") {
|
|
305
|
-
|
|
1213
|
+
let parsed: unknown;
|
|
1214
|
+
try {
|
|
1215
|
+
parsed = JSON.parse(data) as unknown;
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
throw new CodexProtocolError(
|
|
1218
|
+
`Invalid Codex SSE JSON: ${thrownMessage(error)}`,
|
|
1219
|
+
data,
|
|
1220
|
+
error,
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
306
1223
|
if (!isObject(parsed)) throw new Error("Invalid Codex SSE event");
|
|
307
1224
|
yield parsed;
|
|
308
1225
|
}
|
|
@@ -312,7 +1229,9 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
312
1229
|
} finally {
|
|
313
1230
|
signal?.removeEventListener("abort", onAbort);
|
|
314
1231
|
await reader.cancel().catch(() => {});
|
|
315
|
-
|
|
1232
|
+
try {
|
|
1233
|
+
reader.releaseLock();
|
|
1234
|
+
} catch {}
|
|
316
1235
|
}
|
|
317
1236
|
}
|
|
318
1237
|
|
|
@@ -331,15 +1250,6 @@ function socketReusable(socket: WebSocketLike): boolean {
|
|
|
331
1250
|
return socket.readyState === undefined || socket.readyState === 1;
|
|
332
1251
|
}
|
|
333
1252
|
|
|
334
|
-
function scheduleSocketExpiry(sessionId: string, entry: CachedWebSocket): void {
|
|
335
|
-
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
336
|
-
entry.idleTimer = setTimeout(() => {
|
|
337
|
-
if (entry.busy) return;
|
|
338
|
-
closeSocket(entry.socket, "idle_timeout");
|
|
339
|
-
websocketSessions.delete(sessionId);
|
|
340
|
-
}, SESSION_WEBSOCKET_CACHE_TTL_MS);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
1253
|
async function connectWebSocket(
|
|
344
1254
|
url: string,
|
|
345
1255
|
headers: Headers,
|
|
@@ -347,7 +1257,9 @@ async function connectWebSocket(
|
|
|
347
1257
|
timeoutMs: number,
|
|
348
1258
|
): Promise<WebSocketLike> {
|
|
349
1259
|
const WebSocketClass = websocketConstructor();
|
|
350
|
-
if (!WebSocketClass)
|
|
1260
|
+
if (!WebSocketClass) {
|
|
1261
|
+
throw new Error("WebSocket transport is not available in this runtime");
|
|
1262
|
+
}
|
|
351
1263
|
const requestHeaders = headersToRecord(headers);
|
|
352
1264
|
delete requestHeaders["OpenAI-Beta"];
|
|
353
1265
|
|
|
@@ -363,11 +1275,11 @@ async function connectWebSocket(
|
|
|
363
1275
|
socket.removeEventListener("close", onClose);
|
|
364
1276
|
signal?.removeEventListener("abort", onAbort);
|
|
365
1277
|
};
|
|
366
|
-
const fail = (error: Error) => {
|
|
1278
|
+
const fail = (error: Error, closeReason: string) => {
|
|
367
1279
|
if (settled) return;
|
|
368
1280
|
settled = true;
|
|
369
1281
|
cleanup();
|
|
370
|
-
closeSocket(socket,
|
|
1282
|
+
closeSocket(socket, closeReason);
|
|
371
1283
|
reject(error);
|
|
372
1284
|
};
|
|
373
1285
|
const onOpen = () => {
|
|
@@ -376,25 +1288,27 @@ async function connectWebSocket(
|
|
|
376
1288
|
cleanup();
|
|
377
1289
|
resolve(socket);
|
|
378
1290
|
};
|
|
379
|
-
const onError = (event: unknown) => fail(
|
|
1291
|
+
const onError = (event: unknown) => fail(extractWebSocketError(event), "connect_failure");
|
|
380
1292
|
const onClose = (event: unknown) =>
|
|
381
|
-
fail(
|
|
382
|
-
const onAbort = () => fail(new Error("Request was aborted"));
|
|
1293
|
+
fail(extractWebSocketCloseError(event, "WebSocket closed during connect"), "connect_failure");
|
|
1294
|
+
const onAbort = () => fail(new Error("Request was aborted"), "aborted");
|
|
383
1295
|
|
|
384
1296
|
try {
|
|
385
1297
|
socket = new WebSocketClass(url, { headers: requestHeaders });
|
|
386
1298
|
} catch (error) {
|
|
387
|
-
reject(error);
|
|
1299
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
388
1300
|
return;
|
|
389
1301
|
}
|
|
390
1302
|
socket.addEventListener("open", onOpen);
|
|
391
1303
|
socket.addEventListener("error", onError);
|
|
392
1304
|
socket.addEventListener("close", onClose);
|
|
393
1305
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
1306
|
+
if (timeoutMs > 0) {
|
|
1307
|
+
timer = setTimeout(
|
|
1308
|
+
() => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`), "connect_timeout"),
|
|
1309
|
+
timeoutMs,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
398
1312
|
if (signal?.aborted) onAbort();
|
|
399
1313
|
});
|
|
400
1314
|
}
|
|
@@ -403,97 +1317,211 @@ async function acquireWebSocket(
|
|
|
403
1317
|
url: string,
|
|
404
1318
|
headers: Headers,
|
|
405
1319
|
sessionId: string | undefined,
|
|
1320
|
+
accountId: string,
|
|
406
1321
|
signal: AbortSignal | undefined,
|
|
407
1322
|
timeoutMs: number,
|
|
408
|
-
): Promise<{
|
|
1323
|
+
): Promise<{
|
|
1324
|
+
socket: WebSocketLike;
|
|
1325
|
+
entry?: CachedWebSocket;
|
|
1326
|
+
reused: boolean;
|
|
1327
|
+
release(keep: boolean): void;
|
|
1328
|
+
}> {
|
|
1329
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
409
1330
|
if (sessionId) {
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
delete cached.idleTimer;
|
|
414
|
-
}
|
|
415
|
-
const expired = cached && Date.now() - cached.createdAt >= SESSION_WEBSOCKET_MAX_AGE_MS;
|
|
416
|
-
if (cached && !cached.busy && !expired && socketReusable(cached.socket)) {
|
|
1331
|
+
let accountEntries = websocketSessions.get(sessionId);
|
|
1332
|
+
const cached = accountEntries?.get(accountId);
|
|
1333
|
+
if (cached && !cached.busy && socketReusable(cached.socket)) {
|
|
417
1334
|
cached.busy = true;
|
|
418
1335
|
return {
|
|
419
1336
|
socket: cached.socket,
|
|
420
1337
|
entry: cached,
|
|
1338
|
+
reused: true,
|
|
421
1339
|
release(keep) {
|
|
422
1340
|
if (!keep || !socketReusable(cached.socket)) {
|
|
423
1341
|
closeSocket(cached.socket);
|
|
424
|
-
websocketSessions.
|
|
1342
|
+
const currentEntries = websocketSessions.get(sessionId);
|
|
1343
|
+
if (currentEntries?.get(accountId) === cached) currentEntries.delete(accountId);
|
|
1344
|
+
if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
425
1345
|
return;
|
|
426
1346
|
}
|
|
427
1347
|
cached.busy = false;
|
|
428
|
-
scheduleSocketExpiry(sessionId, cached);
|
|
429
1348
|
},
|
|
430
1349
|
};
|
|
431
1350
|
}
|
|
432
1351
|
if (cached && !cached.busy) {
|
|
433
|
-
closeSocket(cached.socket);
|
|
434
|
-
|
|
1352
|
+
closeSocket(cached.socket, "done");
|
|
1353
|
+
accountEntries?.delete(accountId);
|
|
1354
|
+
if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
1355
|
+
}
|
|
1356
|
+
if (cached?.busy) {
|
|
1357
|
+
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
|
|
1358
|
+
return { socket, reused: false, release: () => closeSocket(socket) };
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
|
|
1362
|
+
const entry: CachedWebSocket = { socket, busy: true };
|
|
1363
|
+
accountEntries = websocketSessions.get(sessionId);
|
|
1364
|
+
if (!accountEntries) {
|
|
1365
|
+
accountEntries = new Map();
|
|
1366
|
+
websocketSessions.set(sessionId, accountEntries);
|
|
435
1367
|
}
|
|
1368
|
+
accountEntries.set(accountId, entry);
|
|
1369
|
+
return {
|
|
1370
|
+
socket,
|
|
1371
|
+
entry,
|
|
1372
|
+
reused: false,
|
|
1373
|
+
release(keep) {
|
|
1374
|
+
if (!keep || !socketReusable(socket)) {
|
|
1375
|
+
closeSocket(socket);
|
|
1376
|
+
const currentEntries = websocketSessions.get(sessionId);
|
|
1377
|
+
if (currentEntries?.get(accountId) === entry) currentEntries.delete(accountId);
|
|
1378
|
+
if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
entry.busy = false;
|
|
1382
|
+
},
|
|
1383
|
+
};
|
|
436
1384
|
}
|
|
437
1385
|
|
|
438
1386
|
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
|
|
439
|
-
|
|
440
|
-
return { socket, release: () => closeSocket(socket) };
|
|
441
|
-
}
|
|
442
|
-
const entry: CachedWebSocket = { socket, busy: true, createdAt: Date.now() };
|
|
443
|
-
websocketSessions.set(sessionId, entry);
|
|
444
|
-
return {
|
|
445
|
-
socket,
|
|
446
|
-
entry,
|
|
447
|
-
release(keep) {
|
|
448
|
-
if (!keep || !socketReusable(socket)) {
|
|
449
|
-
closeSocket(socket);
|
|
450
|
-
websocketSessions.delete(sessionId);
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
entry.busy = false;
|
|
454
|
-
scheduleSocketExpiry(sessionId, entry);
|
|
455
|
-
},
|
|
456
|
-
};
|
|
1387
|
+
return { socket, reused: false, release: () => closeSocket(socket) };
|
|
457
1388
|
}
|
|
458
1389
|
|
|
459
1390
|
function requestWithoutHistory(body: JsonRecord): JsonRecord {
|
|
460
|
-
const result =
|
|
1391
|
+
const result = { ...body };
|
|
461
1392
|
delete result.input;
|
|
462
1393
|
delete result.previous_response_id;
|
|
1394
|
+
delete result.client_metadata;
|
|
1395
|
+
// This controls response delivery only, not the model context retained by
|
|
1396
|
+
// previous_response_id.
|
|
1397
|
+
delete result["stream_options"];
|
|
463
1398
|
return result;
|
|
464
1399
|
}
|
|
465
1400
|
|
|
466
|
-
function
|
|
1401
|
+
function responseItemsMatch(previous: unknown, current: unknown): boolean {
|
|
1402
|
+
if (!isObject(previous) || !isObject(current)) {
|
|
1403
|
+
return stableResponsesJson(previous) === stableResponsesJson(current);
|
|
1404
|
+
}
|
|
1405
|
+
const previousComparable = structuredClone(previous);
|
|
1406
|
+
const currentComparable = structuredClone(current);
|
|
1407
|
+
delete previousComparable["internal_chat_message_metadata_passthrough"];
|
|
1408
|
+
delete currentComparable["internal_chat_message_metadata_passthrough"];
|
|
1409
|
+
return stableResponsesJson(previousComparable) === stableResponsesJson(currentComparable);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function jsonWireRequestBody(body: JsonRecord): JsonRecord {
|
|
1413
|
+
const snapshot = JSON.parse(JSON.stringify(body)) as unknown;
|
|
1414
|
+
if (!isObject(snapshot)) {
|
|
1415
|
+
throw new Error("Codex request body must serialize to a JSON object");
|
|
1416
|
+
}
|
|
1417
|
+
return snapshot;
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
type CachedRequestDecision = {
|
|
1421
|
+
body: JsonRecord;
|
|
1422
|
+
contextMode: "full" | "delta";
|
|
1423
|
+
previousResponseId?: string;
|
|
1424
|
+
bypassReason?: CodexContinuationBypassReason;
|
|
1425
|
+
historyMismatch?: CodexContinuationHistoryMismatch;
|
|
1426
|
+
cacheIdentityPreserved?: boolean;
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
type CodexWebSocketAttempt = {
|
|
1430
|
+
connection: "new" | "reused";
|
|
1431
|
+
contextMode: "full" | "delta";
|
|
1432
|
+
inputItems: number;
|
|
1433
|
+
fullInputItems: number;
|
|
1434
|
+
fullRequestBytes: number;
|
|
1435
|
+
wireRequestBytes: number;
|
|
1436
|
+
previousResponseId?: string;
|
|
1437
|
+
bypassReason?: CodexContinuationBypassReason;
|
|
1438
|
+
historyMismatch?: CodexContinuationHistoryMismatch;
|
|
1439
|
+
cacheIdentityPreserved?: boolean;
|
|
1440
|
+
turnStateReplayed?: boolean;
|
|
1441
|
+
turnStateReplayedValue?: string;
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): CachedRequestDecision {
|
|
467
1445
|
const continuation = entry.continuation;
|
|
468
|
-
if (!continuation) return body;
|
|
1446
|
+
if (!continuation) return { body, contextMode: "full" };
|
|
1447
|
+
const previousResponseId = continuation.lastResponseId;
|
|
1448
|
+
const cacheIdentityPreserved =
|
|
1449
|
+
continuation.lastRequestBody.prompt_cache_key === body.prompt_cache_key;
|
|
469
1450
|
if (
|
|
470
1451
|
stableResponsesJson(requestWithoutHistory(body)) !==
|
|
471
1452
|
stableResponsesJson(requestWithoutHistory(continuation.lastRequestBody))
|
|
472
1453
|
) {
|
|
473
1454
|
delete entry.continuation;
|
|
474
|
-
return
|
|
1455
|
+
return {
|
|
1456
|
+
body,
|
|
1457
|
+
contextMode: "full",
|
|
1458
|
+
previousResponseId,
|
|
1459
|
+
bypassReason: "request_template_changed",
|
|
1460
|
+
cacheIdentityPreserved,
|
|
1461
|
+
};
|
|
475
1462
|
}
|
|
476
1463
|
|
|
477
|
-
const currentInput =
|
|
478
|
-
const previousInput =
|
|
479
|
-
|
|
480
|
-
|
|
1464
|
+
const currentInput = body.input ?? [];
|
|
1465
|
+
const previousInput = continuation.lastRequestBody.input ?? [];
|
|
1466
|
+
if (!Array.isArray(currentInput) || !Array.isArray(previousInput)) {
|
|
1467
|
+
delete entry.continuation;
|
|
1468
|
+
return {
|
|
1469
|
+
body,
|
|
1470
|
+
contextMode: "full",
|
|
1471
|
+
previousResponseId,
|
|
1472
|
+
bypassReason: "non_array_input",
|
|
1473
|
+
cacheIdentityPreserved,
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
481
1476
|
const baseline = [...previousInput, ...continuation.lastResponseItems];
|
|
482
|
-
|
|
483
|
-
currentInput.length
|
|
484
|
-
|
|
485
|
-
) {
|
|
1477
|
+
const mismatchIndex = baseline.findIndex(
|
|
1478
|
+
(item, index) => index >= currentInput.length || !responseItemsMatch(item, currentInput[index]),
|
|
1479
|
+
);
|
|
1480
|
+
if (mismatchIndex >= 0) {
|
|
486
1481
|
delete entry.continuation;
|
|
487
|
-
return
|
|
1482
|
+
return {
|
|
1483
|
+
body,
|
|
1484
|
+
contextMode: "full",
|
|
1485
|
+
previousResponseId,
|
|
1486
|
+
bypassReason: "history_prefix_changed",
|
|
1487
|
+
historyMismatch: {
|
|
1488
|
+
index: mismatchIndex,
|
|
1489
|
+
baselineInputItems: baseline.length,
|
|
1490
|
+
currentInputItems: currentInput.length,
|
|
1491
|
+
baselineItem: structuredClone(baseline[mismatchIndex]),
|
|
1492
|
+
...(mismatchIndex < currentInput.length
|
|
1493
|
+
? { currentItem: structuredClone(currentInput[mismatchIndex]) }
|
|
1494
|
+
: {}),
|
|
1495
|
+
},
|
|
1496
|
+
cacheIdentityPreserved,
|
|
1497
|
+
};
|
|
488
1498
|
}
|
|
489
1499
|
|
|
490
1500
|
return {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
1501
|
+
body: {
|
|
1502
|
+
...body,
|
|
1503
|
+
previous_response_id: previousResponseId,
|
|
1504
|
+
input: currentInput.slice(baseline.length),
|
|
1505
|
+
},
|
|
1506
|
+
contextMode: "delta",
|
|
1507
|
+
previousResponseId,
|
|
1508
|
+
cacheIdentityPreserved,
|
|
494
1509
|
};
|
|
495
1510
|
}
|
|
496
1511
|
|
|
1512
|
+
function requestInputLength(body: JsonRecord): number {
|
|
1513
|
+
return typeof body.input === "string" || Array.isArray(body.input) ? body.input.length : 0;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function webSocketEventStartsVisibleOutput(event: JsonRecord): boolean {
|
|
1517
|
+
return (
|
|
1518
|
+
event.type !== "response.created" &&
|
|
1519
|
+
event.type !== "response.queued" &&
|
|
1520
|
+
event.type !== "response.in_progress" &&
|
|
1521
|
+
event.type !== "response.metadata"
|
|
1522
|
+
);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
497
1525
|
async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
|
|
498
1526
|
if (typeof data === "string") return data;
|
|
499
1527
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
|
@@ -525,9 +1553,10 @@ async function* parseWebSocket(
|
|
|
525
1553
|
};
|
|
526
1554
|
const onMessage = (event: unknown) => {
|
|
527
1555
|
void (async () => {
|
|
1556
|
+
let text: string | undefined;
|
|
528
1557
|
try {
|
|
529
1558
|
if (!isObject(event)) return;
|
|
530
|
-
|
|
1559
|
+
text = await decodeWebSocketData(event["data"]);
|
|
531
1560
|
if (!text) return;
|
|
532
1561
|
const parsed = JSON.parse(text) as unknown;
|
|
533
1562
|
if (!isObject(parsed)) throw new Error("Invalid WebSocket event");
|
|
@@ -535,7 +1564,8 @@ async function* parseWebSocket(
|
|
|
535
1564
|
if (
|
|
536
1565
|
type === "response.completed" ||
|
|
537
1566
|
type === "response.done" ||
|
|
538
|
-
type === "response.incomplete"
|
|
1567
|
+
type === "response.incomplete" ||
|
|
1568
|
+
type === "response.failed"
|
|
539
1569
|
) {
|
|
540
1570
|
terminal = true;
|
|
541
1571
|
done = true;
|
|
@@ -543,19 +1573,23 @@ async function* parseWebSocket(
|
|
|
543
1573
|
queue.push(parsed);
|
|
544
1574
|
notify();
|
|
545
1575
|
} catch (error) {
|
|
546
|
-
failure =
|
|
1576
|
+
failure = new CodexProtocolError(
|
|
1577
|
+
`Invalid Codex WebSocket JSON: ${thrownMessage(error)}`,
|
|
1578
|
+
text,
|
|
1579
|
+
error,
|
|
1580
|
+
);
|
|
547
1581
|
done = true;
|
|
548
1582
|
notify();
|
|
549
1583
|
}
|
|
550
1584
|
})();
|
|
551
1585
|
};
|
|
552
1586
|
const onError = (event: unknown) => {
|
|
553
|
-
failure =
|
|
1587
|
+
if (!failure) failure = extractWebSocketError(event);
|
|
554
1588
|
done = true;
|
|
555
1589
|
notify();
|
|
556
1590
|
};
|
|
557
1591
|
const onClose = (event: unknown) => {
|
|
558
|
-
if (!terminal) failure =
|
|
1592
|
+
if (!terminal && !failure) failure = extractWebSocketCloseError(event);
|
|
559
1593
|
done = true;
|
|
560
1594
|
notify();
|
|
561
1595
|
};
|
|
@@ -571,6 +1605,7 @@ async function* parseWebSocket(
|
|
|
571
1605
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
572
1606
|
try {
|
|
573
1607
|
while (true) {
|
|
1608
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
574
1609
|
if (queue.length > 0) {
|
|
575
1610
|
yield queue.shift()!;
|
|
576
1611
|
continue;
|
|
@@ -579,10 +1614,14 @@ async function* parseWebSocket(
|
|
|
579
1614
|
await new Promise<void>((resolve, reject) => {
|
|
580
1615
|
wake = resolve;
|
|
581
1616
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
582
|
-
const timer = setTimeout(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1617
|
+
const timer = setTimeout(() => {
|
|
1618
|
+
const error = new Error(`WebSocket idle timeout after ${timeoutMs}ms`);
|
|
1619
|
+
failure = error;
|
|
1620
|
+
done = true;
|
|
1621
|
+
wake = undefined;
|
|
1622
|
+
closeSocket(socket, "idle_timeout");
|
|
1623
|
+
reject(error);
|
|
1624
|
+
}, timeoutMs);
|
|
586
1625
|
const priorWake = wake;
|
|
587
1626
|
wake = () => {
|
|
588
1627
|
clearTimeout(timer);
|
|
@@ -592,7 +1631,7 @@ async function* parseWebSocket(
|
|
|
592
1631
|
});
|
|
593
1632
|
}
|
|
594
1633
|
if (failure) throw failure;
|
|
595
|
-
if (!terminal) throw new Error("WebSocket
|
|
1634
|
+
if (!terminal) throw new Error("WebSocket stream closed before response.completed");
|
|
596
1635
|
} finally {
|
|
597
1636
|
socket.removeEventListener("message", onMessage);
|
|
598
1637
|
socket.removeEventListener("error", onError);
|
|
@@ -601,23 +1640,27 @@ async function* parseWebSocket(
|
|
|
601
1640
|
}
|
|
602
1641
|
}
|
|
603
1642
|
|
|
604
|
-
function normalizeEvent(event: JsonRecord): JsonRecord {
|
|
605
|
-
const type = event.type;
|
|
1643
|
+
function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
|
|
1644
|
+
const type = typeof event.type === "string" ? event.type : undefined;
|
|
1645
|
+
if (!type) return undefined;
|
|
606
1646
|
if (type === "error") {
|
|
607
1647
|
const nested = isObject(event["error"]) ? event["error"] : undefined;
|
|
608
|
-
|
|
1648
|
+
const code =
|
|
1649
|
+
typeof event["code"] === "string"
|
|
1650
|
+
? event["code"]
|
|
1651
|
+
: typeof nested?.["code"] === "string"
|
|
1652
|
+
? nested["code"]
|
|
1653
|
+
: undefined;
|
|
1654
|
+
const message =
|
|
609
1655
|
typeof event["message"] === "string"
|
|
610
1656
|
? event["message"]
|
|
611
1657
|
: typeof nested?.["message"] === "string"
|
|
612
1658
|
? nested["message"]
|
|
613
|
-
:
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const error = isObject(response?.["error"]) ? response["error"] : undefined;
|
|
619
|
-
throw new CodexResponseError(
|
|
620
|
-
typeof error?.["message"] === "string" ? error["message"] : "Codex response failed",
|
|
1659
|
+
: undefined;
|
|
1660
|
+
throw new CodexApiError(
|
|
1661
|
+
`Codex error: ${message || code || JSON.stringify(event)}`,
|
|
1662
|
+
code,
|
|
1663
|
+
event,
|
|
621
1664
|
);
|
|
622
1665
|
}
|
|
623
1666
|
if (type === "response.done") {
|
|
@@ -626,60 +1669,108 @@ function normalizeEvent(event: JsonRecord): JsonRecord {
|
|
|
626
1669
|
return event;
|
|
627
1670
|
}
|
|
628
1671
|
|
|
1672
|
+
function isTerminalEvent(event: JsonRecord): boolean {
|
|
1673
|
+
return (
|
|
1674
|
+
event.type === "response.completed" ||
|
|
1675
|
+
event.type === "response.incomplete" ||
|
|
1676
|
+
event.type === "response.failed"
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
629
1680
|
async function* requestSse(
|
|
630
1681
|
model: Model<any>,
|
|
631
|
-
|
|
1682
|
+
bodyJson: string,
|
|
632
1683
|
options: CodexTransportOptions,
|
|
633
1684
|
headers: Headers,
|
|
1685
|
+
timeoutMs: number | undefined,
|
|
1686
|
+
onAttempt: (turnStateReplayedValue: string | undefined) => void,
|
|
634
1687
|
): AsyncGenerator<JsonRecord> {
|
|
635
|
-
const bodyJson = JSON.stringify(body);
|
|
636
1688
|
const compressed = compressBody(bodyJson);
|
|
637
1689
|
if (compressed) headers.set("content-encoding", "zstd");
|
|
638
1690
|
const requestBody = compressed ?? bodyJson;
|
|
639
1691
|
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1692
|
+
let response: Response | undefined;
|
|
1693
|
+
let lastError: Error | undefined;
|
|
640
1694
|
|
|
641
1695
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
642
|
-
|
|
643
|
-
options.timeoutMs !== undefined && options.timeoutMs > 0
|
|
644
|
-
? AbortSignal.timeout(options.timeoutMs)
|
|
645
|
-
: undefined;
|
|
646
|
-
const combined = combineAbortSignals([options.signal, timeoutSignal]);
|
|
647
|
-
let response: Response;
|
|
1696
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
648
1697
|
try {
|
|
1698
|
+
onAttempt(applyTurnStateHeader(headers, options.turnState));
|
|
1699
|
+
const timeoutSignal =
|
|
1700
|
+
timeoutMs !== undefined && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
1701
|
+
const combined = combineAbortSignals([options.signal, timeoutSignal]);
|
|
649
1702
|
try {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1703
|
+
try {
|
|
1704
|
+
response = await (options.fetch ?? globalThis.fetch)(resolveCodexUrl(model.baseUrl), {
|
|
1705
|
+
method: "POST",
|
|
1706
|
+
headers,
|
|
1707
|
+
body: requestBody,
|
|
1708
|
+
...(combined.signal ? { signal: combined.signal } : {}),
|
|
1709
|
+
});
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
if (timeoutSignal?.aborted && !options.signal?.aborted) {
|
|
1712
|
+
throw new Error(`Codex SSE response headers timed out after ${String(timeoutMs)}ms`);
|
|
1713
|
+
}
|
|
1714
|
+
throw error;
|
|
660
1715
|
}
|
|
661
|
-
|
|
1716
|
+
} finally {
|
|
1717
|
+
combined.cleanup();
|
|
662
1718
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
if (!response.ok) {
|
|
1719
|
+
captureTurnStateHeader(response.headers, options.turnState);
|
|
1720
|
+
await options.onResponse?.(
|
|
1721
|
+
{ status: response.status, headers: headersToRecord(response.headers) },
|
|
1722
|
+
model,
|
|
1723
|
+
);
|
|
1724
|
+
if (response.ok) break;
|
|
1725
|
+
|
|
671
1726
|
const errorText = await response.text();
|
|
672
1727
|
if (attempt < maxRetries && isRetryable(response.status, errorText)) {
|
|
673
|
-
|
|
1728
|
+
const requestedDelay = retryDelayMs(response.headers);
|
|
1729
|
+
const delay =
|
|
1730
|
+
requestedDelay === undefined
|
|
1731
|
+
? retryBackoffMs(BASE_DELAY_MS, attempt + 1)
|
|
1732
|
+
: validateRetryDelay(requestedDelay, options);
|
|
1733
|
+
await sleep(delay, options.signal);
|
|
674
1734
|
continue;
|
|
675
1735
|
}
|
|
676
|
-
throw
|
|
1736
|
+
throw codexHttpError(response.status, response.statusText, errorText);
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
if (
|
|
1739
|
+
options.signal?.aborted ||
|
|
1740
|
+
(error instanceof Error &&
|
|
1741
|
+
(error.name === "AbortError" || error.message === "Request was aborted"))
|
|
1742
|
+
) {
|
|
1743
|
+
throw new Error("Request was aborted");
|
|
1744
|
+
}
|
|
1745
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1746
|
+
if (
|
|
1747
|
+
attempt < maxRetries &&
|
|
1748
|
+
!(lastError instanceof CodexHttpError) &&
|
|
1749
|
+
!(lastError instanceof RetryDelayExceededError) &&
|
|
1750
|
+
!lastError.message.includes("usage limit")
|
|
1751
|
+
) {
|
|
1752
|
+
await sleep(retryBackoffMs(BASE_DELAY_MS, attempt + 1), options.signal);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
throw lastError;
|
|
677
1756
|
}
|
|
678
|
-
|
|
679
|
-
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
if (!response?.ok) throw lastError ?? new Error("Failed after retries");
|
|
1760
|
+
if (!response.body) throw new Error("No response body");
|
|
1761
|
+
options.onTransportStart?.();
|
|
1762
|
+
let terminal = false;
|
|
1763
|
+
for await (const event of parseSse(response, options.signal)) {
|
|
1764
|
+
const normalized = normalizeEvent(event);
|
|
1765
|
+
if (!normalized) continue;
|
|
1766
|
+
yield normalized;
|
|
1767
|
+
if (isTerminalEvent(normalized)) {
|
|
1768
|
+
terminal = true;
|
|
1769
|
+
break;
|
|
680
1770
|
}
|
|
681
|
-
return;
|
|
682
1771
|
}
|
|
1772
|
+
if (!terminal)
|
|
1773
|
+
throw new SseStreamIncompleteError("Codex SSE stream ended before a terminal event");
|
|
683
1774
|
}
|
|
684
1775
|
|
|
685
1776
|
async function* requestWebSocket(
|
|
@@ -688,33 +1779,136 @@ async function* requestWebSocket(
|
|
|
688
1779
|
options: CodexTransportOptions,
|
|
689
1780
|
headers: Headers,
|
|
690
1781
|
sessionId: string | undefined,
|
|
1782
|
+
accountId: string,
|
|
1783
|
+
timeoutMs: number | undefined,
|
|
1784
|
+
connectTimeoutMs: number,
|
|
1785
|
+
requestBytes: number,
|
|
1786
|
+
onAttempt: (attempt: CodexWebSocketAttempt) => void,
|
|
691
1787
|
): AsyncGenerator<JsonRecord> {
|
|
692
1788
|
const acquired = await acquireWebSocket(
|
|
693
1789
|
resolveCodexWebSocketUrl(model.baseUrl),
|
|
694
1790
|
headers,
|
|
695
1791
|
sessionId,
|
|
1792
|
+
accountId,
|
|
696
1793
|
options.signal,
|
|
697
|
-
|
|
1794
|
+
connectTimeoutMs,
|
|
698
1795
|
);
|
|
699
|
-
let keep =
|
|
1796
|
+
let keep = false;
|
|
1797
|
+
let responseHandleActive = true;
|
|
1798
|
+
const discard = (): boolean => {
|
|
1799
|
+
if (!responseHandleActive) return false;
|
|
1800
|
+
responseHandleActive = false;
|
|
1801
|
+
if (acquired.entry) delete acquired.entry.continuation;
|
|
1802
|
+
acquired.release(false);
|
|
1803
|
+
return true;
|
|
1804
|
+
};
|
|
700
1805
|
try {
|
|
1806
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
701
1807
|
const useContinuation =
|
|
702
1808
|
options.transport === "auto" || options.transport === "websocket-cached";
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
1809
|
+
const routed = withTurnStateMetadata(body, options.turnState);
|
|
1810
|
+
const fullBody =
|
|
1811
|
+
useContinuation && acquired.entry ? jsonWireRequestBody(routed.body) : routed.body;
|
|
1812
|
+
const decision =
|
|
1813
|
+
useContinuation && acquired.entry
|
|
1814
|
+
? cachedRequestBody(acquired.entry, fullBody)
|
|
1815
|
+
: ({ body: fullBody, contextMode: "full" } satisfies CachedRequestDecision);
|
|
1816
|
+
const requestBody = decision.body;
|
|
1817
|
+
const warmup = options.warmup === true;
|
|
1818
|
+
const requestStartedAtUnixMs = String(Date.now());
|
|
1819
|
+
const withRequestStart = (candidate: JsonRecord): JsonRecord => ({
|
|
1820
|
+
...candidate,
|
|
1821
|
+
client_metadata: {
|
|
1822
|
+
...(isObject(candidate.client_metadata) ? candidate.client_metadata : {}),
|
|
1823
|
+
[CODEX_WS_REQUEST_START_METADATA_KEY]: requestStartedAtUnixMs,
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
const fullRequestJson = JSON.stringify({
|
|
1827
|
+
type: "response.create",
|
|
1828
|
+
...withRequestStart(fullBody),
|
|
1829
|
+
...(warmup ? { generate: false } : {}),
|
|
1830
|
+
});
|
|
1831
|
+
const wireRequestJson = JSON.stringify({
|
|
1832
|
+
type: "response.create",
|
|
1833
|
+
...withRequestStart(requestBody),
|
|
1834
|
+
...(warmup ? { generate: false } : {}),
|
|
1835
|
+
});
|
|
1836
|
+
onAttempt({
|
|
1837
|
+
connection: acquired.reused ? "reused" : "new",
|
|
1838
|
+
contextMode: decision.contextMode,
|
|
1839
|
+
inputItems: requestInputLength(requestBody),
|
|
1840
|
+
fullInputItems: requestInputLength(fullBody),
|
|
1841
|
+
fullRequestBytes: serializedBytes(fullRequestJson),
|
|
1842
|
+
wireRequestBytes: serializedBytes(wireRequestJson),
|
|
1843
|
+
...(decision.previousResponseId ? { previousResponseId: decision.previousResponseId } : {}),
|
|
1844
|
+
...(decision.bypassReason ? { bypassReason: decision.bypassReason } : {}),
|
|
1845
|
+
...(decision.historyMismatch ? { historyMismatch: decision.historyMismatch } : {}),
|
|
1846
|
+
...(decision.cacheIdentityPreserved === undefined
|
|
1847
|
+
? {}
|
|
1848
|
+
: { cacheIdentityPreserved: decision.cacheIdentityPreserved }),
|
|
1849
|
+
...(options.turnState
|
|
1850
|
+
? {
|
|
1851
|
+
turnStateReplayed: routed.replayedValue !== undefined,
|
|
1852
|
+
...(routed.replayedValue ? { turnStateReplayedValue: routed.replayedValue } : {}),
|
|
1853
|
+
}
|
|
1854
|
+
: {}),
|
|
1855
|
+
});
|
|
1856
|
+
const stats = sessionId ? getOrCreateWebSocketDebugStats(sessionId) : undefined;
|
|
1857
|
+
if (stats) {
|
|
1858
|
+
stats.requests += 1;
|
|
1859
|
+
if (warmup) stats.prewarmRequests += 1;
|
|
1860
|
+
if (acquired.reused) stats.connectionsReused += 1;
|
|
1861
|
+
else stats.connectionsCreated += 1;
|
|
1862
|
+
if (useContinuation) stats.cachedContextRequests += 1;
|
|
1863
|
+
if (requestBody.store === true) stats.storeTrueRequests += 1;
|
|
1864
|
+
stats.lastInputItems = requestInputLength(requestBody);
|
|
1865
|
+
if (requestBody.previous_response_id) {
|
|
1866
|
+
stats.deltaRequests += 1;
|
|
1867
|
+
stats.lastDeltaInputItems = requestInputLength(requestBody);
|
|
1868
|
+
stats.lastPreviousResponseId = requestBody.previous_response_id as string;
|
|
1869
|
+
} else {
|
|
1870
|
+
stats.fullContextRequests += 1;
|
|
1871
|
+
delete stats.lastDeltaInputItems;
|
|
1872
|
+
delete stats.lastPreviousResponseId;
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
options.onWebSocketResponseHandle?.({
|
|
1876
|
+
discard,
|
|
1877
|
+
failParsing(error) {
|
|
1878
|
+
if (!discard()) return false;
|
|
1879
|
+
options.onTransportDiagnostic?.(
|
|
1880
|
+
transportDiagnostic(error, options.transport ?? "auto", true, requestBytes),
|
|
1881
|
+
);
|
|
1882
|
+
return true;
|
|
1883
|
+
},
|
|
1884
|
+
});
|
|
1885
|
+
acquired.socket.send(wireRequestJson);
|
|
706
1886
|
const responseItems: JsonRecord[] = [];
|
|
707
1887
|
let responseId: string | undefined;
|
|
708
|
-
|
|
1888
|
+
let responseCompleted = false;
|
|
1889
|
+
for await (const event of parseWebSocket(acquired.socket, options.signal, timeoutMs)) {
|
|
1890
|
+
captureTurnStateEvent(event, options.turnState);
|
|
1891
|
+
if (event.type === "response.metadata") continue;
|
|
1892
|
+
if (
|
|
1893
|
+
event.type === "response.created" &&
|
|
1894
|
+
isObject(event.response) &&
|
|
1895
|
+
typeof event.response.id === "string"
|
|
1896
|
+
) {
|
|
1897
|
+
responseId = event.response.id;
|
|
1898
|
+
}
|
|
709
1899
|
if (event.type === "response.output_item.done" && isObject(event.item)) {
|
|
710
1900
|
responseItems.push(structuredClone(event.item));
|
|
711
1901
|
}
|
|
712
1902
|
if (
|
|
713
1903
|
(event.type === "response.completed" ||
|
|
714
1904
|
event.type === "response.done" ||
|
|
715
|
-
event.type === "response.incomplete"
|
|
1905
|
+
event.type === "response.incomplete" ||
|
|
1906
|
+
event.type === "response.failed") &&
|
|
716
1907
|
isObject(event.response)
|
|
717
1908
|
) {
|
|
1909
|
+
if (event.type === "response.completed" || event.type === "response.done") {
|
|
1910
|
+
responseCompleted = true;
|
|
1911
|
+
}
|
|
718
1912
|
if (typeof event.response.id === "string") responseId = event.response.id;
|
|
719
1913
|
if (Array.isArray(event.response["output"])) {
|
|
720
1914
|
const terminalItems = event.response["output"].filter(isObject);
|
|
@@ -727,20 +1921,43 @@ async function* requestWebSocket(
|
|
|
727
1921
|
}
|
|
728
1922
|
}
|
|
729
1923
|
}
|
|
730
|
-
|
|
1924
|
+
const normalized = normalizeEvent(event);
|
|
1925
|
+
if (!normalized) continue;
|
|
1926
|
+
yield normalized;
|
|
1927
|
+
if (isTerminalEvent(normalized)) break;
|
|
731
1928
|
}
|
|
732
|
-
if (
|
|
733
|
-
|
|
734
|
-
|
|
1929
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
1930
|
+
if (useContinuation && acquired.entry && responseId && responseCompleted) {
|
|
1931
|
+
const entry = acquired.entry;
|
|
1932
|
+
const continuation = {
|
|
1933
|
+
lastRequestBody: fullBody,
|
|
735
1934
|
lastResponseId: responseId,
|
|
736
1935
|
lastResponseItems: responseItems.map(normalizeReplayItem),
|
|
737
1936
|
};
|
|
1937
|
+
entry.continuation = continuation;
|
|
1938
|
+
if (sessionId) {
|
|
1939
|
+
options.onContinuationReady?.({
|
|
1940
|
+
responseId,
|
|
1941
|
+
replaceResponseItems(items) {
|
|
1942
|
+
if (
|
|
1943
|
+
websocketSessions.get(sessionId)?.get(accountId) !== entry ||
|
|
1944
|
+
entry.continuation !== continuation ||
|
|
1945
|
+
continuation.lastResponseId !== responseId
|
|
1946
|
+
) {
|
|
1947
|
+
return false;
|
|
1948
|
+
}
|
|
1949
|
+
continuation.lastResponseItems = items.map((item) => structuredClone(item));
|
|
1950
|
+
return true;
|
|
1951
|
+
},
|
|
1952
|
+
});
|
|
1953
|
+
}
|
|
738
1954
|
}
|
|
1955
|
+
keep = true;
|
|
739
1956
|
} catch (error) {
|
|
740
|
-
|
|
741
|
-
keep = false;
|
|
1957
|
+
responseHandleActive = false;
|
|
742
1958
|
throw error;
|
|
743
1959
|
} finally {
|
|
1960
|
+
if (!keep && acquired.entry) delete acquired.entry.continuation;
|
|
744
1961
|
acquired.release(keep);
|
|
745
1962
|
}
|
|
746
1963
|
}
|
|
@@ -755,7 +1972,7 @@ export async function requestCodexJson(
|
|
|
755
1972
|
model.headers,
|
|
756
1973
|
options.headers,
|
|
757
1974
|
options.extraHeaders,
|
|
758
|
-
|
|
1975
|
+
validateCodexAuthentication(model, options.apiKey),
|
|
759
1976
|
options.apiKey,
|
|
760
1977
|
);
|
|
761
1978
|
const response = await (options.fetch ?? globalThis.fetch)(
|
|
@@ -769,7 +1986,7 @@ export async function requestCodexJson(
|
|
|
769
1986
|
);
|
|
770
1987
|
const responseText = await response.text();
|
|
771
1988
|
if (!response.ok) {
|
|
772
|
-
throw
|
|
1989
|
+
throw codexHttpError(response.status, response.statusText, responseText);
|
|
773
1990
|
}
|
|
774
1991
|
try {
|
|
775
1992
|
return JSON.parse(responseText) as unknown;
|
|
@@ -783,73 +2000,452 @@ export async function requestCodexJson(
|
|
|
783
2000
|
}
|
|
784
2001
|
|
|
785
2002
|
export class CodexTransport {
|
|
2003
|
+
async prewarm(
|
|
2004
|
+
model: Model<any>,
|
|
2005
|
+
body: JsonRecord,
|
|
2006
|
+
options: CodexTransportOptions,
|
|
2007
|
+
): Promise<boolean> {
|
|
2008
|
+
const transport = options.transport ?? "auto";
|
|
2009
|
+
const turnStateAvailableAtStart = options.turnState?.available ?? false;
|
|
2010
|
+
const turnStateRevisionAtStart = options.turnState?.revision ?? 0;
|
|
2011
|
+
const turnStateValueAtStart = options.turnState?.replayValue();
|
|
2012
|
+
const diagnosticDetails = () => {
|
|
2013
|
+
const turnStateReceived =
|
|
2014
|
+
(options.turnState?.revision ?? turnStateRevisionAtStart) > turnStateRevisionAtStart;
|
|
2015
|
+
const turnStateReceivedValue = options.turnState?.replayValue();
|
|
2016
|
+
return {
|
|
2017
|
+
...(options.cacheDiagnostics ? { cache: options.cacheDiagnostics } : {}),
|
|
2018
|
+
...(options.turnState
|
|
2019
|
+
? {
|
|
2020
|
+
turnStateAvailableAtStart,
|
|
2021
|
+
turnStateReceived,
|
|
2022
|
+
...(turnStateValueAtStart ? { turnStateAtStart: turnStateValueAtStart } : {}),
|
|
2023
|
+
...(turnStateReceived && turnStateReceivedValue ? { turnStateReceivedValue } : {}),
|
|
2024
|
+
}
|
|
2025
|
+
: {}),
|
|
2026
|
+
};
|
|
2027
|
+
};
|
|
2028
|
+
if (transport === "sse") {
|
|
2029
|
+
options.onTransportDiagnostic?.({
|
|
2030
|
+
type: "codex_transport_prewarm",
|
|
2031
|
+
timestamp: Date.now(),
|
|
2032
|
+
details: {
|
|
2033
|
+
outcome: "skipped",
|
|
2034
|
+
continuationReady: false,
|
|
2035
|
+
reason: "sse_configured",
|
|
2036
|
+
...diagnosticDetails(),
|
|
2037
|
+
},
|
|
2038
|
+
});
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
|
|
2042
|
+
if (isWebSocketSseFallbackActive(cacheSessionId)) {
|
|
2043
|
+
options.onTransportDiagnostic?.({
|
|
2044
|
+
type: "codex_transport_prewarm",
|
|
2045
|
+
timestamp: Date.now(),
|
|
2046
|
+
details: {
|
|
2047
|
+
outcome: "skipped",
|
|
2048
|
+
continuationReady: false,
|
|
2049
|
+
reason: "sticky_sse_fallback",
|
|
2050
|
+
...diagnosticDetails(),
|
|
2051
|
+
},
|
|
2052
|
+
});
|
|
2053
|
+
return false;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
let continuationReady = false;
|
|
2057
|
+
const notifyContinuationReady = (handle: CodexContinuationHandle): void => {
|
|
2058
|
+
options.onContinuationReady?.(handle);
|
|
2059
|
+
};
|
|
2060
|
+
try {
|
|
2061
|
+
for await (const _event of this.request(model, body, {
|
|
2062
|
+
...options,
|
|
2063
|
+
warmup: true,
|
|
2064
|
+
requestKind: "prewarm",
|
|
2065
|
+
onContinuationReady(handle) {
|
|
2066
|
+
continuationReady = true;
|
|
2067
|
+
notifyContinuationReady(handle);
|
|
2068
|
+
},
|
|
2069
|
+
})) {
|
|
2070
|
+
// A v2 warmup completes without model output.
|
|
2071
|
+
}
|
|
2072
|
+
} catch (error) {
|
|
2073
|
+
options.onTransportDiagnostic?.({
|
|
2074
|
+
type: "codex_transport_prewarm",
|
|
2075
|
+
timestamp: Date.now(),
|
|
2076
|
+
details: {
|
|
2077
|
+
outcome: "failed",
|
|
2078
|
+
continuationReady: false,
|
|
2079
|
+
...diagnosticDetails(),
|
|
2080
|
+
},
|
|
2081
|
+
});
|
|
2082
|
+
throw error;
|
|
2083
|
+
}
|
|
2084
|
+
options.onTransportDiagnostic?.({
|
|
2085
|
+
type: "codex_transport_prewarm",
|
|
2086
|
+
timestamp: Date.now(),
|
|
2087
|
+
details: {
|
|
2088
|
+
outcome: "completed",
|
|
2089
|
+
continuationReady,
|
|
2090
|
+
...diagnosticDetails(),
|
|
2091
|
+
},
|
|
2092
|
+
});
|
|
2093
|
+
return continuationReady;
|
|
2094
|
+
}
|
|
2095
|
+
|
|
786
2096
|
async *request(
|
|
787
2097
|
model: Model<any>,
|
|
788
2098
|
body: JsonRecord,
|
|
789
2099
|
options: CodexTransportOptions,
|
|
790
2100
|
): AsyncGenerator<JsonRecord> {
|
|
791
2101
|
if (!options.apiKey) throw new Error(`No API key for provider: ${model.provider}`);
|
|
792
|
-
const
|
|
793
|
-
const
|
|
2102
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
2103
|
+
const connectTimeoutMs =
|
|
2104
|
+
normalizeTimeoutMs(options.websocketConnectTimeoutMs) ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
|
|
2105
|
+
const sseBody = responsesLiteSsePayload(body);
|
|
2106
|
+
const accountId = options.accountId ?? validateCodexAuthentication(model, options.apiKey);
|
|
2107
|
+
const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
|
|
2108
|
+
const requestId = codexCacheKey(cacheSessionId);
|
|
794
2109
|
const transport = options.transport ?? "auto";
|
|
2110
|
+
const turnStateAvailableAtStart = options.turnState?.available ?? false;
|
|
2111
|
+
const turnStateRevisionAtStart = options.turnState?.revision ?? 0;
|
|
2112
|
+
const turnStateValueAtStart = options.turnState?.replayValue();
|
|
2113
|
+
const websocketMaxRetries = normalizeRetryCount(
|
|
2114
|
+
options.websocketMaxRetries,
|
|
2115
|
+
DEFAULT_WEBSOCKET_MAX_RETRIES,
|
|
2116
|
+
"websocketMaxRetries",
|
|
2117
|
+
);
|
|
2118
|
+
const websocketRetryBaseDelayMs = normalizeRetryCount(
|
|
2119
|
+
options.websocketRetryBaseDelayMs,
|
|
2120
|
+
DEFAULT_WEBSOCKET_RETRY_BASE_DELAY_MS,
|
|
2121
|
+
"websocketRetryBaseDelayMs",
|
|
2122
|
+
);
|
|
2123
|
+
const sseStreamMaxRetries = normalizeRetryCount(
|
|
2124
|
+
options.sseStreamMaxRetries,
|
|
2125
|
+
DEFAULT_SSE_STREAM_MAX_RETRIES,
|
|
2126
|
+
"sseStreamMaxRetries",
|
|
2127
|
+
);
|
|
2128
|
+
const sseStreamRetryBaseDelayMs = normalizeRetryCount(
|
|
2129
|
+
options.sseStreamRetryBaseDelayMs,
|
|
2130
|
+
DEFAULT_SSE_STREAM_RETRY_BASE_DELAY_MS,
|
|
2131
|
+
"sseStreamRetryBaseDelayMs",
|
|
2132
|
+
);
|
|
795
2133
|
|
|
796
|
-
const websocketDisabled =
|
|
797
|
-
|
|
2134
|
+
const websocketDisabled = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
|
|
2135
|
+
let sseRecovery:
|
|
2136
|
+
| {
|
|
2137
|
+
trigger: "sse_after_websocket_failure" | "sticky_sse_after_websocket_failure";
|
|
2138
|
+
previousCacheIdentity: CacheIdentitySnapshot;
|
|
2139
|
+
}
|
|
2140
|
+
| undefined;
|
|
2141
|
+
const existingFallback = webSocketFallbackSession(cacheSessionId);
|
|
2142
|
+
if (websocketDisabled && existingFallback) {
|
|
2143
|
+
sseRecovery = {
|
|
2144
|
+
trigger: "sticky_sse_after_websocket_failure",
|
|
2145
|
+
previousCacheIdentity: existingFallback.cacheIdentity,
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
if (websocketDisabled) recordWebSocketSseFallback(cacheSessionId);
|
|
798
2149
|
if (transport !== "sse" && !websocketDisabled) {
|
|
2150
|
+
const websocketRequestBytes = serializedBytes(JSON.stringify(body));
|
|
799
2151
|
const headers = websocketHeaders(
|
|
800
2152
|
model.headers,
|
|
801
2153
|
options.headers,
|
|
802
2154
|
accountId,
|
|
803
2155
|
options.apiKey,
|
|
804
|
-
|
|
2156
|
+
requestId || uuidv7(),
|
|
2157
|
+
body,
|
|
805
2158
|
);
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
2159
|
+
const webSocketCacheIdentity = cacheIdentitySnapshot(body, headers, accountId);
|
|
2160
|
+
let retriedConnectionLimit = false;
|
|
2161
|
+
let retriedMissingContinuation = false;
|
|
2162
|
+
let websocketRetries = 0;
|
|
2163
|
+
let visibleOutputEmitted = false;
|
|
2164
|
+
let anyEventEmitted = false;
|
|
2165
|
+
while (true) {
|
|
2166
|
+
let emitted = false;
|
|
2167
|
+
let attempt: CodexWebSocketAttempt | undefined;
|
|
2168
|
+
let usage: CodexCacheUsageDiagnostic | undefined;
|
|
2169
|
+
let responseId: string | undefined;
|
|
2170
|
+
try {
|
|
2171
|
+
for await (const event of requestWebSocket(
|
|
2172
|
+
model,
|
|
2173
|
+
body,
|
|
2174
|
+
options,
|
|
2175
|
+
headers,
|
|
2176
|
+
cacheSessionId,
|
|
2177
|
+
accountId,
|
|
2178
|
+
timeoutMs,
|
|
2179
|
+
connectTimeoutMs,
|
|
2180
|
+
websocketRequestBytes,
|
|
2181
|
+
(currentAttempt) => {
|
|
2182
|
+
attempt = currentAttempt;
|
|
2183
|
+
if (!currentAttempt.bypassReason) return;
|
|
2184
|
+
options.onTransportDiagnostic?.(
|
|
2185
|
+
transportRecoveryDiagnostic({
|
|
2186
|
+
trigger: "local_continuation_bypass",
|
|
2187
|
+
configuredTransport: transport,
|
|
2188
|
+
attempts: [webSocketRecoveryAttempt(currentAttempt, "selected")],
|
|
2189
|
+
cacheIdentity: webSocketCacheIdentity,
|
|
2190
|
+
...(currentAttempt.previousResponseId
|
|
2191
|
+
? { previousResponseId: currentAttempt.previousResponseId }
|
|
2192
|
+
: {}),
|
|
2193
|
+
continuationBypassReason: currentAttempt.bypassReason,
|
|
2194
|
+
...(currentAttempt.historyMismatch
|
|
2195
|
+
? { historyMismatch: currentAttempt.historyMismatch }
|
|
2196
|
+
: {}),
|
|
2197
|
+
...(currentAttempt.cacheIdentityPreserved === undefined
|
|
2198
|
+
? {}
|
|
2199
|
+
: { cacheIdentityPreserved: currentAttempt.cacheIdentityPreserved }),
|
|
2200
|
+
accountIdentityPreserved: true,
|
|
2201
|
+
}),
|
|
2202
|
+
);
|
|
2203
|
+
},
|
|
2204
|
+
)) {
|
|
2205
|
+
if (!emitted) options.onTransportStart?.();
|
|
2206
|
+
emitted = true;
|
|
2207
|
+
anyEventEmitted = true;
|
|
2208
|
+
if (webSocketEventStartsVisibleOutput(event)) visibleOutputEmitted = true;
|
|
2209
|
+
usage = cacheUsageDiagnostic(event) ?? usage;
|
|
2210
|
+
if (isObject(event.response) && typeof event.response.id === "string") {
|
|
2211
|
+
responseId = event.response.id;
|
|
2212
|
+
}
|
|
2213
|
+
yield event;
|
|
2214
|
+
}
|
|
2215
|
+
if (attempt && shouldReportRequestDiagnostic(options)) {
|
|
2216
|
+
options.onTransportDiagnostic?.(
|
|
2217
|
+
transportRequestDiagnostic({
|
|
2218
|
+
requestOptions: options,
|
|
2219
|
+
body,
|
|
2220
|
+
configuredTransport: transport,
|
|
2221
|
+
selectedTransport: "websocket",
|
|
2222
|
+
attempt,
|
|
2223
|
+
cacheIdentity: webSocketCacheIdentity,
|
|
2224
|
+
turnStateAvailableAtStart,
|
|
2225
|
+
turnStateRevisionAtStart,
|
|
2226
|
+
...(turnStateValueAtStart ? { turnStateValueAtStart } : {}),
|
|
2227
|
+
...(responseId ? { responseId } : {}),
|
|
2228
|
+
...(usage ? { usage } : {}),
|
|
2229
|
+
}),
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
return;
|
|
2233
|
+
} catch (error) {
|
|
2234
|
+
const aborted = options.signal?.aborted;
|
|
2235
|
+
const connectionLimitBeforeStart =
|
|
2236
|
+
!emitted && isWebSocketConnectionLimitReachedError(error);
|
|
2237
|
+
if (!aborted && isPreviousResponseNotFoundError(error) && !retriedMissingContinuation) {
|
|
2238
|
+
retriedMissingContinuation = true;
|
|
2239
|
+
if (attempt) {
|
|
2240
|
+
const retryTurnStateValue = options.turnState?.replayValue();
|
|
2241
|
+
options.onTransportDiagnostic?.(
|
|
2242
|
+
transportRecoveryDiagnostic({
|
|
2243
|
+
trigger: "previous_response_not_found",
|
|
2244
|
+
configuredTransport: transport,
|
|
2245
|
+
attempts: [
|
|
2246
|
+
webSocketRecoveryAttempt(attempt, "previous_response_not_found"),
|
|
2247
|
+
{
|
|
2248
|
+
transport: "websocket",
|
|
2249
|
+
connection: "new",
|
|
2250
|
+
contextMode: "full",
|
|
2251
|
+
inputItems: attempt.fullInputItems,
|
|
2252
|
+
fullInputItems: attempt.fullInputItems,
|
|
2253
|
+
fullRequestBytes: attempt.fullRequestBytes,
|
|
2254
|
+
wireRequestBytes: attempt.fullRequestBytes,
|
|
2255
|
+
outcome: "retry_scheduled",
|
|
2256
|
+
...(options.turnState
|
|
2257
|
+
? {
|
|
2258
|
+
turnStateReplayed: options.turnState.available,
|
|
2259
|
+
...(retryTurnStateValue
|
|
2260
|
+
? { turnStateReplayedValue: retryTurnStateValue }
|
|
2261
|
+
: {}),
|
|
2262
|
+
}
|
|
2263
|
+
: {}),
|
|
2264
|
+
},
|
|
2265
|
+
],
|
|
2266
|
+
cacheIdentity: webSocketCacheIdentity,
|
|
2267
|
+
error,
|
|
2268
|
+
...(attempt.previousResponseId
|
|
2269
|
+
? { previousResponseId: attempt.previousResponseId }
|
|
2270
|
+
: {}),
|
|
2271
|
+
cacheIdentityPreserved: true,
|
|
2272
|
+
accountIdentityPreserved: true,
|
|
2273
|
+
}),
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
continue;
|
|
2277
|
+
}
|
|
2278
|
+
if (!aborted && connectionLimitBeforeStart && !retriedConnectionLimit) {
|
|
2279
|
+
retriedConnectionLimit = true;
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
const retryableTransportError =
|
|
2283
|
+
!aborted &&
|
|
2284
|
+
!visibleOutputEmitted &&
|
|
2285
|
+
(!isCodexNonTransportError(error) || connectionLimitBeforeStart);
|
|
2286
|
+
if (retryableTransportError && websocketRetries < websocketMaxRetries) {
|
|
2287
|
+
websocketRetries += 1;
|
|
2288
|
+
if (attempt) {
|
|
2289
|
+
options.onTransportDiagnostic?.(
|
|
2290
|
+
transportRecoveryDiagnostic({
|
|
2291
|
+
trigger: "websocket_retry",
|
|
2292
|
+
configuredTransport: transport,
|
|
2293
|
+
attempts: [webSocketRecoveryAttempt(attempt, "retry_scheduled")],
|
|
2294
|
+
cacheIdentity: webSocketCacheIdentity,
|
|
2295
|
+
error,
|
|
2296
|
+
cacheIdentityPreserved: true,
|
|
2297
|
+
accountIdentityPreserved: true,
|
|
2298
|
+
retryNumber: websocketRetries,
|
|
2299
|
+
maxRetries: websocketMaxRetries,
|
|
2300
|
+
}),
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
await sleep(
|
|
2304
|
+
retryBackoffMs(websocketRetryBaseDelayMs, websocketRetries),
|
|
2305
|
+
options.signal,
|
|
2306
|
+
);
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
|
|
2310
|
+
throw error;
|
|
2311
|
+
}
|
|
2312
|
+
options.onTransportDiagnostic?.(
|
|
2313
|
+
transportDiagnostic(
|
|
2314
|
+
error,
|
|
2315
|
+
transport,
|
|
2316
|
+
anyEventEmitted,
|
|
2317
|
+
websocketRequestBytes,
|
|
2318
|
+
!visibleOutputEmitted,
|
|
2319
|
+
),
|
|
2320
|
+
);
|
|
2321
|
+
recordWebSocketFailure(cacheSessionId, error, webSocketCacheIdentity);
|
|
2322
|
+
if (visibleOutputEmitted) throw error;
|
|
2323
|
+
recordWebSocketSseFallback(cacheSessionId);
|
|
2324
|
+
if (options.warmup) throw error;
|
|
2325
|
+
sseRecovery = {
|
|
2326
|
+
trigger: "sse_after_websocket_failure",
|
|
2327
|
+
previousCacheIdentity: webSocketCacheIdentity,
|
|
2328
|
+
};
|
|
2329
|
+
break;
|
|
821
2330
|
}
|
|
822
|
-
if (sessionId) websocketFallbackSessions.add(sessionId);
|
|
823
2331
|
}
|
|
824
2332
|
}
|
|
825
2333
|
|
|
2334
|
+
const bodyJson = JSON.stringify(sseBody);
|
|
2335
|
+
const sseRequestBytes = serializedBytes(bodyJson);
|
|
826
2336
|
const headers = sseHeaders(
|
|
827
2337
|
model.headers,
|
|
828
2338
|
options.headers,
|
|
829
2339
|
accountId,
|
|
830
2340
|
options.apiKey,
|
|
831
|
-
|
|
2341
|
+
requestId,
|
|
2342
|
+
body,
|
|
832
2343
|
);
|
|
833
|
-
|
|
2344
|
+
if (sseRecovery) {
|
|
2345
|
+
const sseCacheIdentity = cacheIdentitySnapshot(sseBody, headers, accountId);
|
|
2346
|
+
options.onTransportDiagnostic?.(
|
|
2347
|
+
transportRecoveryDiagnostic({
|
|
2348
|
+
trigger: sseRecovery.trigger,
|
|
2349
|
+
configuredTransport: transport,
|
|
2350
|
+
attempts: [
|
|
2351
|
+
sseRecoveryAttempt(sseBody, sseRequestBytes, options.turnState?.replayValue()),
|
|
2352
|
+
],
|
|
2353
|
+
cacheIdentity: sseCacheIdentity,
|
|
2354
|
+
previousCacheIdentity: sseRecovery.previousCacheIdentity,
|
|
2355
|
+
}),
|
|
2356
|
+
);
|
|
2357
|
+
}
|
|
2358
|
+
const sseCacheIdentity = cacheIdentitySnapshot(sseBody, headers, accountId);
|
|
2359
|
+
let sseTurnStateReplayedValue: string | undefined;
|
|
2360
|
+
let usage: CodexCacheUsageDiagnostic | undefined;
|
|
2361
|
+
let responseId: string | undefined;
|
|
2362
|
+
let sseStreamRetries = 0;
|
|
2363
|
+
while (true) {
|
|
2364
|
+
let visibleOutputEmitted = false;
|
|
2365
|
+
try {
|
|
2366
|
+
for await (const event of requestSse(
|
|
2367
|
+
model,
|
|
2368
|
+
bodyJson,
|
|
2369
|
+
options,
|
|
2370
|
+
headers,
|
|
2371
|
+
timeoutMs,
|
|
2372
|
+
(replayedValue) => {
|
|
2373
|
+
sseTurnStateReplayedValue = replayedValue;
|
|
2374
|
+
},
|
|
2375
|
+
)) {
|
|
2376
|
+
if (webSocketEventStartsVisibleOutput(event)) visibleOutputEmitted = true;
|
|
2377
|
+
usage = cacheUsageDiagnostic(event) ?? usage;
|
|
2378
|
+
if (isObject(event.response) && typeof event.response.id === "string") {
|
|
2379
|
+
responseId = event.response.id;
|
|
2380
|
+
}
|
|
2381
|
+
yield event;
|
|
2382
|
+
}
|
|
2383
|
+
break;
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
const retryableError =
|
|
2386
|
+
error instanceof CodexHttpError ? error.retryable : !isCodexNonTransportError(error);
|
|
2387
|
+
const retryable =
|
|
2388
|
+
!options.signal?.aborted &&
|
|
2389
|
+
!visibleOutputEmitted &&
|
|
2390
|
+
retryableError &&
|
|
2391
|
+
thrownMessage(error) !== "Request was aborted" &&
|
|
2392
|
+
!(error instanceof RetryDelayExceededError);
|
|
2393
|
+
if (!retryable || sseStreamRetries >= sseStreamMaxRetries) throw error;
|
|
2394
|
+
sseStreamRetries += 1;
|
|
2395
|
+
options.onTransportDiagnostic?.(
|
|
2396
|
+
transportRecoveryDiagnostic({
|
|
2397
|
+
trigger: "sse_stream_retry",
|
|
2398
|
+
configuredTransport: transport,
|
|
2399
|
+
attempts: [
|
|
2400
|
+
{
|
|
2401
|
+
...sseRecoveryAttempt(sseBody, sseRequestBytes, sseTurnStateReplayedValue),
|
|
2402
|
+
outcome: "retry_scheduled",
|
|
2403
|
+
},
|
|
2404
|
+
],
|
|
2405
|
+
cacheIdentity: sseCacheIdentity,
|
|
2406
|
+
error,
|
|
2407
|
+
cacheIdentityPreserved: true,
|
|
2408
|
+
accountIdentityPreserved: true,
|
|
2409
|
+
retryNumber: sseStreamRetries,
|
|
2410
|
+
maxRetries: sseStreamMaxRetries,
|
|
2411
|
+
}),
|
|
2412
|
+
);
|
|
2413
|
+
await sleep(retryBackoffMs(sseStreamRetryBaseDelayMs, sseStreamRetries), options.signal);
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
if (shouldReportRequestDiagnostic(options)) {
|
|
2417
|
+
const inputItems = requestInputLength(sseBody);
|
|
2418
|
+
options.onTransportDiagnostic?.(
|
|
2419
|
+
transportRequestDiagnostic({
|
|
2420
|
+
requestOptions: options,
|
|
2421
|
+
body: sseBody,
|
|
2422
|
+
configuredTransport: transport,
|
|
2423
|
+
selectedTransport: "sse",
|
|
2424
|
+
attempt: {
|
|
2425
|
+
contextMode: "full",
|
|
2426
|
+
inputItems,
|
|
2427
|
+
fullInputItems: inputItems,
|
|
2428
|
+
fullRequestBytes: sseRequestBytes,
|
|
2429
|
+
wireRequestBytes: sseRequestBytes,
|
|
2430
|
+
turnStateReplayed: sseTurnStateReplayedValue !== undefined,
|
|
2431
|
+
...(sseTurnStateReplayedValue
|
|
2432
|
+
? { turnStateReplayedValue: sseTurnStateReplayedValue }
|
|
2433
|
+
: {}),
|
|
2434
|
+
},
|
|
2435
|
+
cacheIdentity: sseCacheIdentity,
|
|
2436
|
+
turnStateAvailableAtStart,
|
|
2437
|
+
turnStateRevisionAtStart,
|
|
2438
|
+
...(turnStateValueAtStart ? { turnStateValueAtStart } : {}),
|
|
2439
|
+
...(responseId ? { responseId } : {}),
|
|
2440
|
+
...(usage ? { usage } : {}),
|
|
2441
|
+
}),
|
|
2442
|
+
);
|
|
2443
|
+
}
|
|
834
2444
|
}
|
|
835
2445
|
|
|
836
2446
|
close(sessionId?: string): void {
|
|
837
|
-
|
|
838
|
-
const entry = websocketSessions.get(sessionId);
|
|
839
|
-
if (entry?.idleTimer) clearTimeout(entry.idleTimer);
|
|
840
|
-
if (entry) closeSocket(entry.socket, "session_shutdown");
|
|
841
|
-
websocketSessions.delete(sessionId);
|
|
842
|
-
websocketFallbackSessions.delete(sessionId);
|
|
843
|
-
return;
|
|
844
|
-
}
|
|
845
|
-
for (const entry of websocketSessions.values()) {
|
|
846
|
-
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
847
|
-
closeSocket(entry.socket, "shutdown");
|
|
848
|
-
}
|
|
849
|
-
websocketSessions.clear();
|
|
850
|
-
websocketFallbackSessions.clear();
|
|
2447
|
+
closeOpenAICodexWebSocketSessions(sessionId);
|
|
851
2448
|
}
|
|
852
2449
|
}
|
|
853
2450
|
|
|
854
|
-
|
|
855
|
-
registerSessionResourceCleanup((sessionId) => transportCleanup.close(sessionId));
|
|
2451
|
+
registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions);
|