pi-openai-codex-compat 0.0.2 → 0.0.3
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 +50 -0
- package/README.md +30 -16
- package/extensions/openai-codex-compat/apply-patch.ts +4 -5
- package/extensions/openai-codex-compat/codex-cache-key.ts +9 -0
- package/extensions/openai-codex-compat/codex-protocol.ts +3 -2
- package/extensions/openai-codex-compat/codex-provider.ts +221 -107
- package/extensions/openai-codex-compat/codex-stream.ts +85 -14
- package/extensions/openai-codex-compat/codex-transport.ts +714 -161
- package/extensions/openai-codex-compat/config.ts +2 -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 +7 -0
- package/extensions/openai-codex-compat/provider-error.ts +79 -0
- package/extensions/openai-codex-compat/responses-replay.ts +0 -7
- package/extensions/openai-codex-compat/web-run.ts +7 -0
- package/package.json +2 -1
|
@@ -6,9 +6,11 @@ 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";
|
|
10
12
|
import { isObject, type JsonRecord } from "./codex-protocol.ts";
|
|
11
|
-
import { normalizeReplayItem
|
|
13
|
+
import { normalizeReplayItem } from "./responses-replay.ts";
|
|
12
14
|
|
|
13
15
|
/**
|
|
14
16
|
* Focused adaptation of @earendil-works/pi-ai@0.83.0
|
|
@@ -19,10 +21,13 @@ const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
|
19
21
|
const OPENAI_BETA_RESPONSES_WEBSOCKETS = "responses_websockets=2026-02-06";
|
|
20
22
|
const DEFAULT_MAX_RETRIES = 0;
|
|
21
23
|
const BASE_DELAY_MS = 1_000;
|
|
24
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 60_000;
|
|
22
25
|
const REQUEST_COMPRESSION_ZSTD_LEVEL = 3;
|
|
23
26
|
const DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS = 15_000;
|
|
24
27
|
const SESSION_WEBSOCKET_CACHE_TTL_MS = 5 * 60 * 1_000;
|
|
25
28
|
const SESSION_WEBSOCKET_MAX_AGE_MS = 55 * 60 * 1_000;
|
|
29
|
+
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
|
30
|
+
const PREVIOUS_RESPONSE_NOT_FOUND_CODE = "previous_response_not_found";
|
|
26
31
|
|
|
27
32
|
type ProcessWithBuiltinModules = typeof process & {
|
|
28
33
|
getBuiltinModule?: {
|
|
@@ -31,10 +36,6 @@ type ProcessWithBuiltinModules = typeof process & {
|
|
|
31
36
|
};
|
|
32
37
|
};
|
|
33
38
|
|
|
34
|
-
type CodexTransportOptions = OpenAICodexResponsesOptions & {
|
|
35
|
-
env?: ProviderEnv;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
39
|
export type CodexJsonRequestOptions = {
|
|
39
40
|
apiKey: string;
|
|
40
41
|
headers?: ProviderHeaders;
|
|
@@ -43,6 +44,60 @@ export type CodexJsonRequestOptions = {
|
|
|
43
44
|
fetch?: typeof fetch;
|
|
44
45
|
};
|
|
45
46
|
|
|
47
|
+
export type CodexTransportDiagnostic = {
|
|
48
|
+
type: "provider_transport_failure";
|
|
49
|
+
timestamp: number;
|
|
50
|
+
error: {
|
|
51
|
+
name?: string;
|
|
52
|
+
message: string;
|
|
53
|
+
stack?: string;
|
|
54
|
+
code?: string | number;
|
|
55
|
+
};
|
|
56
|
+
details: {
|
|
57
|
+
configuredTransport: string;
|
|
58
|
+
fallbackTransport?: "sse";
|
|
59
|
+
eventsEmitted: boolean;
|
|
60
|
+
phase: "before_message_stream_start" | "after_message_stream_start";
|
|
61
|
+
requestBytes: number;
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type CodexContinuationHandle = {
|
|
66
|
+
readonly responseId: string;
|
|
67
|
+
replaceResponseItems(items: readonly JsonRecord[]): boolean;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type CodexWebSocketResponseHandle = {
|
|
71
|
+
discard(): boolean;
|
|
72
|
+
failParsing(error: unknown): boolean;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export interface OpenAICodexWebSocketDebugStats {
|
|
76
|
+
requests: number;
|
|
77
|
+
connectionsCreated: number;
|
|
78
|
+
connectionsReused: number;
|
|
79
|
+
cachedContextRequests: number;
|
|
80
|
+
storeTrueRequests: number;
|
|
81
|
+
fullContextRequests: number;
|
|
82
|
+
deltaRequests: number;
|
|
83
|
+
lastInputItems: number;
|
|
84
|
+
lastDeltaInputItems?: number;
|
|
85
|
+
lastPreviousResponseId?: string;
|
|
86
|
+
websocketFailures: number;
|
|
87
|
+
sseFallbacks: number;
|
|
88
|
+
websocketFallbackActive?: boolean;
|
|
89
|
+
lastWebSocketError?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type CodexTransportOptions = OpenAICodexResponsesOptions & {
|
|
93
|
+
accountId?: string;
|
|
94
|
+
env?: ProviderEnv;
|
|
95
|
+
onContinuationReady?(handle: CodexContinuationHandle): void;
|
|
96
|
+
onWebSocketResponseHandle?(handle: CodexWebSocketResponseHandle): void;
|
|
97
|
+
onTransportStart?(): void;
|
|
98
|
+
onTransportDiagnostic?(diagnostic: CodexTransportDiagnostic): void;
|
|
99
|
+
};
|
|
100
|
+
|
|
46
101
|
type WebSocketEventType = "open" | "message" | "error" | "close";
|
|
47
102
|
type WebSocketListener = (event: unknown) => void;
|
|
48
103
|
|
|
@@ -71,10 +126,132 @@ type CachedWebSocket = {
|
|
|
71
126
|
};
|
|
72
127
|
};
|
|
73
128
|
|
|
74
|
-
const websocketSessions = new Map<string, CachedWebSocket
|
|
129
|
+
const websocketSessions = new Map<string, Map<string, CachedWebSocket>>();
|
|
75
130
|
const websocketFallbackSessions = new Set<string>();
|
|
131
|
+
const websocketDebugStats = new Map<string, OpenAICodexWebSocketDebugStats>();
|
|
132
|
+
|
|
133
|
+
function getOrCreateWebSocketDebugStats(sessionId: string): OpenAICodexWebSocketDebugStats {
|
|
134
|
+
let stats = websocketDebugStats.get(sessionId);
|
|
135
|
+
if (!stats) {
|
|
136
|
+
stats = {
|
|
137
|
+
requests: 0,
|
|
138
|
+
connectionsCreated: 0,
|
|
139
|
+
connectionsReused: 0,
|
|
140
|
+
cachedContextRequests: 0,
|
|
141
|
+
storeTrueRequests: 0,
|
|
142
|
+
fullContextRequests: 0,
|
|
143
|
+
deltaRequests: 0,
|
|
144
|
+
lastInputItems: 0,
|
|
145
|
+
websocketFailures: 0,
|
|
146
|
+
sseFallbacks: 0,
|
|
147
|
+
};
|
|
148
|
+
websocketDebugStats.set(sessionId, stats);
|
|
149
|
+
}
|
|
150
|
+
return stats;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function getOpenAICodexWebSocketDebugStats(
|
|
154
|
+
sessionId: string,
|
|
155
|
+
): OpenAICodexWebSocketDebugStats | undefined {
|
|
156
|
+
const stats = websocketDebugStats.get(sessionId);
|
|
157
|
+
return stats ? { ...stats } : undefined;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function resetOpenAICodexWebSocketDebugStats(sessionId?: string): void {
|
|
161
|
+
if (sessionId) {
|
|
162
|
+
websocketDebugStats.delete(sessionId);
|
|
163
|
+
websocketFallbackSessions.delete(sessionId);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
websocketDebugStats.clear();
|
|
167
|
+
websocketFallbackSessions.clear();
|
|
168
|
+
}
|
|
76
169
|
|
|
77
|
-
|
|
170
|
+
export function closeOpenAICodexWebSocketSessions(sessionId?: string): void {
|
|
171
|
+
const closeEntry = (entry: CachedWebSocket): void => {
|
|
172
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
173
|
+
closeSocket(entry.socket, "debug_close");
|
|
174
|
+
};
|
|
175
|
+
if (sessionId) {
|
|
176
|
+
for (const entry of websocketSessions.get(sessionId)?.values() ?? []) closeEntry(entry);
|
|
177
|
+
websocketSessions.delete(sessionId);
|
|
178
|
+
websocketFallbackSessions.delete(sessionId);
|
|
179
|
+
const stats = websocketDebugStats.get(sessionId);
|
|
180
|
+
if (stats?.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
for (const accountEntries of websocketSessions.values()) {
|
|
184
|
+
for (const entry of accountEntries.values()) closeEntry(entry);
|
|
185
|
+
}
|
|
186
|
+
websocketSessions.clear();
|
|
187
|
+
websocketFallbackSessions.clear();
|
|
188
|
+
for (const stats of websocketDebugStats.values()) {
|
|
189
|
+
if (stats.websocketFallbackActive !== undefined) stats.websocketFallbackActive = false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isWebSocketSseFallbackActive(sessionId: string | undefined): boolean {
|
|
194
|
+
return sessionId ? websocketFallbackSessions.has(sessionId) : false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function recordWebSocketSseFallback(sessionId: string | undefined): void {
|
|
198
|
+
if (!sessionId) return;
|
|
199
|
+
const stats = getOrCreateWebSocketDebugStats(sessionId);
|
|
200
|
+
stats.sseFallbacks += 1;
|
|
201
|
+
stats.websocketFallbackActive = isWebSocketSseFallbackActive(sessionId);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function recordWebSocketFailure(sessionId: string | undefined, error: unknown): void {
|
|
205
|
+
if (!sessionId) return;
|
|
206
|
+
websocketFallbackSessions.add(sessionId);
|
|
207
|
+
const stats = getOrCreateWebSocketDebugStats(sessionId);
|
|
208
|
+
stats.websocketFailures += 1;
|
|
209
|
+
stats.lastWebSocketError = thrownMessage(error);
|
|
210
|
+
stats.websocketFallbackActive = true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
class CodexApiError extends Error {
|
|
214
|
+
readonly code: string | undefined;
|
|
215
|
+
readonly payload: JsonRecord;
|
|
216
|
+
|
|
217
|
+
constructor(message: string, code: string | undefined, payload: JsonRecord) {
|
|
218
|
+
super(message);
|
|
219
|
+
this.name = "CodexApiError";
|
|
220
|
+
this.code = code;
|
|
221
|
+
this.payload = payload;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
class CodexProtocolError extends Error {
|
|
226
|
+
readonly payload: unknown;
|
|
227
|
+
|
|
228
|
+
constructor(message: string, payload: unknown, cause: unknown) {
|
|
229
|
+
super(message, { cause });
|
|
230
|
+
this.name = "CodexProtocolError";
|
|
231
|
+
this.payload = payload;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
class WebSocketCloseError extends Error {
|
|
236
|
+
readonly code: number | undefined;
|
|
237
|
+
readonly reason: string | undefined;
|
|
238
|
+
readonly wasClean: boolean | undefined;
|
|
239
|
+
|
|
240
|
+
constructor(
|
|
241
|
+
message: string,
|
|
242
|
+
options: {
|
|
243
|
+
code: number | undefined;
|
|
244
|
+
reason: string | undefined;
|
|
245
|
+
wasClean: boolean | undefined;
|
|
246
|
+
},
|
|
247
|
+
) {
|
|
248
|
+
super(message);
|
|
249
|
+
this.name = "WebSocketCloseError";
|
|
250
|
+
this.code = options.code;
|
|
251
|
+
this.reason = options.reason;
|
|
252
|
+
this.wasClean = options.wasClean;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
78
255
|
|
|
79
256
|
function nodeOs(): typeof NodeOs | undefined {
|
|
80
257
|
const currentProcess = process as ProcessWithBuiltinModules;
|
|
@@ -86,8 +263,94 @@ function nodeZlib(): typeof NodeZlib | undefined {
|
|
|
86
263
|
return currentProcess.getBuiltinModule?.("node:zlib");
|
|
87
264
|
}
|
|
88
265
|
|
|
89
|
-
function
|
|
90
|
-
|
|
266
|
+
function extractWebSocketError(event: unknown): Error {
|
|
267
|
+
if (isObject(event)) {
|
|
268
|
+
if (typeof event["message"] === "string" && event["message"].length > 0) {
|
|
269
|
+
return new Error(event["message"]);
|
|
270
|
+
}
|
|
271
|
+
const nestedError = event["error"];
|
|
272
|
+
if (nestedError instanceof Error && nestedError.message.length > 0) return nestedError;
|
|
273
|
+
if (
|
|
274
|
+
isObject(nestedError) &&
|
|
275
|
+
typeof nestedError["message"] === "string" &&
|
|
276
|
+
nestedError["message"].length > 0
|
|
277
|
+
) {
|
|
278
|
+
return new Error(nestedError["message"]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return new Error("WebSocket error");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function extractWebSocketCloseError(
|
|
285
|
+
event: unknown,
|
|
286
|
+
context = "WebSocket closed",
|
|
287
|
+
): WebSocketCloseError {
|
|
288
|
+
const code = isObject(event) && typeof event["code"] === "number" ? event["code"] : undefined;
|
|
289
|
+
let reason =
|
|
290
|
+
isObject(event) && typeof event["reason"] === "string" && event["reason"].length > 0
|
|
291
|
+
? event["reason"]
|
|
292
|
+
: undefined;
|
|
293
|
+
if (reason === undefined && code === 1_009) reason = "message too big";
|
|
294
|
+
const wasClean =
|
|
295
|
+
isObject(event) && typeof event["wasClean"] === "boolean" ? event["wasClean"] : undefined;
|
|
296
|
+
const details = [
|
|
297
|
+
code === undefined ? undefined : `code ${code}`,
|
|
298
|
+
reason === undefined ? undefined : `reason: ${reason}`,
|
|
299
|
+
wasClean === undefined ? undefined : `wasClean: ${String(wasClean)}`,
|
|
300
|
+
].filter((detail): detail is string => detail !== undefined);
|
|
301
|
+
return new WebSocketCloseError(
|
|
302
|
+
details.length > 0 ? `${context} (${details.join(", ")})` : context,
|
|
303
|
+
{ code, reason, wasClean },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function thrownMessage(error: unknown): string {
|
|
308
|
+
return error instanceof Error ? error.message || error.name : String(error);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function isCodexNonTransportError(error: unknown): boolean {
|
|
312
|
+
return error instanceof CodexApiError || error instanceof CodexProtocolError;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isWebSocketConnectionLimitReachedError(error: unknown): boolean {
|
|
316
|
+
return error instanceof CodexApiError && error.code === WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isPreviousResponseNotFoundError(error: unknown): boolean {
|
|
320
|
+
return error instanceof CodexApiError && error.code === PREVIOUS_RESPONSE_NOT_FOUND_CODE;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function diagnosticError(error: unknown): CodexTransportDiagnostic["error"] {
|
|
324
|
+
if (!(error instanceof Error)) {
|
|
325
|
+
return { name: "ThrownValue", message: thrownMessage(error) };
|
|
326
|
+
}
|
|
327
|
+
const code = (error as Error & { code?: unknown }).code;
|
|
328
|
+
return {
|
|
329
|
+
...(error.name ? { name: error.name } : {}),
|
|
330
|
+
message: error.message || error.name,
|
|
331
|
+
...(error.stack ? { stack: error.stack } : {}),
|
|
332
|
+
...(typeof code === "string" || typeof code === "number" ? { code } : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function transportDiagnostic(
|
|
337
|
+
error: unknown,
|
|
338
|
+
transport: string,
|
|
339
|
+
emitted: boolean,
|
|
340
|
+
requestBytes: number,
|
|
341
|
+
): CodexTransportDiagnostic {
|
|
342
|
+
return {
|
|
343
|
+
type: "provider_transport_failure",
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
error: diagnosticError(error),
|
|
346
|
+
details: {
|
|
347
|
+
configuredTransport: transport,
|
|
348
|
+
...(!emitted ? { fallbackTransport: "sse" as const } : {}),
|
|
349
|
+
eventsEmitted: emitted,
|
|
350
|
+
phase: emitted ? "after_message_stream_start" : "before_message_stream_start",
|
|
351
|
+
requestBytes,
|
|
352
|
+
},
|
|
353
|
+
};
|
|
91
354
|
}
|
|
92
355
|
|
|
93
356
|
function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
@@ -108,6 +371,79 @@ function sleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
|
|
108
371
|
});
|
|
109
372
|
}
|
|
110
373
|
|
|
374
|
+
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
375
|
+
if (value === undefined) return undefined;
|
|
376
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
377
|
+
throw new Error(`Invalid timeoutMs: ${String(value)}`);
|
|
378
|
+
}
|
|
379
|
+
return Math.floor(value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function isTerminalRateLimitError(errorText: string): boolean {
|
|
383
|
+
return /GoUsageLimitError|FreeUsageLimitError|Monthly usage limit reached|available balance|insufficient_quota|out of budget|quota exceeded|billing/i.test(
|
|
384
|
+
errorText,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function retryDelayMs(headers: Headers): number | undefined {
|
|
389
|
+
const retryAfterMs = headers.get("retry-after-ms");
|
|
390
|
+
if (retryAfterMs !== null) {
|
|
391
|
+
const milliseconds = Number(retryAfterMs);
|
|
392
|
+
if (Number.isFinite(milliseconds)) return Math.max(0, milliseconds);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const retryAfter = headers.get("retry-after");
|
|
396
|
+
if (!retryAfter) return undefined;
|
|
397
|
+
const seconds = Number(retryAfter);
|
|
398
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
|
|
399
|
+
const date = Date.parse(retryAfter);
|
|
400
|
+
return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
class RetryDelayExceededError extends Error {}
|
|
404
|
+
|
|
405
|
+
function validateRetryDelay(delayMs: number, options: CodexTransportOptions): number {
|
|
406
|
+
const maximum = options.maxRetryDelayMs ?? DEFAULT_MAX_RETRY_DELAY_MS;
|
|
407
|
+
if (maximum > 0 && delayMs > maximum) {
|
|
408
|
+
throw new RetryDelayExceededError(
|
|
409
|
+
`Server requested ${Math.ceil(delayMs / 1_000)}s retry delay (max: ${Math.ceil(maximum / 1_000)}s)`,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return delayMs;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function codexHttpError(status: number, statusText: string, raw: string): Error {
|
|
416
|
+
let message = raw || statusText || "Request failed";
|
|
417
|
+
let friendlyMessage: string | undefined;
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(raw) as {
|
|
420
|
+
error?: {
|
|
421
|
+
code?: string;
|
|
422
|
+
type?: string;
|
|
423
|
+
message?: string;
|
|
424
|
+
plan_type?: string;
|
|
425
|
+
resets_at?: number;
|
|
426
|
+
};
|
|
427
|
+
};
|
|
428
|
+
const error = parsed?.error;
|
|
429
|
+
if (!error) return new Error(message);
|
|
430
|
+
const code = error.code || error.type || "";
|
|
431
|
+
if (
|
|
432
|
+
status === 429 ||
|
|
433
|
+
/usage_limit_reached|usage_not_included|rate_limit_exceeded/i.test(code)
|
|
434
|
+
) {
|
|
435
|
+
const plan = error.plan_type ? ` (${error.plan_type.toLowerCase()} plan)` : "";
|
|
436
|
+
const resetMinutes = error.resets_at
|
|
437
|
+
? Math.max(0, Math.round((error.resets_at * 1_000 - Date.now()) / 60_000))
|
|
438
|
+
: undefined;
|
|
439
|
+
const reset = resetMinutes === undefined ? "" : ` Try again in ~${String(resetMinutes)} min.`;
|
|
440
|
+
friendlyMessage = `You have hit your ChatGPT usage limit${plan}.${reset}`.trim();
|
|
441
|
+
}
|
|
442
|
+
message = error.message || friendlyMessage || message;
|
|
443
|
+
} catch {}
|
|
444
|
+
return new Error(friendlyMessage || message);
|
|
445
|
+
}
|
|
446
|
+
|
|
111
447
|
function combineAbortSignals(signals: readonly (AbortSignal | undefined)[]): {
|
|
112
448
|
signal?: AbortSignal;
|
|
113
449
|
cleanup(): void;
|
|
@@ -150,7 +486,11 @@ function extractAccountId(token: string): string {
|
|
|
150
486
|
if (parts.length !== 3) throw new Error("Invalid token");
|
|
151
487
|
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonRecord;
|
|
152
488
|
const authentication = payload["https://api.openai.com/auth"];
|
|
153
|
-
if (
|
|
489
|
+
if (
|
|
490
|
+
!isObject(authentication) ||
|
|
491
|
+
typeof authentication["chatgpt_account_id"] !== "string" ||
|
|
492
|
+
authentication["chatgpt_account_id"].length === 0
|
|
493
|
+
) {
|
|
154
494
|
throw new Error("No account ID");
|
|
155
495
|
}
|
|
156
496
|
return authentication["chatgpt_account_id"];
|
|
@@ -159,6 +499,11 @@ function extractAccountId(token: string): string {
|
|
|
159
499
|
}
|
|
160
500
|
}
|
|
161
501
|
|
|
502
|
+
export function validateCodexAuthentication(model: Model<any>, apiKey: string | undefined): string {
|
|
503
|
+
if (!apiKey) throw new Error(`No API key for provider: ${model.provider}`);
|
|
504
|
+
return extractAccountId(apiKey);
|
|
505
|
+
}
|
|
506
|
+
|
|
162
507
|
function resolveCodexUrl(baseUrl?: string): string {
|
|
163
508
|
const raw = baseUrl?.trim() || DEFAULT_CODEX_BASE_URL;
|
|
164
509
|
const normalized = raw.replace(/\/+$/, "");
|
|
@@ -268,13 +613,13 @@ function compressBody(body: string): Uint8Array | undefined {
|
|
|
268
613
|
}
|
|
269
614
|
|
|
270
615
|
function isRetryable(status: number, text: string): boolean {
|
|
271
|
-
if (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
) {
|
|
275
|
-
return false;
|
|
616
|
+
if (status === 429 && isTerminalRateLimitError(text)) return false;
|
|
617
|
+
if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) {
|
|
618
|
+
return true;
|
|
276
619
|
}
|
|
277
|
-
return
|
|
620
|
+
return /rate.?limit|overloaded|service.?unavailable|upstream.?connect|connection.?refused/i.test(
|
|
621
|
+
text,
|
|
622
|
+
);
|
|
278
623
|
}
|
|
279
624
|
|
|
280
625
|
async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerator<JsonRecord> {
|
|
@@ -289,6 +634,7 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
289
634
|
while (true) {
|
|
290
635
|
if (signal?.aborted) throw new Error("Request was aborted");
|
|
291
636
|
const { done, value } = await reader.read();
|
|
637
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
292
638
|
if (done) break;
|
|
293
639
|
buffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
|
|
294
640
|
let boundary = buffer.indexOf("\n\n");
|
|
@@ -302,7 +648,16 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
302
648
|
.join("\n")
|
|
303
649
|
.trim();
|
|
304
650
|
if (data && data !== "[DONE]") {
|
|
305
|
-
|
|
651
|
+
let parsed: unknown;
|
|
652
|
+
try {
|
|
653
|
+
parsed = JSON.parse(data) as unknown;
|
|
654
|
+
} catch (error) {
|
|
655
|
+
throw new CodexProtocolError(
|
|
656
|
+
`Invalid Codex SSE JSON: ${thrownMessage(error)}`,
|
|
657
|
+
data,
|
|
658
|
+
error,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
306
661
|
if (!isObject(parsed)) throw new Error("Invalid Codex SSE event");
|
|
307
662
|
yield parsed;
|
|
308
663
|
}
|
|
@@ -312,7 +667,9 @@ async function* parseSse(response: Response, signal?: AbortSignal): AsyncGenerat
|
|
|
312
667
|
} finally {
|
|
313
668
|
signal?.removeEventListener("abort", onAbort);
|
|
314
669
|
await reader.cancel().catch(() => {});
|
|
315
|
-
|
|
670
|
+
try {
|
|
671
|
+
reader.releaseLock();
|
|
672
|
+
} catch {}
|
|
316
673
|
}
|
|
317
674
|
}
|
|
318
675
|
|
|
@@ -331,12 +688,14 @@ function socketReusable(socket: WebSocketLike): boolean {
|
|
|
331
688
|
return socket.readyState === undefined || socket.readyState === 1;
|
|
332
689
|
}
|
|
333
690
|
|
|
334
|
-
function scheduleSocketExpiry(sessionId: string, entry: CachedWebSocket): void {
|
|
691
|
+
function scheduleSocketExpiry(sessionId: string, accountId: string, entry: CachedWebSocket): void {
|
|
335
692
|
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
336
693
|
entry.idleTimer = setTimeout(() => {
|
|
337
694
|
if (entry.busy) return;
|
|
338
695
|
closeSocket(entry.socket, "idle_timeout");
|
|
339
|
-
websocketSessions.
|
|
696
|
+
const accountEntries = websocketSessions.get(sessionId);
|
|
697
|
+
if (accountEntries?.get(accountId) === entry) accountEntries.delete(accountId);
|
|
698
|
+
if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
340
699
|
}, SESSION_WEBSOCKET_CACHE_TTL_MS);
|
|
341
700
|
}
|
|
342
701
|
|
|
@@ -347,7 +706,9 @@ async function connectWebSocket(
|
|
|
347
706
|
timeoutMs: number,
|
|
348
707
|
): Promise<WebSocketLike> {
|
|
349
708
|
const WebSocketClass = websocketConstructor();
|
|
350
|
-
if (!WebSocketClass)
|
|
709
|
+
if (!WebSocketClass) {
|
|
710
|
+
throw new Error("WebSocket transport is not available in this runtime");
|
|
711
|
+
}
|
|
351
712
|
const requestHeaders = headersToRecord(headers);
|
|
352
713
|
delete requestHeaders["OpenAI-Beta"];
|
|
353
714
|
|
|
@@ -363,11 +724,11 @@ async function connectWebSocket(
|
|
|
363
724
|
socket.removeEventListener("close", onClose);
|
|
364
725
|
signal?.removeEventListener("abort", onAbort);
|
|
365
726
|
};
|
|
366
|
-
const fail = (error: Error) => {
|
|
727
|
+
const fail = (error: Error, closeReason: string) => {
|
|
367
728
|
if (settled) return;
|
|
368
729
|
settled = true;
|
|
369
730
|
cleanup();
|
|
370
|
-
closeSocket(socket,
|
|
731
|
+
closeSocket(socket, closeReason);
|
|
371
732
|
reject(error);
|
|
372
733
|
};
|
|
373
734
|
const onOpen = () => {
|
|
@@ -376,25 +737,27 @@ async function connectWebSocket(
|
|
|
376
737
|
cleanup();
|
|
377
738
|
resolve(socket);
|
|
378
739
|
};
|
|
379
|
-
const onError = (event: unknown) => fail(
|
|
740
|
+
const onError = (event: unknown) => fail(extractWebSocketError(event), "connect_failure");
|
|
380
741
|
const onClose = (event: unknown) =>
|
|
381
|
-
fail(
|
|
382
|
-
const onAbort = () => fail(new Error("Request was aborted"));
|
|
742
|
+
fail(extractWebSocketCloseError(event, "WebSocket closed during connect"), "connect_failure");
|
|
743
|
+
const onAbort = () => fail(new Error("Request was aborted"), "aborted");
|
|
383
744
|
|
|
384
745
|
try {
|
|
385
746
|
socket = new WebSocketClass(url, { headers: requestHeaders });
|
|
386
747
|
} catch (error) {
|
|
387
|
-
reject(error);
|
|
748
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
388
749
|
return;
|
|
389
750
|
}
|
|
390
751
|
socket.addEventListener("open", onOpen);
|
|
391
752
|
socket.addEventListener("error", onError);
|
|
392
753
|
socket.addEventListener("close", onClose);
|
|
393
754
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
755
|
+
if (timeoutMs > 0) {
|
|
756
|
+
timer = setTimeout(
|
|
757
|
+
() => fail(new Error(`WebSocket connect timeout after ${timeoutMs}ms`), "connect_timeout"),
|
|
758
|
+
timeoutMs,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
398
761
|
if (signal?.aborted) onAbort();
|
|
399
762
|
});
|
|
400
763
|
}
|
|
@@ -403,11 +766,19 @@ async function acquireWebSocket(
|
|
|
403
766
|
url: string,
|
|
404
767
|
headers: Headers,
|
|
405
768
|
sessionId: string | undefined,
|
|
769
|
+
accountId: string,
|
|
406
770
|
signal: AbortSignal | undefined,
|
|
407
771
|
timeoutMs: number,
|
|
408
|
-
): Promise<{
|
|
772
|
+
): Promise<{
|
|
773
|
+
socket: WebSocketLike;
|
|
774
|
+
entry?: CachedWebSocket;
|
|
775
|
+
reused: boolean;
|
|
776
|
+
release(keep: boolean): void;
|
|
777
|
+
}> {
|
|
778
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
409
779
|
if (sessionId) {
|
|
410
|
-
|
|
780
|
+
let accountEntries = websocketSessions.get(sessionId);
|
|
781
|
+
const cached = accountEntries?.get(accountId);
|
|
411
782
|
if (cached?.idleTimer) {
|
|
412
783
|
clearTimeout(cached.idleTimer);
|
|
413
784
|
delete cached.idleTimer;
|
|
@@ -418,70 +789,101 @@ async function acquireWebSocket(
|
|
|
418
789
|
return {
|
|
419
790
|
socket: cached.socket,
|
|
420
791
|
entry: cached,
|
|
792
|
+
reused: true,
|
|
421
793
|
release(keep) {
|
|
422
794
|
if (!keep || !socketReusable(cached.socket)) {
|
|
795
|
+
if (cached.idleTimer) {
|
|
796
|
+
clearTimeout(cached.idleTimer);
|
|
797
|
+
delete cached.idleTimer;
|
|
798
|
+
}
|
|
423
799
|
closeSocket(cached.socket);
|
|
424
|
-
websocketSessions.
|
|
800
|
+
const currentEntries = websocketSessions.get(sessionId);
|
|
801
|
+
if (currentEntries?.get(accountId) === cached) currentEntries.delete(accountId);
|
|
802
|
+
if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
425
803
|
return;
|
|
426
804
|
}
|
|
427
805
|
cached.busy = false;
|
|
428
|
-
scheduleSocketExpiry(sessionId, cached);
|
|
806
|
+
scheduleSocketExpiry(sessionId, accountId, cached);
|
|
429
807
|
},
|
|
430
808
|
};
|
|
431
809
|
}
|
|
432
810
|
if (cached && !cached.busy) {
|
|
433
|
-
closeSocket(cached.socket);
|
|
434
|
-
|
|
811
|
+
closeSocket(cached.socket, expired ? "connection_age_limit" : "done");
|
|
812
|
+
accountEntries?.delete(accountId);
|
|
813
|
+
if (accountEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
814
|
+
}
|
|
815
|
+
if (cached?.busy) {
|
|
816
|
+
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
|
|
817
|
+
return { socket, reused: false, release: () => closeSocket(socket) };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const socket = await connectWebSocket(url, headers, signal, timeoutMs);
|
|
821
|
+
const entry: CachedWebSocket = { socket, busy: true, createdAt: Date.now() };
|
|
822
|
+
accountEntries = websocketSessions.get(sessionId);
|
|
823
|
+
if (!accountEntries) {
|
|
824
|
+
accountEntries = new Map();
|
|
825
|
+
websocketSessions.set(sessionId, accountEntries);
|
|
435
826
|
}
|
|
827
|
+
accountEntries.set(accountId, entry);
|
|
828
|
+
return {
|
|
829
|
+
socket,
|
|
830
|
+
entry,
|
|
831
|
+
reused: false,
|
|
832
|
+
release(keep) {
|
|
833
|
+
if (!keep || !socketReusable(socket)) {
|
|
834
|
+
closeSocket(socket);
|
|
835
|
+
if (entry.idleTimer) clearTimeout(entry.idleTimer);
|
|
836
|
+
const currentEntries = websocketSessions.get(sessionId);
|
|
837
|
+
if (currentEntries?.get(accountId) === entry) currentEntries.delete(accountId);
|
|
838
|
+
if (currentEntries?.size === 0) websocketSessions.delete(sessionId);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
entry.busy = false;
|
|
842
|
+
scheduleSocketExpiry(sessionId, accountId, entry);
|
|
843
|
+
},
|
|
844
|
+
};
|
|
436
845
|
}
|
|
437
846
|
|
|
438
847
|
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
|
-
};
|
|
848
|
+
return { socket, reused: false, release: () => closeSocket(socket) };
|
|
457
849
|
}
|
|
458
850
|
|
|
459
851
|
function requestWithoutHistory(body: JsonRecord): JsonRecord {
|
|
460
|
-
const result =
|
|
852
|
+
const result = { ...body };
|
|
461
853
|
delete result.input;
|
|
462
854
|
delete result.previous_response_id;
|
|
463
855
|
return result;
|
|
464
856
|
}
|
|
465
857
|
|
|
858
|
+
function jsonWireRequestBody(body: JsonRecord): JsonRecord {
|
|
859
|
+
const snapshot = JSON.parse(JSON.stringify(body)) as unknown;
|
|
860
|
+
if (!isObject(snapshot)) {
|
|
861
|
+
throw new Error("Codex request body must serialize to a JSON object");
|
|
862
|
+
}
|
|
863
|
+
return snapshot;
|
|
864
|
+
}
|
|
865
|
+
|
|
466
866
|
function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord {
|
|
467
867
|
const continuation = entry.continuation;
|
|
468
868
|
if (!continuation) return body;
|
|
469
869
|
if (
|
|
470
|
-
|
|
471
|
-
|
|
870
|
+
JSON.stringify(requestWithoutHistory(body)) !==
|
|
871
|
+
JSON.stringify(requestWithoutHistory(continuation.lastRequestBody))
|
|
472
872
|
) {
|
|
473
873
|
delete entry.continuation;
|
|
474
874
|
return body;
|
|
475
875
|
}
|
|
476
876
|
|
|
477
|
-
const currentInput =
|
|
478
|
-
const previousInput =
|
|
479
|
-
|
|
480
|
-
|
|
877
|
+
const currentInput = body.input ?? [];
|
|
878
|
+
const previousInput = continuation.lastRequestBody.input ?? [];
|
|
879
|
+
if (!Array.isArray(currentInput) || !Array.isArray(previousInput)) {
|
|
880
|
+
delete entry.continuation;
|
|
881
|
+
return body;
|
|
882
|
+
}
|
|
481
883
|
const baseline = [...previousInput, ...continuation.lastResponseItems];
|
|
482
884
|
if (
|
|
483
885
|
currentInput.length < baseline.length ||
|
|
484
|
-
|
|
886
|
+
JSON.stringify(currentInput.slice(0, baseline.length)) !== JSON.stringify(baseline)
|
|
485
887
|
) {
|
|
486
888
|
delete entry.continuation;
|
|
487
889
|
return body;
|
|
@@ -494,6 +896,10 @@ function cachedRequestBody(entry: CachedWebSocket, body: JsonRecord): JsonRecord
|
|
|
494
896
|
};
|
|
495
897
|
}
|
|
496
898
|
|
|
899
|
+
function requestInputLength(body: JsonRecord): number {
|
|
900
|
+
return typeof body.input === "string" || Array.isArray(body.input) ? body.input.length : 0;
|
|
901
|
+
}
|
|
902
|
+
|
|
497
903
|
async function decodeWebSocketData(data: unknown): Promise<string | undefined> {
|
|
498
904
|
if (typeof data === "string") return data;
|
|
499
905
|
if (data instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(data));
|
|
@@ -525,9 +931,10 @@ async function* parseWebSocket(
|
|
|
525
931
|
};
|
|
526
932
|
const onMessage = (event: unknown) => {
|
|
527
933
|
void (async () => {
|
|
934
|
+
let text: string | undefined;
|
|
528
935
|
try {
|
|
529
936
|
if (!isObject(event)) return;
|
|
530
|
-
|
|
937
|
+
text = await decodeWebSocketData(event["data"]);
|
|
531
938
|
if (!text) return;
|
|
532
939
|
const parsed = JSON.parse(text) as unknown;
|
|
533
940
|
if (!isObject(parsed)) throw new Error("Invalid WebSocket event");
|
|
@@ -543,19 +950,23 @@ async function* parseWebSocket(
|
|
|
543
950
|
queue.push(parsed);
|
|
544
951
|
notify();
|
|
545
952
|
} catch (error) {
|
|
546
|
-
failure =
|
|
953
|
+
failure = new CodexProtocolError(
|
|
954
|
+
`Invalid Codex WebSocket JSON: ${thrownMessage(error)}`,
|
|
955
|
+
text,
|
|
956
|
+
error,
|
|
957
|
+
);
|
|
547
958
|
done = true;
|
|
548
959
|
notify();
|
|
549
960
|
}
|
|
550
961
|
})();
|
|
551
962
|
};
|
|
552
963
|
const onError = (event: unknown) => {
|
|
553
|
-
failure =
|
|
964
|
+
if (!failure) failure = extractWebSocketError(event);
|
|
554
965
|
done = true;
|
|
555
966
|
notify();
|
|
556
967
|
};
|
|
557
968
|
const onClose = (event: unknown) => {
|
|
558
|
-
if (!terminal) failure =
|
|
969
|
+
if (!terminal && !failure) failure = extractWebSocketCloseError(event);
|
|
559
970
|
done = true;
|
|
560
971
|
notify();
|
|
561
972
|
};
|
|
@@ -571,6 +982,7 @@ async function* parseWebSocket(
|
|
|
571
982
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
572
983
|
try {
|
|
573
984
|
while (true) {
|
|
985
|
+
if (signal?.aborted) throw new Error("Request was aborted");
|
|
574
986
|
if (queue.length > 0) {
|
|
575
987
|
yield queue.shift()!;
|
|
576
988
|
continue;
|
|
@@ -579,10 +991,14 @@ async function* parseWebSocket(
|
|
|
579
991
|
await new Promise<void>((resolve, reject) => {
|
|
580
992
|
wake = resolve;
|
|
581
993
|
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
582
|
-
const timer = setTimeout(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
994
|
+
const timer = setTimeout(() => {
|
|
995
|
+
const error = new Error(`WebSocket idle timeout after ${timeoutMs}ms`);
|
|
996
|
+
failure = error;
|
|
997
|
+
done = true;
|
|
998
|
+
wake = undefined;
|
|
999
|
+
closeSocket(socket, "idle_timeout");
|
|
1000
|
+
reject(error);
|
|
1001
|
+
}, timeoutMs);
|
|
586
1002
|
const priorWake = wake;
|
|
587
1003
|
wake = () => {
|
|
588
1004
|
clearTimeout(timer);
|
|
@@ -592,7 +1008,7 @@ async function* parseWebSocket(
|
|
|
592
1008
|
});
|
|
593
1009
|
}
|
|
594
1010
|
if (failure) throw failure;
|
|
595
|
-
if (!terminal) throw new Error("WebSocket
|
|
1011
|
+
if (!terminal) throw new Error("WebSocket stream closed before response.completed");
|
|
596
1012
|
} finally {
|
|
597
1013
|
socket.removeEventListener("message", onMessage);
|
|
598
1014
|
socket.removeEventListener("error", onError);
|
|
@@ -601,24 +1017,35 @@ async function* parseWebSocket(
|
|
|
601
1017
|
}
|
|
602
1018
|
}
|
|
603
1019
|
|
|
604
|
-
function normalizeEvent(event: JsonRecord): JsonRecord {
|
|
605
|
-
const type = event.type;
|
|
1020
|
+
function normalizeEvent(event: JsonRecord): JsonRecord | undefined {
|
|
1021
|
+
const type = typeof event.type === "string" ? event.type : undefined;
|
|
1022
|
+
if (!type) return undefined;
|
|
606
1023
|
if (type === "error") {
|
|
607
1024
|
const nested = isObject(event["error"]) ? event["error"] : undefined;
|
|
608
|
-
|
|
1025
|
+
const code =
|
|
1026
|
+
typeof event["code"] === "string"
|
|
1027
|
+
? event["code"]
|
|
1028
|
+
: typeof nested?.["code"] === "string"
|
|
1029
|
+
? nested["code"]
|
|
1030
|
+
: undefined;
|
|
1031
|
+
const message =
|
|
609
1032
|
typeof event["message"] === "string"
|
|
610
1033
|
? event["message"]
|
|
611
1034
|
: typeof nested?.["message"] === "string"
|
|
612
1035
|
? nested["message"]
|
|
613
|
-
:
|
|
1036
|
+
: undefined;
|
|
1037
|
+
throw new CodexApiError(
|
|
1038
|
+
`Codex error: ${message || code || JSON.stringify(event)}`,
|
|
1039
|
+
code,
|
|
1040
|
+
event,
|
|
614
1041
|
);
|
|
615
1042
|
}
|
|
616
1043
|
if (type === "response.failed") {
|
|
617
1044
|
const response = isObject(event.response) ? event.response : undefined;
|
|
618
1045
|
const error = isObject(response?.["error"]) ? response["error"] : undefined;
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
);
|
|
1046
|
+
const message = typeof error?.["message"] === "string" ? error["message"] : undefined;
|
|
1047
|
+
const code = typeof error?.["code"] === "string" ? error["code"] : undefined;
|
|
1048
|
+
throw new CodexApiError(message || "Codex response failed", code, event);
|
|
622
1049
|
}
|
|
623
1050
|
if (type === "response.done") {
|
|
624
1051
|
return { ...event, type: "response.completed" };
|
|
@@ -626,59 +1053,93 @@ function normalizeEvent(event: JsonRecord): JsonRecord {
|
|
|
626
1053
|
return event;
|
|
627
1054
|
}
|
|
628
1055
|
|
|
1056
|
+
function isTerminalEvent(event: JsonRecord): boolean {
|
|
1057
|
+
return event.type === "response.completed" || event.type === "response.incomplete";
|
|
1058
|
+
}
|
|
1059
|
+
|
|
629
1060
|
async function* requestSse(
|
|
630
1061
|
model: Model<any>,
|
|
631
|
-
|
|
1062
|
+
bodyJson: string,
|
|
632
1063
|
options: CodexTransportOptions,
|
|
633
1064
|
headers: Headers,
|
|
1065
|
+
timeoutMs: number | undefined,
|
|
634
1066
|
): AsyncGenerator<JsonRecord> {
|
|
635
|
-
const bodyJson = JSON.stringify(body);
|
|
636
1067
|
const compressed = compressBody(bodyJson);
|
|
637
1068
|
if (compressed) headers.set("content-encoding", "zstd");
|
|
638
1069
|
const requestBody = compressed ?? bodyJson;
|
|
639
1070
|
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
1071
|
+
let response: Response | undefined;
|
|
1072
|
+
let lastError: Error | undefined;
|
|
640
1073
|
|
|
641
1074
|
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;
|
|
1075
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
648
1076
|
try {
|
|
1077
|
+
const timeoutSignal =
|
|
1078
|
+
timeoutMs !== undefined && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined;
|
|
1079
|
+
const combined = combineAbortSignals([options.signal, timeoutSignal]);
|
|
649
1080
|
try {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
1081
|
+
try {
|
|
1082
|
+
response = await (options.fetch ?? globalThis.fetch)(resolveCodexUrl(model.baseUrl), {
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
headers,
|
|
1085
|
+
body: requestBody,
|
|
1086
|
+
...(combined.signal ? { signal: combined.signal } : {}),
|
|
1087
|
+
});
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (timeoutSignal?.aborted && !options.signal?.aborted) {
|
|
1090
|
+
throw new Error(`Codex SSE response headers timed out after ${String(timeoutMs)}ms`);
|
|
1091
|
+
}
|
|
1092
|
+
throw error;
|
|
660
1093
|
}
|
|
661
|
-
|
|
1094
|
+
} finally {
|
|
1095
|
+
combined.cleanup();
|
|
662
1096
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
);
|
|
670
|
-
if (!response.ok) {
|
|
1097
|
+
await options.onResponse?.(
|
|
1098
|
+
{ status: response.status, headers: headersToRecord(response.headers) },
|
|
1099
|
+
model,
|
|
1100
|
+
);
|
|
1101
|
+
if (response.ok) break;
|
|
1102
|
+
|
|
671
1103
|
const errorText = await response.text();
|
|
672
1104
|
if (attempt < maxRetries && isRetryable(response.status, errorText)) {
|
|
1105
|
+
const requestedDelay = retryDelayMs(response.headers);
|
|
1106
|
+
const delay =
|
|
1107
|
+
requestedDelay === undefined
|
|
1108
|
+
? BASE_DELAY_MS * 2 ** attempt
|
|
1109
|
+
: validateRetryDelay(requestedDelay, options);
|
|
1110
|
+
await sleep(delay, options.signal);
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
throw codexHttpError(response.status, response.statusText, errorText);
|
|
1114
|
+
} catch (error) {
|
|
1115
|
+
if (
|
|
1116
|
+
options.signal?.aborted ||
|
|
1117
|
+
(error instanceof Error &&
|
|
1118
|
+
(error.name === "AbortError" || error.message === "Request was aborted"))
|
|
1119
|
+
) {
|
|
1120
|
+
throw new Error("Request was aborted");
|
|
1121
|
+
}
|
|
1122
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
1123
|
+
if (
|
|
1124
|
+
attempt < maxRetries &&
|
|
1125
|
+
!(lastError instanceof RetryDelayExceededError) &&
|
|
1126
|
+
!lastError.message.includes("usage limit")
|
|
1127
|
+
) {
|
|
673
1128
|
await sleep(BASE_DELAY_MS * 2 ** attempt, options.signal);
|
|
674
1129
|
continue;
|
|
675
1130
|
}
|
|
676
|
-
throw
|
|
1131
|
+
throw lastError;
|
|
677
1132
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (!response?.ok) throw lastError ?? new Error("Failed after retries");
|
|
1136
|
+
if (!response.body) throw new Error("No response body");
|
|
1137
|
+
options.onTransportStart?.();
|
|
1138
|
+
for await (const event of parseSse(response, options.signal)) {
|
|
1139
|
+
const normalized = normalizeEvent(event);
|
|
1140
|
+
if (!normalized) continue;
|
|
1141
|
+
yield normalized;
|
|
1142
|
+
if (isTerminalEvent(normalized)) return;
|
|
682
1143
|
}
|
|
683
1144
|
}
|
|
684
1145
|
|
|
@@ -688,24 +1149,74 @@ async function* requestWebSocket(
|
|
|
688
1149
|
options: CodexTransportOptions,
|
|
689
1150
|
headers: Headers,
|
|
690
1151
|
sessionId: string | undefined,
|
|
1152
|
+
accountId: string,
|
|
1153
|
+
timeoutMs: number | undefined,
|
|
1154
|
+
connectTimeoutMs: number,
|
|
1155
|
+
requestBytes: number,
|
|
691
1156
|
): AsyncGenerator<JsonRecord> {
|
|
692
1157
|
const acquired = await acquireWebSocket(
|
|
693
1158
|
resolveCodexWebSocketUrl(model.baseUrl),
|
|
694
1159
|
headers,
|
|
695
1160
|
sessionId,
|
|
1161
|
+
accountId,
|
|
696
1162
|
options.signal,
|
|
697
|
-
|
|
1163
|
+
connectTimeoutMs,
|
|
698
1164
|
);
|
|
699
|
-
let keep =
|
|
1165
|
+
let keep = false;
|
|
1166
|
+
let responseHandleActive = true;
|
|
1167
|
+
const discard = (): boolean => {
|
|
1168
|
+
if (!responseHandleActive) return false;
|
|
1169
|
+
responseHandleActive = false;
|
|
1170
|
+
if (acquired.entry) delete acquired.entry.continuation;
|
|
1171
|
+
acquired.release(false);
|
|
1172
|
+
return true;
|
|
1173
|
+
};
|
|
700
1174
|
try {
|
|
1175
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
701
1176
|
const useContinuation =
|
|
702
1177
|
options.transport === "auto" || options.transport === "websocket-cached";
|
|
1178
|
+
const fullBody = useContinuation && acquired.entry ? jsonWireRequestBody(body) : body;
|
|
703
1179
|
const requestBody =
|
|
704
|
-
useContinuation && acquired.entry ? cachedRequestBody(acquired.entry,
|
|
1180
|
+
useContinuation && acquired.entry ? cachedRequestBody(acquired.entry, fullBody) : fullBody;
|
|
1181
|
+
const stats = sessionId ? getOrCreateWebSocketDebugStats(sessionId) : undefined;
|
|
1182
|
+
if (stats) {
|
|
1183
|
+
stats.requests += 1;
|
|
1184
|
+
if (acquired.reused) stats.connectionsReused += 1;
|
|
1185
|
+
else stats.connectionsCreated += 1;
|
|
1186
|
+
if (useContinuation) stats.cachedContextRequests += 1;
|
|
1187
|
+
if (requestBody.store === true) stats.storeTrueRequests += 1;
|
|
1188
|
+
stats.lastInputItems = requestInputLength(requestBody);
|
|
1189
|
+
if (requestBody.previous_response_id) {
|
|
1190
|
+
stats.deltaRequests += 1;
|
|
1191
|
+
stats.lastDeltaInputItems = requestInputLength(requestBody);
|
|
1192
|
+
stats.lastPreviousResponseId = requestBody.previous_response_id as string;
|
|
1193
|
+
} else {
|
|
1194
|
+
stats.fullContextRequests += 1;
|
|
1195
|
+
delete stats.lastDeltaInputItems;
|
|
1196
|
+
delete stats.lastPreviousResponseId;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
options.onWebSocketResponseHandle?.({
|
|
1200
|
+
discard,
|
|
1201
|
+
failParsing(error) {
|
|
1202
|
+
if (!discard()) return false;
|
|
1203
|
+
options.onTransportDiagnostic?.(
|
|
1204
|
+
transportDiagnostic(error, options.transport ?? "auto", true, requestBytes),
|
|
1205
|
+
);
|
|
1206
|
+
return true;
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
705
1209
|
acquired.socket.send(JSON.stringify({ type: "response.create", ...requestBody }));
|
|
706
1210
|
const responseItems: JsonRecord[] = [];
|
|
707
1211
|
let responseId: string | undefined;
|
|
708
|
-
for await (const event of parseWebSocket(acquired.socket, options.signal,
|
|
1212
|
+
for await (const event of parseWebSocket(acquired.socket, options.signal, timeoutMs)) {
|
|
1213
|
+
if (
|
|
1214
|
+
event.type === "response.created" &&
|
|
1215
|
+
isObject(event.response) &&
|
|
1216
|
+
typeof event.response.id === "string"
|
|
1217
|
+
) {
|
|
1218
|
+
responseId = event.response.id;
|
|
1219
|
+
}
|
|
709
1220
|
if (event.type === "response.output_item.done" && isObject(event.item)) {
|
|
710
1221
|
responseItems.push(structuredClone(event.item));
|
|
711
1222
|
}
|
|
@@ -727,20 +1238,43 @@ async function* requestWebSocket(
|
|
|
727
1238
|
}
|
|
728
1239
|
}
|
|
729
1240
|
}
|
|
730
|
-
|
|
1241
|
+
const normalized = normalizeEvent(event);
|
|
1242
|
+
if (!normalized) continue;
|
|
1243
|
+
yield normalized;
|
|
1244
|
+
if (isTerminalEvent(normalized)) break;
|
|
731
1245
|
}
|
|
1246
|
+
if (options.signal?.aborted) throw new Error("Request was aborted");
|
|
732
1247
|
if (useContinuation && acquired.entry && responseId) {
|
|
733
|
-
|
|
734
|
-
|
|
1248
|
+
const entry = acquired.entry;
|
|
1249
|
+
const continuation = {
|
|
1250
|
+
lastRequestBody: fullBody,
|
|
735
1251
|
lastResponseId: responseId,
|
|
736
1252
|
lastResponseItems: responseItems.map(normalizeReplayItem),
|
|
737
1253
|
};
|
|
1254
|
+
entry.continuation = continuation;
|
|
1255
|
+
if (sessionId) {
|
|
1256
|
+
options.onContinuationReady?.({
|
|
1257
|
+
responseId,
|
|
1258
|
+
replaceResponseItems(items) {
|
|
1259
|
+
if (
|
|
1260
|
+
websocketSessions.get(sessionId)?.get(accountId) !== entry ||
|
|
1261
|
+
entry.continuation !== continuation ||
|
|
1262
|
+
continuation.lastResponseId !== responseId
|
|
1263
|
+
) {
|
|
1264
|
+
return false;
|
|
1265
|
+
}
|
|
1266
|
+
continuation.lastResponseItems = items.map((item) => structuredClone(item));
|
|
1267
|
+
return true;
|
|
1268
|
+
},
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
738
1271
|
}
|
|
1272
|
+
keep = true;
|
|
739
1273
|
} catch (error) {
|
|
740
|
-
|
|
741
|
-
keep = false;
|
|
1274
|
+
responseHandleActive = false;
|
|
742
1275
|
throw error;
|
|
743
1276
|
} finally {
|
|
1277
|
+
if (!keep && acquired.entry) delete acquired.entry.continuation;
|
|
744
1278
|
acquired.release(keep);
|
|
745
1279
|
}
|
|
746
1280
|
}
|
|
@@ -755,7 +1289,7 @@ export async function requestCodexJson(
|
|
|
755
1289
|
model.headers,
|
|
756
1290
|
options.headers,
|
|
757
1291
|
options.extraHeaders,
|
|
758
|
-
|
|
1292
|
+
validateCodexAuthentication(model, options.apiKey),
|
|
759
1293
|
options.apiKey,
|
|
760
1294
|
);
|
|
761
1295
|
const response = await (options.fetch ?? globalThis.fetch)(
|
|
@@ -769,7 +1303,7 @@ export async function requestCodexJson(
|
|
|
769
1303
|
);
|
|
770
1304
|
const responseText = await response.text();
|
|
771
1305
|
if (!response.ok) {
|
|
772
|
-
throw
|
|
1306
|
+
throw codexHttpError(response.status, response.statusText, responseText);
|
|
773
1307
|
}
|
|
774
1308
|
try {
|
|
775
1309
|
return JSON.parse(responseText) as unknown;
|
|
@@ -789,37 +1323,70 @@ export class CodexTransport {
|
|
|
789
1323
|
options: CodexTransportOptions,
|
|
790
1324
|
): AsyncGenerator<JsonRecord> {
|
|
791
1325
|
if (!options.apiKey) throw new Error(`No API key for provider: ${model.provider}`);
|
|
792
|
-
const
|
|
793
|
-
const
|
|
1326
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
1327
|
+
const connectTimeoutMs =
|
|
1328
|
+
normalizeTimeoutMs(options.websocketConnectTimeoutMs) ?? DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS;
|
|
1329
|
+
const bodyJson = JSON.stringify(body);
|
|
1330
|
+
const requestBytes = new TextEncoder().encode(bodyJson).byteLength;
|
|
1331
|
+
const accountId = options.accountId ?? validateCodexAuthentication(model, options.apiKey);
|
|
1332
|
+
const cacheSessionId = options.cacheRetention === "none" ? undefined : options.sessionId;
|
|
1333
|
+
const requestId = codexCacheKey(cacheSessionId);
|
|
794
1334
|
const transport = options.transport ?? "auto";
|
|
795
1335
|
|
|
796
|
-
const websocketDisabled =
|
|
797
|
-
|
|
1336
|
+
const websocketDisabled = transport !== "sse" && isWebSocketSseFallbackActive(cacheSessionId);
|
|
1337
|
+
if (websocketDisabled) recordWebSocketSseFallback(cacheSessionId);
|
|
798
1338
|
if (transport !== "sse" && !websocketDisabled) {
|
|
799
1339
|
const headers = websocketHeaders(
|
|
800
1340
|
model.headers,
|
|
801
1341
|
options.headers,
|
|
802
1342
|
accountId,
|
|
803
1343
|
options.apiKey,
|
|
804
|
-
|
|
1344
|
+
requestId || uuidv7(),
|
|
805
1345
|
);
|
|
806
|
-
let
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1346
|
+
let retriedConnectionLimit = false;
|
|
1347
|
+
let retriedMissingContinuation = false;
|
|
1348
|
+
while (true) {
|
|
1349
|
+
let emitted = false;
|
|
1350
|
+
try {
|
|
1351
|
+
for await (const event of requestWebSocket(
|
|
1352
|
+
model,
|
|
1353
|
+
body,
|
|
1354
|
+
options,
|
|
1355
|
+
headers,
|
|
1356
|
+
cacheSessionId,
|
|
1357
|
+
accountId,
|
|
1358
|
+
timeoutMs,
|
|
1359
|
+
connectTimeoutMs,
|
|
1360
|
+
requestBytes,
|
|
1361
|
+
)) {
|
|
1362
|
+
if (!emitted) options.onTransportStart?.();
|
|
1363
|
+
emitted = true;
|
|
1364
|
+
yield event;
|
|
1365
|
+
}
|
|
1366
|
+
return;
|
|
1367
|
+
} catch (error) {
|
|
1368
|
+
const aborted = options.signal?.aborted;
|
|
1369
|
+
const connectionLimitBeforeStart =
|
|
1370
|
+
!emitted && isWebSocketConnectionLimitReachedError(error);
|
|
1371
|
+
if (!aborted && isPreviousResponseNotFoundError(error) && !retriedMissingContinuation) {
|
|
1372
|
+
retriedMissingContinuation = true;
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
if (!aborted && connectionLimitBeforeStart && !retriedConnectionLimit) {
|
|
1376
|
+
retriedConnectionLimit = true;
|
|
1377
|
+
continue;
|
|
1378
|
+
}
|
|
1379
|
+
if (aborted || (isCodexNonTransportError(error) && !connectionLimitBeforeStart)) {
|
|
1380
|
+
throw error;
|
|
1381
|
+
}
|
|
1382
|
+
options.onTransportDiagnostic?.(
|
|
1383
|
+
transportDiagnostic(error, transport, emitted, requestBytes),
|
|
1384
|
+
);
|
|
1385
|
+
recordWebSocketFailure(cacheSessionId, error);
|
|
1386
|
+
if (emitted) throw error;
|
|
1387
|
+
recordWebSocketSseFallback(cacheSessionId);
|
|
1388
|
+
break;
|
|
821
1389
|
}
|
|
822
|
-
if (sessionId) websocketFallbackSessions.add(sessionId);
|
|
823
1390
|
}
|
|
824
1391
|
}
|
|
825
1392
|
|
|
@@ -828,28 +1395,14 @@ export class CodexTransport {
|
|
|
828
1395
|
options.headers,
|
|
829
1396
|
accountId,
|
|
830
1397
|
options.apiKey,
|
|
831
|
-
|
|
1398
|
+
requestId,
|
|
832
1399
|
);
|
|
833
|
-
yield* requestSse(model,
|
|
1400
|
+
yield* requestSse(model, bodyJson, options, headers, timeoutMs);
|
|
834
1401
|
}
|
|
835
1402
|
|
|
836
1403
|
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();
|
|
1404
|
+
closeOpenAICodexWebSocketSessions(sessionId);
|
|
851
1405
|
}
|
|
852
1406
|
}
|
|
853
1407
|
|
|
854
|
-
|
|
855
|
-
registerSessionResourceCleanup((sessionId) => transportCleanup.close(sessionId));
|
|
1408
|
+
registerSessionResourceCleanup(closeOpenAICodexWebSocketSessions);
|