@opengeni/codex 0.2.5 → 0.2.9
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/dist/api-client.d.ts +55 -0
- package/dist/billing.d.ts +11 -0
- package/dist/bounded-operation.d.ts +16 -0
- package/dist/{chunk-AMRVHBCD.js → chunk-YGKQDUY7.js} +13 -1
- package/dist/{chunk-AMRVHBCD.js.map → chunk-YGKQDUY7.js.map} +1 -1
- package/dist/constants.d.ts +33 -29
- package/dist/constants.js +13 -1
- package/dist/device-code.d.ts +36 -0
- package/dist/fetch.d.ts +84 -0
- package/dist/index.d.ts +13 -377
- package/dist/index.js +684 -68
- package/dist/index.js.map +1 -1
- package/dist/mcp-sanitize.d.ts +33 -0
- package/dist/model-output-truncation.d.ts +36 -0
- package/dist/normalize.d.ts +8 -0
- package/dist/oversized-image-card.d.ts +17 -0
- package/dist/refresh.d.ts +28 -0
- package/dist/request-context.d.ts +99 -0
- package/dist/reset-credits.d.ts +33 -0
- package/dist/response-timeout.d.ts +28 -0
- package/dist/usage-normalize.d.ts +61 -0
- package/package.json +3 -3
- package/src/api-client.ts +131 -22
- package/src/bounded-operation.ts +43 -0
- package/src/constants.ts +18 -0
- package/src/fetch.ts +416 -11
- package/src/index.ts +2 -0
- package/src/normalize.ts +6 -3
- package/src/refresh.ts +21 -10
- package/src/request-context.ts +58 -0
- package/src/reset-credits.ts +157 -0
- package/src/response-timeout.ts +159 -0
- package/src/usage-normalize.ts +20 -2
package/src/fetch.ts
CHANGED
|
@@ -10,13 +10,24 @@
|
|
|
10
10
|
// - retries once on 401 after a forced token refresh (spec §1.9)
|
|
11
11
|
// Stream parsing is delegated to the SDK (SSE passthrough; spec §0(d)).
|
|
12
12
|
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
13
14
|
import { CODEX_ORIGINATOR } from "./constants";
|
|
14
15
|
import { normalizeCodexRequestBody } from "./normalize";
|
|
15
16
|
import {
|
|
16
17
|
codexRequestStorage,
|
|
18
|
+
type CodexModelRequestEvent,
|
|
19
|
+
type CodexRequestContext,
|
|
20
|
+
type CodexResponseTimeoutPolicy,
|
|
17
21
|
type CodexTokenSnapshot,
|
|
18
22
|
type CodexUsageHeaderSnapshot,
|
|
19
23
|
} from "./request-context";
|
|
24
|
+
import {
|
|
25
|
+
CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
|
|
26
|
+
CodexResponseTimeoutError,
|
|
27
|
+
classifyCodexResponseTimeoutError,
|
|
28
|
+
isPreHeadersTimeoutError,
|
|
29
|
+
resolveCodexResponseTimeoutPolicy,
|
|
30
|
+
} from "./response-timeout";
|
|
20
31
|
|
|
21
32
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
22
33
|
|
|
@@ -52,6 +63,56 @@ export function isCodexTransportError(error: unknown): boolean {
|
|
|
52
63
|
return false;
|
|
53
64
|
}
|
|
54
65
|
|
|
66
|
+
export type CodexEncryptedArtifactRejection = {
|
|
67
|
+
status: 400;
|
|
68
|
+
kind: "encrypted_content_rejected";
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify only the provider's definitive request rejection for an opaque
|
|
73
|
+
* reasoning artifact that it can no longer decrypt/parse. A Codex transport
|
|
74
|
+
* marker plus HTTP 400 proves this request was rejected before inference; the
|
|
75
|
+
* semantic match prevents unrelated malformed prompts from entering recovery.
|
|
76
|
+
*/
|
|
77
|
+
export function classifyCodexEncryptedArtifactRejection(
|
|
78
|
+
error: unknown,
|
|
79
|
+
): CodexEncryptedArtifactRejection | null {
|
|
80
|
+
if (!isCodexTransportError(error)) return null;
|
|
81
|
+
let current: unknown = error;
|
|
82
|
+
for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
|
|
83
|
+
const value = current as Record<string, unknown>;
|
|
84
|
+
const body =
|
|
85
|
+
value.error && typeof value.error === "object"
|
|
86
|
+
? (value.error as Record<string, unknown>)
|
|
87
|
+
: null;
|
|
88
|
+
const status = Number(value.status ?? body?.status);
|
|
89
|
+
const message = [
|
|
90
|
+
typeof value.message === "string" ? value.message : "",
|
|
91
|
+
typeof body?.message === "string" ? body.message : "",
|
|
92
|
+
typeof value.code === "string" ? value.code : "",
|
|
93
|
+
typeof body?.code === "string" ? body.code : "",
|
|
94
|
+
typeof value.type === "string" ? value.type : "",
|
|
95
|
+
typeof body?.type === "string" ? body.type : "",
|
|
96
|
+
]
|
|
97
|
+
.join(" ")
|
|
98
|
+
.toLowerCase();
|
|
99
|
+
const unsupportedFieldShape =
|
|
100
|
+
/(?:invalid value|supported values?|unsupported|unknown (?:field|parameter|value))/.test(
|
|
101
|
+
message,
|
|
102
|
+
);
|
|
103
|
+
if (
|
|
104
|
+
status === 400 &&
|
|
105
|
+
!unsupportedFieldShape &&
|
|
106
|
+
/(?:encrypted[_ ]content|encrypted reasoning|reasoning artifact)/.test(message) &&
|
|
107
|
+
/(?:decrypt(?:ed|ion)?|could not be parsed|cannot be parsed|failed to parse)/.test(message)
|
|
108
|
+
) {
|
|
109
|
+
return { status: 400, kind: "encrypted_content_rejected" };
|
|
110
|
+
}
|
|
111
|
+
current = value.cause;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
55
116
|
/** Parse an integer header value; null when absent or not a finite integer. */
|
|
56
117
|
function parseIntHeader(value: string | null): number | null {
|
|
57
118
|
if (value === null) {
|
|
@@ -118,6 +179,258 @@ export function parseCodexUsageHeaders(headers: Headers): CodexUsageHeaderSnapsh
|
|
|
118
179
|
};
|
|
119
180
|
}
|
|
120
181
|
|
|
182
|
+
type RequestAudit = {
|
|
183
|
+
ctx: CodexRequestContext;
|
|
184
|
+
requestId: string;
|
|
185
|
+
transportAttempt: number;
|
|
186
|
+
model?: string;
|
|
187
|
+
logicalStartedAt: number;
|
|
188
|
+
attemptStartedAt: number;
|
|
189
|
+
policy: CodexResponseTimeoutPolicy;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
async function emitRequestEvent(
|
|
193
|
+
audit: RequestAudit,
|
|
194
|
+
event: Omit<
|
|
195
|
+
CodexModelRequestEvent,
|
|
196
|
+
"requestId" | "transportAttempt" | "model" | "durationMs" | "timeoutPolicy"
|
|
197
|
+
>,
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
await audit.ctx.onModelRequestEvent?.({
|
|
200
|
+
requestId: audit.requestId,
|
|
201
|
+
transportAttempt: audit.transportAttempt,
|
|
202
|
+
...(audit.model ? { model: audit.model } : {}),
|
|
203
|
+
durationMs: Math.max(0, Date.now() - audit.attemptStartedAt),
|
|
204
|
+
timeoutPolicy: audit.policy,
|
|
205
|
+
...event,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function providerRequestId(headers: Headers): string | undefined {
|
|
210
|
+
return headers.get("x-request-id") ?? headers.get("request-id") ?? undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function fetchBeforeHeaders(
|
|
214
|
+
base: FetchLike,
|
|
215
|
+
input: string,
|
|
216
|
+
init: RequestInit,
|
|
217
|
+
audit: RequestAudit,
|
|
218
|
+
): Promise<Response> {
|
|
219
|
+
const elapsed = Date.now() - audit.logicalStartedAt;
|
|
220
|
+
const wholeRemainingMs = audit.policy.wholeRequestTimeoutMs - elapsed;
|
|
221
|
+
const timeoutClass =
|
|
222
|
+
wholeRemainingMs <= audit.policy.headersTimeoutMs ? "whole_request" : "headers";
|
|
223
|
+
const deadlineMs = Math.max(1, Math.min(audit.policy.headersTimeoutMs, wholeRemainingMs));
|
|
224
|
+
if (wholeRemainingMs <= 0) {
|
|
225
|
+
throw new CodexResponseTimeoutError("whole_request", audit.requestId, false);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const externalSignal = init.signal;
|
|
229
|
+
if (externalSignal?.aborted) throw externalSignal.reason;
|
|
230
|
+
const controller = new AbortController();
|
|
231
|
+
const forwardAbort = () => controller.abort(externalSignal?.reason);
|
|
232
|
+
externalSignal?.addEventListener("abort", forwardAbort, { once: true });
|
|
233
|
+
const basePromise = base(input, { ...init, signal: controller.signal });
|
|
234
|
+
let deadlineError: CodexResponseTimeoutError | null = null;
|
|
235
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
236
|
+
const deadline = new Promise<never>((_resolve, reject) => {
|
|
237
|
+
timer = setTimeout(() => {
|
|
238
|
+
deadlineError = new CodexResponseTimeoutError(timeoutClass, audit.requestId, false);
|
|
239
|
+
reject(deadlineError);
|
|
240
|
+
}, deadlineMs);
|
|
241
|
+
});
|
|
242
|
+
try {
|
|
243
|
+
return await Promise.race([basePromise, deadline]);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (deadlineError) {
|
|
246
|
+
controller.abort(deadlineError);
|
|
247
|
+
void basePromise
|
|
248
|
+
.then((late) => late.body?.cancel(deadlineError ?? undefined))
|
|
249
|
+
.catch(() => undefined);
|
|
250
|
+
throw deadlineError;
|
|
251
|
+
}
|
|
252
|
+
throw error;
|
|
253
|
+
} finally {
|
|
254
|
+
if (timer) clearTimeout(timer);
|
|
255
|
+
externalSignal?.removeEventListener("abort", forwardAbort);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function observedResponse(
|
|
260
|
+
res: Response,
|
|
261
|
+
audit: RequestAudit,
|
|
262
|
+
externalSignal: AbortSignal | null | undefined,
|
|
263
|
+
): Promise<Response> {
|
|
264
|
+
const requestId = providerRequestId(res.headers);
|
|
265
|
+
if (!res.body) {
|
|
266
|
+
await emitRequestEvent(audit, {
|
|
267
|
+
phase: res.ok ? "completed" : "failed",
|
|
268
|
+
responseObserved: true,
|
|
269
|
+
status: res.status,
|
|
270
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
271
|
+
});
|
|
272
|
+
return res;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const reader = res.body.getReader();
|
|
276
|
+
let terminal = false;
|
|
277
|
+
let firstByte = false;
|
|
278
|
+
let idleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
279
|
+
let wholeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
280
|
+
let armIdle: () => void = () => undefined;
|
|
281
|
+
let abortFromOutside: (() => void) | undefined;
|
|
282
|
+
|
|
283
|
+
const clearTimers = () => {
|
|
284
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
285
|
+
if (wholeTimer) clearTimeout(wholeTimer);
|
|
286
|
+
if (abortFromOutside) externalSignal?.removeEventListener("abort", abortFromOutside);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const body = new ReadableStream<Uint8Array>({
|
|
290
|
+
start(controller) {
|
|
291
|
+
const timeOut = (klass: "idle_stream" | "whole_request") => {
|
|
292
|
+
if (terminal) return;
|
|
293
|
+
terminal = true;
|
|
294
|
+
clearTimers();
|
|
295
|
+
const error = new CodexResponseTimeoutError(klass, audit.requestId, true);
|
|
296
|
+
void reader.cancel(error).catch(() => undefined);
|
|
297
|
+
void emitRequestEvent(audit, {
|
|
298
|
+
phase: "timed_out",
|
|
299
|
+
responseObserved: true,
|
|
300
|
+
timeoutClass: klass,
|
|
301
|
+
status: res.status,
|
|
302
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
303
|
+
}).then(
|
|
304
|
+
() => controller.error(error),
|
|
305
|
+
() => controller.error(error),
|
|
306
|
+
);
|
|
307
|
+
};
|
|
308
|
+
armIdle = () => {
|
|
309
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
310
|
+
idleTimer = setTimeout(() => timeOut("idle_stream"), audit.policy.streamIdleTimeoutMs);
|
|
311
|
+
};
|
|
312
|
+
armIdle();
|
|
313
|
+
const wholeRemaining = Math.max(
|
|
314
|
+
1,
|
|
315
|
+
audit.policy.wholeRequestTimeoutMs - (Date.now() - audit.logicalStartedAt),
|
|
316
|
+
);
|
|
317
|
+
wholeTimer = setTimeout(() => timeOut("whole_request"), wholeRemaining);
|
|
318
|
+
abortFromOutside = () => {
|
|
319
|
+
if (terminal) return;
|
|
320
|
+
terminal = true;
|
|
321
|
+
clearTimers();
|
|
322
|
+
const reason = externalSignal?.reason ?? new DOMException("Aborted", "AbortError");
|
|
323
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
324
|
+
void emitRequestEvent(audit, {
|
|
325
|
+
phase: "failed",
|
|
326
|
+
responseObserved: true,
|
|
327
|
+
status: res.status,
|
|
328
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
329
|
+
}).then(
|
|
330
|
+
() => controller.error(reason),
|
|
331
|
+
() => controller.error(reason),
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
if (externalSignal?.aborted) {
|
|
335
|
+
abortFromOutside();
|
|
336
|
+
} else {
|
|
337
|
+
externalSignal?.addEventListener("abort", abortFromOutside, {
|
|
338
|
+
once: true,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
async pull(controller) {
|
|
343
|
+
if (terminal) return;
|
|
344
|
+
try {
|
|
345
|
+
const chunk = await reader.read();
|
|
346
|
+
if (terminal) return;
|
|
347
|
+
if (chunk.done) {
|
|
348
|
+
terminal = true;
|
|
349
|
+
clearTimers();
|
|
350
|
+
await emitRequestEvent(audit, {
|
|
351
|
+
phase: res.ok ? "completed" : "failed",
|
|
352
|
+
responseObserved: true,
|
|
353
|
+
status: res.status,
|
|
354
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
355
|
+
});
|
|
356
|
+
controller.close();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
armIdle();
|
|
360
|
+
if (!firstByte) {
|
|
361
|
+
firstByte = true;
|
|
362
|
+
await emitRequestEvent(audit, {
|
|
363
|
+
phase: "first_byte",
|
|
364
|
+
responseObserved: true,
|
|
365
|
+
status: res.status,
|
|
366
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
controller.enqueue(chunk.value);
|
|
370
|
+
} catch (error) {
|
|
371
|
+
if (terminal) return;
|
|
372
|
+
terminal = true;
|
|
373
|
+
clearTimers();
|
|
374
|
+
await emitRequestEvent(audit, {
|
|
375
|
+
phase: "failed",
|
|
376
|
+
responseObserved: true,
|
|
377
|
+
status: res.status,
|
|
378
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
379
|
+
});
|
|
380
|
+
controller.error(error);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
async cancel(reason) {
|
|
384
|
+
if (!terminal) {
|
|
385
|
+
terminal = true;
|
|
386
|
+
clearTimers();
|
|
387
|
+
await emitRequestEvent(audit, {
|
|
388
|
+
phase: "failed",
|
|
389
|
+
responseObserved: true,
|
|
390
|
+
status: res.status,
|
|
391
|
+
...(requestId ? { providerRequestId: requestId } : {}),
|
|
392
|
+
}).catch(() => undefined);
|
|
393
|
+
}
|
|
394
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
const headers = new Headers(res.headers);
|
|
398
|
+
headers.delete("content-length");
|
|
399
|
+
return new Response(body, {
|
|
400
|
+
status: res.status,
|
|
401
|
+
statusText: res.statusText,
|
|
402
|
+
headers,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function timeoutErrorResponse(info: {
|
|
407
|
+
timeoutClass: "connect" | "headers" | "idle_stream" | "whole_request";
|
|
408
|
+
requestId: string;
|
|
409
|
+
responseObserved: boolean;
|
|
410
|
+
message: string;
|
|
411
|
+
}): Response {
|
|
412
|
+
return new Response(
|
|
413
|
+
JSON.stringify({
|
|
414
|
+
error: {
|
|
415
|
+
type: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
|
|
416
|
+
code: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
|
|
417
|
+
message: info.message,
|
|
418
|
+
timeout_class: info.timeoutClass,
|
|
419
|
+
response_observed: info.responseObserved,
|
|
420
|
+
request_id: info.requestId,
|
|
421
|
+
},
|
|
422
|
+
}),
|
|
423
|
+
{
|
|
424
|
+
status: 504,
|
|
425
|
+
headers: {
|
|
426
|
+
"content-type": "application/json",
|
|
427
|
+
"x-should-retry": "false",
|
|
428
|
+
[CODEX_TRANSPORT_ERROR_HEADER]: "1",
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
121
434
|
export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): FetchLike {
|
|
122
435
|
return async (input, init) => {
|
|
123
436
|
const ctx = codexRequestStorage.getStore();
|
|
@@ -131,7 +444,15 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
131
444
|
// URLs whose base already includes /codex (avoids /codex/codex/responses).
|
|
132
445
|
const rewritten = rawUrl.replace(/(?<!\/codex)\/responses(\b|$)/, "/codex/responses$1");
|
|
133
446
|
|
|
134
|
-
const
|
|
447
|
+
const policy = resolveCodexResponseTimeoutPolicy(ctx.responseTimeoutPolicy);
|
|
448
|
+
const requestId = ctx.nextRequestId?.() ?? randomUUID();
|
|
449
|
+
const logicalStartedAt = Date.now();
|
|
450
|
+
let transportAttempt = 0;
|
|
451
|
+
|
|
452
|
+
const attempt = async (
|
|
453
|
+
auth: CodexTokenSnapshot,
|
|
454
|
+
authenticationAttempt: number,
|
|
455
|
+
): Promise<Response> => {
|
|
135
456
|
const headers = new Headers(init?.headers);
|
|
136
457
|
headers.set("Authorization", `Bearer ${auth.accessToken}`);
|
|
137
458
|
if (auth.chatgptAccountId) {
|
|
@@ -153,31 +474,104 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
153
474
|
}
|
|
154
475
|
headers.delete("OpenAI-Beta"); // omit on SSE (spec §1.2); fallback: "responses=experimental" if backend 400s
|
|
155
476
|
headers.delete("x-api-key");
|
|
477
|
+
// Codex CLI advertises betas via x-codex-beta-features (not OpenAI-Beta).
|
|
478
|
+
if (ctx.betaFeatures && ctx.betaFeatures.length > 0) {
|
|
479
|
+
headers.set("x-codex-beta-features", ctx.betaFeatures.join(","));
|
|
480
|
+
}
|
|
481
|
+
// Turn analytics / request_kind live in x-codex-turn-metadata — body
|
|
482
|
+
// metadata is stripped by normalizeCodexRequestBody and rejected upstream.
|
|
483
|
+
if (ctx.turnMetadata && Object.keys(ctx.turnMetadata).length > 0) {
|
|
484
|
+
headers.set("x-codex-turn-metadata", JSON.stringify(ctx.turnMetadata));
|
|
485
|
+
}
|
|
156
486
|
|
|
157
487
|
// The backend is streaming-only; force stream=true on the wire but remember
|
|
158
488
|
// the caller's intent so a non-streaming caller (e.g. the compaction
|
|
159
489
|
// summarizer) still gets a single JSON Response back.
|
|
160
490
|
let callerWantsStream = true;
|
|
491
|
+
let model: string | undefined;
|
|
161
492
|
const nextInit: RequestInit = { ...init, headers };
|
|
162
493
|
if (typeof init?.body === "string") {
|
|
163
494
|
try {
|
|
164
495
|
const parsed = JSON.parse(init.body) as Record<string, unknown>;
|
|
165
496
|
callerWantsStream = parsed.stream === true;
|
|
166
|
-
|
|
497
|
+
const normalized = normalizeCodexRequestBody(parsed, ctx.resolveModel);
|
|
498
|
+
model = typeof normalized.model === "string" ? normalized.model : undefined;
|
|
499
|
+
nextInit.body = JSON.stringify(normalized);
|
|
167
500
|
} catch {
|
|
168
501
|
/* leave unparseable bodies untouched (already copied from init) */
|
|
169
502
|
}
|
|
170
503
|
}
|
|
504
|
+
headers.set(
|
|
505
|
+
"Idempotency-Key",
|
|
506
|
+
authenticationAttempt === 0 ? requestId : `${requestId}:auth-${authenticationAttempt}`,
|
|
507
|
+
);
|
|
171
508
|
if (process.env.CODEX_DEBUG) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
509
|
+
let keys: string[] = [];
|
|
510
|
+
if (typeof nextInit.body === "string") {
|
|
511
|
+
try {
|
|
512
|
+
keys = Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>);
|
|
513
|
+
} catch {
|
|
514
|
+
/* an unparseable body is already passed through unchanged above */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
176
517
|
console.error(
|
|
177
518
|
`[codex-debug] POST ${rewritten} stream=${callerWantsStream} bodyKeys=[${keys.join(",")}]`,
|
|
178
519
|
);
|
|
179
520
|
}
|
|
180
|
-
|
|
521
|
+
let res: Response;
|
|
522
|
+
transportAttempt += 1;
|
|
523
|
+
const audit: RequestAudit = {
|
|
524
|
+
ctx,
|
|
525
|
+
requestId,
|
|
526
|
+
transportAttempt,
|
|
527
|
+
...(model ? { model } : {}),
|
|
528
|
+
logicalStartedAt,
|
|
529
|
+
attemptStartedAt: Date.now(),
|
|
530
|
+
policy,
|
|
531
|
+
};
|
|
532
|
+
await emitRequestEvent(audit, {
|
|
533
|
+
phase: "started",
|
|
534
|
+
responseObserved: false,
|
|
535
|
+
});
|
|
536
|
+
try {
|
|
537
|
+
res = await fetchBeforeHeaders(base, rewritten, nextInit, audit);
|
|
538
|
+
const upstreamRequestId = providerRequestId(res.headers);
|
|
539
|
+
await emitRequestEvent(audit, {
|
|
540
|
+
phase: "headers",
|
|
541
|
+
responseObserved: true,
|
|
542
|
+
status: res.status,
|
|
543
|
+
...(upstreamRequestId ? { providerRequestId: upstreamRequestId } : {}),
|
|
544
|
+
});
|
|
545
|
+
const observed = await observedResponse(res, audit, nextInit.signal);
|
|
546
|
+
res = observed;
|
|
547
|
+
} catch (error) {
|
|
548
|
+
if (nextInit.signal?.aborted) {
|
|
549
|
+
await emitRequestEvent(audit, {
|
|
550
|
+
phase: "failed",
|
|
551
|
+
responseObserved: false,
|
|
552
|
+
}).catch(() => undefined);
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
const klass = isPreHeadersTimeoutError(error);
|
|
556
|
+
if (!klass) {
|
|
557
|
+
await emitRequestEvent(audit, {
|
|
558
|
+
phase: "failed",
|
|
559
|
+
responseObserved: false,
|
|
560
|
+
});
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
// An absent response does not prove that the provider never accepted
|
|
564
|
+
// this operation. Until a provider-specific receipt can prove
|
|
565
|
+
// non-acceptance or resume the same operation, never replay it.
|
|
566
|
+
// Audit persistence must not replace the typed transport timeout.
|
|
567
|
+
await emitRequestEvent(audit, {
|
|
568
|
+
phase: "timed_out",
|
|
569
|
+
responseObserved: false,
|
|
570
|
+
timeoutClass: klass,
|
|
571
|
+
willRetry: false,
|
|
572
|
+
}).catch(() => undefined);
|
|
573
|
+
throw new CodexResponseTimeoutError(klass, requestId, false);
|
|
574
|
+
}
|
|
181
575
|
// Multi-account P4 (Part A): scrape the usage headers ONCE, before the
|
|
182
576
|
// OK/!res.ok branch, so the same fire-and-forget read also covers the 429
|
|
183
577
|
// hard-cap path (an exhausted serving account stamps its own fresh
|
|
@@ -216,11 +610,22 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
216
610
|
return callerWantsStream ? repairCodexStream(res) : await sseToJsonResponse(res);
|
|
217
611
|
};
|
|
218
612
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
613
|
+
try {
|
|
614
|
+
let res = await attempt(await ctx.getToken(), 0);
|
|
615
|
+
if (res.status === 401) {
|
|
616
|
+
res = await attempt(await ctx.refresh(), 1); // single refresh-on-401 retry (spec §1.9)
|
|
617
|
+
}
|
|
618
|
+
return res;
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const timeout = classifyCodexResponseTimeoutError(error);
|
|
621
|
+
if (!timeout) throw error;
|
|
622
|
+
return timeoutErrorResponse({
|
|
623
|
+
timeoutClass: timeout.timeoutClass,
|
|
624
|
+
requestId: timeout.requestId ?? requestId,
|
|
625
|
+
responseObserved: timeout.responseObserved,
|
|
626
|
+
message: timeout.message,
|
|
627
|
+
});
|
|
222
628
|
}
|
|
223
|
-
return res;
|
|
224
629
|
};
|
|
225
630
|
}
|
|
226
631
|
|
package/src/index.ts
CHANGED
|
@@ -4,8 +4,10 @@ export * from "./device-code";
|
|
|
4
4
|
export * from "./refresh";
|
|
5
5
|
export * from "./normalize";
|
|
6
6
|
export * from "./usage-normalize";
|
|
7
|
+
export * from "./reset-credits";
|
|
7
8
|
export * from "./api-client";
|
|
8
9
|
export * from "./request-context";
|
|
10
|
+
export * from "./response-timeout";
|
|
9
11
|
export * from "./fetch";
|
|
10
12
|
export * from "./mcp-sanitize";
|
|
11
13
|
export * from "./model-output-truncation";
|
package/src/normalize.ts
CHANGED
|
@@ -16,9 +16,11 @@ const MINIMAL = "minimal";
|
|
|
16
16
|
// The ChatGPT/Codex backend is a STRICT ALLOWLIST: it 400s on ANY top-level field
|
|
17
17
|
// the Codex CLI itself does not send (confirmed live against the backend —
|
|
18
18
|
// "Unsupported parameter: temperature / top_p / metadata / previous_response_id /
|
|
19
|
-
// logprobs /
|
|
20
|
-
// background / conversation", and "Unsupported tool type: mcp").
|
|
21
|
-
//
|
|
19
|
+
// logprobs / user / safety_identifier / truncation / max_tool_calls /
|
|
20
|
+
// background / conversation", and "Unsupported tool type: mcp").
|
|
21
|
+
// `service_tier` is allowlisted for Codex Fast mode (`priority`; config may say
|
|
22
|
+
// `fast` and maps to the same request value). Our @openai/agents stack adds
|
|
23
|
+
// several other fields, so after our transforms we keep ONLY the codex
|
|
22
24
|
// Responses payload fields (CODEX-SUBSCRIPTION-SPEC §1 field table).
|
|
23
25
|
const CODEX_ALLOWED_TOP_LEVEL_KEYS = new Set<string>([
|
|
24
26
|
"model",
|
|
@@ -33,6 +35,7 @@ const CODEX_ALLOWED_TOP_LEVEL_KEYS = new Set<string>([
|
|
|
33
35
|
"include",
|
|
34
36
|
"prompt_cache_key",
|
|
35
37
|
"text",
|
|
38
|
+
"service_tier",
|
|
36
39
|
]);
|
|
37
40
|
|
|
38
41
|
/** Mutates a parsed Responses request body in place and returns it. Pure + synchronous + unit-testable. */
|
package/src/refresh.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
import { CODEX_CLIENT_ID, CODEX_ID_TOKEN_AUTH_CLAIM, CODEX_TOKEN_URL } from "./constants";
|
|
6
6
|
import type { CodexFetch } from "./device-code";
|
|
7
|
+
import { runBoundedCodexOperation } from "./bounded-operation";
|
|
8
|
+
|
|
9
|
+
const CODEX_REFRESH_TIMEOUT_MS = 5_000;
|
|
7
10
|
|
|
8
11
|
/** Permanent — the workspace must reconnect (status => needs_relogin). */
|
|
9
12
|
export class CodexReloginRequired extends Error {
|
|
@@ -32,17 +35,25 @@ export type CodexRefreshTokens = {
|
|
|
32
35
|
export async function refreshCodexToken(
|
|
33
36
|
refreshToken: string,
|
|
34
37
|
fetchImpl: CodexFetch = fetch,
|
|
38
|
+
timeoutMs = CODEX_REFRESH_TIMEOUT_MS,
|
|
35
39
|
): Promise<CodexRefreshTokens> {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
const fetched = await runBoundedCodexOperation(async (signal) => {
|
|
41
|
+
const res = await fetchImpl(CODEX_TOKEN_URL, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { "Content-Type": "application/json" },
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
client_id: CODEX_CLIENT_ID,
|
|
46
|
+
grant_type: "refresh_token",
|
|
47
|
+
refresh_token: refreshToken,
|
|
48
|
+
}),
|
|
49
|
+
signal,
|
|
50
|
+
});
|
|
51
|
+
return { res, text: await res.text() };
|
|
52
|
+
}, timeoutMs);
|
|
53
|
+
if (!fetched.ok) {
|
|
54
|
+
throw new CodexRefreshTransient(`Codex token refresh ${fetched.reason}`);
|
|
55
|
+
}
|
|
56
|
+
const { res, text } = fetched.value;
|
|
46
57
|
if (!res.ok) {
|
|
47
58
|
const code = extractRefreshErrorCode(text);
|
|
48
59
|
const msg = code ? PERMANENT_REFRESH_FAILURES[code] : undefined;
|
package/src/request-context.ts
CHANGED
|
@@ -30,6 +30,38 @@ export type CodexUsageHeaderSnapshot = {
|
|
|
30
30
|
checkedAt: Date;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
export type CodexResponseTimeoutClass = "connect" | "headers" | "idle_stream" | "whole_request";
|
|
34
|
+
|
|
35
|
+
export type CodexResponseTimeoutPolicy = {
|
|
36
|
+
/** Maximum wait for response headers, including DNS/TCP/TLS establishment. */
|
|
37
|
+
headersTimeoutMs: number;
|
|
38
|
+
/** Maximum silence between response-body chunks after headers arrive. */
|
|
39
|
+
streamIdleTimeoutMs: number;
|
|
40
|
+
/** Maximum wall time for one logical Responses request. */
|
|
41
|
+
wholeRequestTimeoutMs: number;
|
|
42
|
+
/**
|
|
43
|
+
* Reserved compatibility field. It is currently normalized to zero because
|
|
44
|
+
* an absent response does not prove that the provider never accepted a
|
|
45
|
+
* request, so automatic replay is not safe without an operation receipt.
|
|
46
|
+
*/
|
|
47
|
+
noByteRetries: number;
|
|
48
|
+
retryBackoffMs: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type CodexModelRequestEvent = {
|
|
52
|
+
requestId: string;
|
|
53
|
+
transportAttempt: number;
|
|
54
|
+
phase: "started" | "headers" | "first_byte" | "completed" | "failed" | "timed_out";
|
|
55
|
+
model?: string;
|
|
56
|
+
durationMs: number;
|
|
57
|
+
responseObserved: boolean;
|
|
58
|
+
timeoutPolicy: CodexResponseTimeoutPolicy;
|
|
59
|
+
timeoutClass?: CodexResponseTimeoutClass;
|
|
60
|
+
providerRequestId?: string;
|
|
61
|
+
status?: number;
|
|
62
|
+
willRetry?: boolean;
|
|
63
|
+
};
|
|
64
|
+
|
|
33
65
|
export type CodexRequestContext = {
|
|
34
66
|
clientVersion: string;
|
|
35
67
|
/**
|
|
@@ -58,6 +90,32 @@ export type CodexRequestContext = {
|
|
|
58
90
|
* P2 usage cache once per turn in its `finally` — packages/codex stays db-free.
|
|
59
91
|
*/
|
|
60
92
|
onUsageHeaders?: (snapshot: CodexUsageHeaderSnapshot) => void;
|
|
93
|
+
/** Optional per-run override, primarily for deterministic transport tests. */
|
|
94
|
+
responseTimeoutPolicy?: Partial<CodexResponseTimeoutPolicy>;
|
|
95
|
+
/** Worker-owned durable audit sink; payloads never contain request bodies or auth. */
|
|
96
|
+
onModelRequestEvent?: (event: CodexModelRequestEvent) => Promise<void> | void;
|
|
97
|
+
/** Stable request identity supplied by the owning durable execution. */
|
|
98
|
+
nextRequestId?: () => string;
|
|
99
|
+
/**
|
|
100
|
+
* Optional Codex beta feature flags advertised as `x-codex-beta-features`
|
|
101
|
+
* (comma-separated). Used for remote compaction v2 (`remote_compaction_v2`).
|
|
102
|
+
*/
|
|
103
|
+
betaFeatures?: readonly string[];
|
|
104
|
+
/**
|
|
105
|
+
* Optional turn analytics / routing metadata sent as `x-codex-turn-metadata`
|
|
106
|
+
* (JSON). Body `metadata` is stripped by normalize — never put request_kind there.
|
|
107
|
+
*/
|
|
108
|
+
turnMetadata?: Record<string, unknown>;
|
|
61
109
|
};
|
|
62
110
|
|
|
63
111
|
export const codexRequestStorage = new AsyncLocalStorage<CodexRequestContext>();
|
|
112
|
+
|
|
113
|
+
/** Nest a Codex ALS scope with header overrides (e.g. remote compaction v2). */
|
|
114
|
+
export function withCodexRequestOverrides<T>(
|
|
115
|
+
overrides: Pick<CodexRequestContext, "betaFeatures" | "turnMetadata">,
|
|
116
|
+
fn: () => T,
|
|
117
|
+
): T {
|
|
118
|
+
const current = codexRequestStorage.getStore();
|
|
119
|
+
if (!current) return fn();
|
|
120
|
+
return codexRequestStorage.run({ ...current, ...overrides }, fn);
|
|
121
|
+
}
|