@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/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 attempt = async (auth: CodexTokenSnapshot): Promise<Response> => {
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
- nextInit.body = JSON.stringify(normalizeCodexRequestBody(parsed, ctx.resolveModel));
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
- const keys =
173
- typeof nextInit.body === "string"
174
- ? Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>)
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
- const res = await base(rewritten, nextInit);
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
- let res = await attempt(await ctx.getToken());
220
- if (res.status === 401) {
221
- res = await attempt(await ctx.refresh()); // single refresh-on-401 retry (spec §1.9)
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 / service_tier / user / safety_identifier / truncation / max_tool_calls /
20
- // background / conversation", and "Unsupported tool type: mcp"). Our @openai/agents
21
- // stack adds several of these, so after our transforms we keep ONLY the codex
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 res = await fetchImpl(CODEX_TOKEN_URL, {
37
- method: "POST",
38
- headers: { "Content-Type": "application/json" },
39
- body: JSON.stringify({
40
- client_id: CODEX_CLIENT_ID,
41
- grant_type: "refresh_token",
42
- refresh_token: refreshToken,
43
- }),
44
- });
45
- const text = await res.text();
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;
@@ -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
+ }