@opengeni/codex 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/codex",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "ChatGPT/Codex subscription auth + transport: device-code login, token refresh, and the Responses-backend fetch. Pure HTTP + transforms; no database dependency.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
package/src/api-client.ts CHANGED
@@ -3,6 +3,13 @@
3
3
 
4
4
  import { CODEX_ORIGINATOR, CODEX_RESPONSES_BASE, CODEX_WHAM_BASE } from "./constants";
5
5
  import type { CodexFetch } from "./device-code";
6
+ import {
7
+ parseCodexRateLimitResetConsumeResponse,
8
+ parseCodexRateLimitResetCreditsDetails,
9
+ type CodexRateLimitResetConsumeResponse,
10
+ type CodexRateLimitResetCreditsDetails,
11
+ } from "./reset-credits";
12
+ import { runBoundedCodexOperation } from "./bounded-operation";
6
13
 
7
14
  export type CodexAuthHeaders = {
8
15
  accessToken: string;
@@ -11,6 +18,16 @@ export type CodexAuthHeaders = {
11
18
  clientVersion: string;
12
19
  };
13
20
 
21
+ const CODEX_READ_TIMEOUT_MS = 5_000;
22
+ const RESET_CREDIT_DETAILS_TIMEOUT_MS = 5_000;
23
+ const RESET_CREDIT_CONSUME_TIMEOUT_MS = 10_000;
24
+
25
+ export type ResetCreditFetchFailureReason =
26
+ | "http_error"
27
+ | "invalid_response"
28
+ | "network_error"
29
+ | "timeout";
30
+
14
31
  function subscriptionHeaders(a: CodexAuthHeaders): Record<string, string> {
15
32
  return {
16
33
  Authorization: `Bearer ${a.accessToken}`,
@@ -26,34 +43,126 @@ function subscriptionHeaders(a: CodexAuthHeaders): Record<string, string> {
26
43
  export async function fetchCodexModels(
27
44
  a: CodexAuthHeaders,
28
45
  fetchImpl: CodexFetch = fetch,
46
+ timeoutMs = CODEX_READ_TIMEOUT_MS,
29
47
  ): Promise<{ ok: boolean; status: number; slugs: string[] }> {
30
- const res = await fetchImpl(
31
- `${CODEX_RESPONSES_BASE}/models?client_version=${encodeURIComponent(a.clientVersion)}`,
32
- {
33
- method: "GET",
34
- headers: subscriptionHeaders(a),
35
- },
36
- );
37
- if (!res.ok) {
38
- return { ok: false, status: res.status, slugs: [] };
39
- }
40
- const body = (await res.json()) as { models?: Array<{ slug?: string }> };
41
- const slugs = (body.models ?? [])
42
- .map((m) => m.slug)
43
- .filter((s): s is string => typeof s === "string");
44
- return { ok: true, status: res.status, slugs };
48
+ const fetched = await runBoundedCodexOperation(async (signal) => {
49
+ const res = await fetchImpl(
50
+ `${CODEX_RESPONSES_BASE}/models?client_version=${encodeURIComponent(a.clientVersion)}`,
51
+ { method: "GET", headers: subscriptionHeaders(a), signal },
52
+ );
53
+ if (!res.ok) {
54
+ await res.arrayBuffer().catch(() => undefined);
55
+ return { ok: false, status: res.status, slugs: [] as string[] };
56
+ }
57
+ const body = (await res.json()) as { models?: Array<{ slug?: string }> };
58
+ const slugs = (body.models ?? [])
59
+ .map((model) => model.slug)
60
+ .filter((slug): slug is string => typeof slug === "string");
61
+ return { ok: true, status: res.status, slugs };
62
+ }, timeoutMs);
63
+ return fetched.ok ? fetched.value : { ok: false, status: 0, slugs: [] };
45
64
  }
46
65
 
47
66
  /** GET /wham/usage — authoritative limits. NB the WHAM base is /backend-api, NOT /codex (spec §1.8a). */
48
67
  export async function fetchCodexUsage(
49
68
  a: CodexAuthHeaders,
50
69
  fetchImpl: CodexFetch = fetch,
70
+ timeoutMs = CODEX_READ_TIMEOUT_MS,
51
71
  ): Promise<{ status: number; payload: unknown }> {
52
- const res = await fetchImpl(`${CODEX_WHAM_BASE}/wham/usage`, {
53
- method: "GET",
54
- headers: subscriptionHeaders(a),
55
- });
56
- // A 404 may carry a usage-limit body; the route layer normalizes it to a limits state (spec §1.8c).
57
- const payload = res.ok || res.status === 404 ? await res.json().catch(() => null) : null;
58
- return { status: res.status, payload };
72
+ const fetched = await runBoundedCodexOperation(async (signal) => {
73
+ const res = await fetchImpl(`${CODEX_WHAM_BASE}/wham/usage`, {
74
+ method: "GET",
75
+ headers: subscriptionHeaders(a),
76
+ signal,
77
+ });
78
+ // A 404 may carry a usage-limit body; the route layer normalizes it to a limits state (spec §1.8c).
79
+ const payload = res.ok || res.status === 404 ? await res.json().catch(() => null) : null;
80
+ if (!res.ok && res.status !== 404) await res.arrayBuffer().catch(() => undefined);
81
+ return { status: res.status, payload };
82
+ }, timeoutMs);
83
+ if (!fetched.ok) throw new Error(`Codex usage request ${fetched.reason}`);
84
+ return fetched.value;
85
+ }
86
+
87
+ /**
88
+ * GET /wham/rate-limit-reset-credits — detailed earned reset credits.
89
+ *
90
+ * A non-2xx or malformed body returns an explicit non-ok result. The caller may
91
+ * fall back to the count-only summary embedded in /wham/usage, but must never
92
+ * invent actionable rows from that count.
93
+ */
94
+ export async function fetchCodexRateLimitResetCredits(
95
+ a: CodexAuthHeaders,
96
+ fetchImpl: CodexFetch = fetch,
97
+ timeoutMs = RESET_CREDIT_DETAILS_TIMEOUT_MS,
98
+ ): Promise<
99
+ | { ok: true; status: number; details: CodexRateLimitResetCreditsDetails }
100
+ | { ok: false; status: number; reason: ResetCreditFetchFailureReason }
101
+ > {
102
+ const fetched = await runBoundedCodexOperation(async (signal) => {
103
+ const res = await fetchImpl(`${CODEX_WHAM_BASE}/wham/rate-limit-reset-credits`, {
104
+ method: "GET",
105
+ headers: subscriptionHeaders(a),
106
+ signal,
107
+ });
108
+ if (!res.ok) {
109
+ // Drain the body without retaining/logging it. Provider error bodies may
110
+ // contain account-specific details and are not part of this contract.
111
+ await res.arrayBuffer().catch(() => undefined);
112
+ return { ok: false as const, status: res.status, reason: "http_error" as const };
113
+ }
114
+ const details = parseCodexRateLimitResetCreditsDetails(await res.json().catch(() => null));
115
+ return details
116
+ ? { ok: true as const, status: res.status, details }
117
+ : { ok: false as const, status: res.status, reason: "invalid_response" as const };
118
+ }, timeoutMs);
119
+ return fetched.ok ? fetched.value : { ok: false, status: 0, reason: fetched.reason };
120
+ }
121
+
122
+ /**
123
+ * POST /wham/rate-limit-reset-credits/consume with the exact v0.144.6 body.
124
+ * `idempotencyKey` identifies one logical human redemption and MUST be reused
125
+ * by the server on retries. Supplying `creditId` is preferred; omission leaves
126
+ * provider selection in control and is therefore not used by OpenGeni's
127
+ * human-only flow.
128
+ */
129
+ export async function consumeCodexRateLimitResetCredit(
130
+ a: CodexAuthHeaders,
131
+ input: { idempotencyKey: string; creditId?: string | undefined },
132
+ fetchImpl: CodexFetch = fetch,
133
+ timeoutMs = RESET_CREDIT_CONSUME_TIMEOUT_MS,
134
+ ): Promise<
135
+ | { ok: true; status: number; result: CodexRateLimitResetConsumeResponse }
136
+ | {
137
+ ok: false;
138
+ status: number;
139
+ reason: ResetCreditFetchFailureReason | "invalid_request";
140
+ }
141
+ > {
142
+ if (input.idempotencyKey.length === 0 || input.creditId === "") {
143
+ return { ok: false, status: 0, reason: "invalid_request" };
144
+ }
145
+ const fetched = await runBoundedCodexOperation(async (signal) => {
146
+ const res = await fetchImpl(`${CODEX_WHAM_BASE}/wham/rate-limit-reset-credits/consume`, {
147
+ method: "POST",
148
+ headers: {
149
+ ...subscriptionHeaders(a),
150
+ "content-type": "application/json",
151
+ },
152
+ body: JSON.stringify({
153
+ redeem_request_id: input.idempotencyKey,
154
+ ...(input.creditId ? { credit_id: input.creditId } : {}),
155
+ }),
156
+ signal,
157
+ });
158
+ if (!res.ok) {
159
+ await res.arrayBuffer().catch(() => undefined);
160
+ return { ok: false as const, status: res.status, reason: "http_error" as const };
161
+ }
162
+ const result = parseCodexRateLimitResetConsumeResponse(await res.json().catch(() => null));
163
+ return result
164
+ ? { ok: true as const, status: res.status, result }
165
+ : { ok: false as const, status: res.status, reason: "invalid_response" as const };
166
+ }, timeoutMs);
167
+ return fetched.ok ? fetched.value : { ok: false, status: 0, reason: fetched.reason };
59
168
  }
@@ -0,0 +1,43 @@
1
+ export type CodexOperationFailureReason = "network_error" | "timeout";
2
+
3
+ /**
4
+ * Bound the complete provider operation, including response-body consumption.
5
+ *
6
+ * AbortController makes native fetch release its socket, while Promise.race is
7
+ * the backstop for injected/custom fetch implementations that ignore `signal`.
8
+ * The losing operation is rejection-handled and can never become an unhandled
9
+ * promise after the caller has received the timeout result.
10
+ */
11
+ export async function runBoundedCodexOperation<T>(
12
+ operation: (signal: AbortSignal) => Promise<T>,
13
+ timeoutMs: number,
14
+ ): Promise<{ ok: true; value: T } | { ok: false; reason: CodexOperationFailureReason }> {
15
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
16
+ throw new Error("Codex operation timeout must be positive");
17
+ }
18
+
19
+ const controller = new AbortController();
20
+ let timedOut = false;
21
+ let timeout: ReturnType<typeof setTimeout> | undefined;
22
+ const work = operation(controller.signal).then(
23
+ (value) => ({ ok: true as const, value }),
24
+ () => ({
25
+ ok: false as const,
26
+ reason:
27
+ timedOut || controller.signal.aborted ? ("timeout" as const) : ("network_error" as const),
28
+ }),
29
+ );
30
+ const deadline = new Promise<{ ok: false; reason: "timeout" }>((resolve) => {
31
+ timeout = setTimeout(() => {
32
+ timedOut = true;
33
+ controller.abort();
34
+ resolve({ ok: false, reason: "timeout" });
35
+ }, timeoutMs);
36
+ });
37
+
38
+ try {
39
+ return await Promise.race([work, deadline]);
40
+ } finally {
41
+ if (timeout) clearTimeout(timeout);
42
+ }
43
+ }
package/src/constants.ts CHANGED
@@ -57,6 +57,24 @@ export const CODEX_CLIENT_VERSION = "0.144.6";
57
57
  export const CODEX_REFRESH_WINDOW_MS = 5 * 60 * 1000; // proactive refresh when within 5 min of exp (spec §1.1)
58
58
  export const CODEX_REFRESH_FALLBACK_MS = 8 * 24 * 60 * 60 * 1000; // 8 days when exp is unparseable
59
59
 
60
+ // Codex Responses transport deadlines. The OpenAI SDK's own timeout only covers
61
+ // the wait for response headers and erases the underlying timeout class into the
62
+ // bare `Request timed out.` error. Keep the provider-specific budgets here so
63
+ // the transport can enforce and durably report them without enabling the SDK's
64
+ // blind request replay.
65
+ export const CODEX_RESPONSE_HEADERS_TIMEOUT_MS = 4 * 60_000;
66
+ export const CODEX_RESPONSE_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
67
+ export const CODEX_RESPONSE_WHOLE_TIMEOUT_MS = 30 * 60_000;
68
+ // Kept as a compatibility-shaped policy field, but automatic replay is disabled
69
+ // until a provider-specific operation receipt can prove non-acceptance or resume
70
+ // the same operation identity. An absent response does not prove that the
71
+ // provider never accepted the request.
72
+ export const CODEX_RESPONSE_NO_BYTE_RETRIES = 0;
73
+ export const CODEX_RESPONSE_RETRY_BACKOFF_MS = 1_000;
74
+ // Must exceed the transport-owned whole-response deadline. This SDK guard is a
75
+ // last-resort envelope; the inner transport emits the typed/durable failure.
76
+ export const CODEX_RESPONSE_SDK_OUTER_TIMEOUT_MS = 35 * 60_000;
77
+
60
78
  // ── Apps / connectors MCP (spec §1.10, §E) ───────────────────────────────────
61
79
  // One server-side MCP exposes ALL the user's ChatGPT/Codex connectors
62
80
  // (gmail/github/linear/slack/sentry/drive/calendar/…). Streamable HTTP, always.
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
 
@@ -118,6 +129,252 @@ export function parseCodexUsageHeaders(headers: Headers): CodexUsageHeaderSnapsh
118
129
  };
119
130
  }
120
131
 
132
+ type RequestAudit = {
133
+ ctx: CodexRequestContext;
134
+ requestId: string;
135
+ transportAttempt: number;
136
+ model?: string;
137
+ logicalStartedAt: number;
138
+ attemptStartedAt: number;
139
+ policy: CodexResponseTimeoutPolicy;
140
+ };
141
+
142
+ async function emitRequestEvent(
143
+ audit: RequestAudit,
144
+ event: Omit<
145
+ CodexModelRequestEvent,
146
+ "requestId" | "transportAttempt" | "model" | "durationMs" | "timeoutPolicy"
147
+ >,
148
+ ): Promise<void> {
149
+ await audit.ctx.onModelRequestEvent?.({
150
+ requestId: audit.requestId,
151
+ transportAttempt: audit.transportAttempt,
152
+ ...(audit.model ? { model: audit.model } : {}),
153
+ durationMs: Math.max(0, Date.now() - audit.attemptStartedAt),
154
+ timeoutPolicy: audit.policy,
155
+ ...event,
156
+ });
157
+ }
158
+
159
+ function providerRequestId(headers: Headers): string | undefined {
160
+ return headers.get("x-request-id") ?? headers.get("request-id") ?? undefined;
161
+ }
162
+
163
+ async function fetchBeforeHeaders(
164
+ base: FetchLike,
165
+ input: string,
166
+ init: RequestInit,
167
+ audit: RequestAudit,
168
+ ): Promise<Response> {
169
+ const elapsed = Date.now() - audit.logicalStartedAt;
170
+ const wholeRemainingMs = audit.policy.wholeRequestTimeoutMs - elapsed;
171
+ const timeoutClass =
172
+ wholeRemainingMs <= audit.policy.headersTimeoutMs ? "whole_request" : "headers";
173
+ const deadlineMs = Math.max(1, Math.min(audit.policy.headersTimeoutMs, wholeRemainingMs));
174
+ if (wholeRemainingMs <= 0) {
175
+ throw new CodexResponseTimeoutError("whole_request", audit.requestId, false);
176
+ }
177
+
178
+ const externalSignal = init.signal;
179
+ if (externalSignal?.aborted) throw externalSignal.reason;
180
+ const controller = new AbortController();
181
+ const forwardAbort = () => controller.abort(externalSignal?.reason);
182
+ externalSignal?.addEventListener("abort", forwardAbort, { once: true });
183
+ const basePromise = base(input, { ...init, signal: controller.signal });
184
+ let deadlineError: CodexResponseTimeoutError | null = null;
185
+ let timer: ReturnType<typeof setTimeout> | undefined;
186
+ const deadline = new Promise<never>((_resolve, reject) => {
187
+ timer = setTimeout(() => {
188
+ deadlineError = new CodexResponseTimeoutError(timeoutClass, audit.requestId, false);
189
+ reject(deadlineError);
190
+ }, deadlineMs);
191
+ });
192
+ try {
193
+ return await Promise.race([basePromise, deadline]);
194
+ } catch (error) {
195
+ if (deadlineError) {
196
+ controller.abort(deadlineError);
197
+ void basePromise
198
+ .then((late) => late.body?.cancel(deadlineError ?? undefined))
199
+ .catch(() => undefined);
200
+ throw deadlineError;
201
+ }
202
+ throw error;
203
+ } finally {
204
+ if (timer) clearTimeout(timer);
205
+ externalSignal?.removeEventListener("abort", forwardAbort);
206
+ }
207
+ }
208
+
209
+ async function observedResponse(
210
+ res: Response,
211
+ audit: RequestAudit,
212
+ externalSignal: AbortSignal | null | undefined,
213
+ ): Promise<Response> {
214
+ const requestId = providerRequestId(res.headers);
215
+ if (!res.body) {
216
+ await emitRequestEvent(audit, {
217
+ phase: res.ok ? "completed" : "failed",
218
+ responseObserved: true,
219
+ status: res.status,
220
+ ...(requestId ? { providerRequestId: requestId } : {}),
221
+ });
222
+ return res;
223
+ }
224
+
225
+ const reader = res.body.getReader();
226
+ let terminal = false;
227
+ let firstByte = false;
228
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
229
+ let wholeTimer: ReturnType<typeof setTimeout> | undefined;
230
+ let armIdle: () => void = () => undefined;
231
+ let abortFromOutside: (() => void) | undefined;
232
+
233
+ const clearTimers = () => {
234
+ if (idleTimer) clearTimeout(idleTimer);
235
+ if (wholeTimer) clearTimeout(wholeTimer);
236
+ if (abortFromOutside) externalSignal?.removeEventListener("abort", abortFromOutside);
237
+ };
238
+
239
+ const body = new ReadableStream<Uint8Array>({
240
+ start(controller) {
241
+ const timeOut = (klass: "idle_stream" | "whole_request") => {
242
+ if (terminal) return;
243
+ terminal = true;
244
+ clearTimers();
245
+ const error = new CodexResponseTimeoutError(klass, audit.requestId, true);
246
+ void reader.cancel(error).catch(() => undefined);
247
+ void emitRequestEvent(audit, {
248
+ phase: "timed_out",
249
+ responseObserved: true,
250
+ timeoutClass: klass,
251
+ status: res.status,
252
+ ...(requestId ? { providerRequestId: requestId } : {}),
253
+ }).then(
254
+ () => controller.error(error),
255
+ () => controller.error(error),
256
+ );
257
+ };
258
+ armIdle = () => {
259
+ if (idleTimer) clearTimeout(idleTimer);
260
+ idleTimer = setTimeout(() => timeOut("idle_stream"), audit.policy.streamIdleTimeoutMs);
261
+ };
262
+ armIdle();
263
+ const wholeRemaining = Math.max(
264
+ 1,
265
+ audit.policy.wholeRequestTimeoutMs - (Date.now() - audit.logicalStartedAt),
266
+ );
267
+ wholeTimer = setTimeout(() => timeOut("whole_request"), wholeRemaining);
268
+ abortFromOutside = () => {
269
+ if (terminal) return;
270
+ terminal = true;
271
+ clearTimers();
272
+ const reason = externalSignal?.reason ?? new DOMException("Aborted", "AbortError");
273
+ void reader.cancel(reason).catch(() => undefined);
274
+ void emitRequestEvent(audit, {
275
+ phase: "failed",
276
+ responseObserved: true,
277
+ status: res.status,
278
+ ...(requestId ? { providerRequestId: requestId } : {}),
279
+ }).then(
280
+ () => controller.error(reason),
281
+ () => controller.error(reason),
282
+ );
283
+ };
284
+ if (externalSignal?.aborted) {
285
+ abortFromOutside();
286
+ } else {
287
+ externalSignal?.addEventListener("abort", abortFromOutside, { once: true });
288
+ }
289
+ },
290
+ async pull(controller) {
291
+ if (terminal) return;
292
+ try {
293
+ const chunk = await reader.read();
294
+ if (terminal) return;
295
+ if (chunk.done) {
296
+ terminal = true;
297
+ clearTimers();
298
+ await emitRequestEvent(audit, {
299
+ phase: res.ok ? "completed" : "failed",
300
+ responseObserved: true,
301
+ status: res.status,
302
+ ...(requestId ? { providerRequestId: requestId } : {}),
303
+ });
304
+ controller.close();
305
+ return;
306
+ }
307
+ armIdle();
308
+ if (!firstByte) {
309
+ firstByte = true;
310
+ await emitRequestEvent(audit, {
311
+ phase: "first_byte",
312
+ responseObserved: true,
313
+ status: res.status,
314
+ ...(requestId ? { providerRequestId: requestId } : {}),
315
+ });
316
+ }
317
+ controller.enqueue(chunk.value);
318
+ } catch (error) {
319
+ if (terminal) return;
320
+ terminal = true;
321
+ clearTimers();
322
+ await emitRequestEvent(audit, {
323
+ phase: "failed",
324
+ responseObserved: true,
325
+ status: res.status,
326
+ ...(requestId ? { providerRequestId: requestId } : {}),
327
+ });
328
+ controller.error(error);
329
+ }
330
+ },
331
+ async cancel(reason) {
332
+ if (!terminal) {
333
+ terminal = true;
334
+ clearTimers();
335
+ await emitRequestEvent(audit, {
336
+ phase: "failed",
337
+ responseObserved: true,
338
+ status: res.status,
339
+ ...(requestId ? { providerRequestId: requestId } : {}),
340
+ }).catch(() => undefined);
341
+ }
342
+ await reader.cancel(reason).catch(() => undefined);
343
+ },
344
+ });
345
+ const headers = new Headers(res.headers);
346
+ headers.delete("content-length");
347
+ return new Response(body, { status: res.status, statusText: res.statusText, headers });
348
+ }
349
+
350
+ function timeoutErrorResponse(info: {
351
+ timeoutClass: "connect" | "headers" | "idle_stream" | "whole_request";
352
+ requestId: string;
353
+ responseObserved: boolean;
354
+ message: string;
355
+ }): Response {
356
+ return new Response(
357
+ JSON.stringify({
358
+ error: {
359
+ type: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
360
+ code: CODEX_RESPONSE_TIMEOUT_ERROR_TYPE,
361
+ message: info.message,
362
+ timeout_class: info.timeoutClass,
363
+ response_observed: info.responseObserved,
364
+ request_id: info.requestId,
365
+ },
366
+ }),
367
+ {
368
+ status: 504,
369
+ headers: {
370
+ "content-type": "application/json",
371
+ "x-should-retry": "false",
372
+ [CODEX_TRANSPORT_ERROR_HEADER]: "1",
373
+ },
374
+ },
375
+ );
376
+ }
377
+
121
378
  export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): FetchLike {
122
379
  return async (input, init) => {
123
380
  const ctx = codexRequestStorage.getStore();
@@ -131,7 +388,15 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
131
388
  // URLs whose base already includes /codex (avoids /codex/codex/responses).
132
389
  const rewritten = rawUrl.replace(/(?<!\/codex)\/responses(\b|$)/, "/codex/responses$1");
133
390
 
134
- const attempt = async (auth: CodexTokenSnapshot): Promise<Response> => {
391
+ const policy = resolveCodexResponseTimeoutPolicy(ctx.responseTimeoutPolicy);
392
+ const requestId = ctx.nextRequestId?.() ?? randomUUID();
393
+ const logicalStartedAt = Date.now();
394
+ let transportAttempt = 0;
395
+
396
+ const attempt = async (
397
+ auth: CodexTokenSnapshot,
398
+ authenticationAttempt: number,
399
+ ): Promise<Response> => {
135
400
  const headers = new Headers(init?.headers);
136
401
  headers.set("Authorization", `Bearer ${auth.accessToken}`);
137
402
  if (auth.chatgptAccountId) {
@@ -158,26 +423,90 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
158
423
  // the caller's intent so a non-streaming caller (e.g. the compaction
159
424
  // summarizer) still gets a single JSON Response back.
160
425
  let callerWantsStream = true;
426
+ let model: string | undefined;
161
427
  const nextInit: RequestInit = { ...init, headers };
162
428
  if (typeof init?.body === "string") {
163
429
  try {
164
430
  const parsed = JSON.parse(init.body) as Record<string, unknown>;
165
431
  callerWantsStream = parsed.stream === true;
166
- nextInit.body = JSON.stringify(normalizeCodexRequestBody(parsed, ctx.resolveModel));
432
+ const normalized = normalizeCodexRequestBody(parsed, ctx.resolveModel);
433
+ model = typeof normalized.model === "string" ? normalized.model : undefined;
434
+ nextInit.body = JSON.stringify(normalized);
167
435
  } catch {
168
436
  /* leave unparseable bodies untouched (already copied from init) */
169
437
  }
170
438
  }
439
+ headers.set(
440
+ "Idempotency-Key",
441
+ authenticationAttempt === 0 ? requestId : `${requestId}:auth-${authenticationAttempt}`,
442
+ );
171
443
  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
- : [];
444
+ let keys: string[] = [];
445
+ if (typeof nextInit.body === "string") {
446
+ try {
447
+ keys = Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>);
448
+ } catch {
449
+ /* an unparseable body is already passed through unchanged above */
450
+ }
451
+ }
176
452
  console.error(
177
453
  `[codex-debug] POST ${rewritten} stream=${callerWantsStream} bodyKeys=[${keys.join(",")}]`,
178
454
  );
179
455
  }
180
- const res = await base(rewritten, nextInit);
456
+ let res: Response;
457
+ transportAttempt += 1;
458
+ const audit: RequestAudit = {
459
+ ctx,
460
+ requestId,
461
+ transportAttempt,
462
+ ...(model ? { model } : {}),
463
+ logicalStartedAt,
464
+ attemptStartedAt: Date.now(),
465
+ policy,
466
+ };
467
+ await emitRequestEvent(audit, {
468
+ phase: "started",
469
+ responseObserved: false,
470
+ });
471
+ try {
472
+ res = await fetchBeforeHeaders(base, rewritten, nextInit, audit);
473
+ const upstreamRequestId = providerRequestId(res.headers);
474
+ await emitRequestEvent(audit, {
475
+ phase: "headers",
476
+ responseObserved: true,
477
+ status: res.status,
478
+ ...(upstreamRequestId ? { providerRequestId: upstreamRequestId } : {}),
479
+ });
480
+ const observed = await observedResponse(res, audit, nextInit.signal);
481
+ res = observed;
482
+ } catch (error) {
483
+ if (nextInit.signal?.aborted) {
484
+ await emitRequestEvent(audit, {
485
+ phase: "failed",
486
+ responseObserved: false,
487
+ }).catch(() => undefined);
488
+ throw error;
489
+ }
490
+ const klass = isPreHeadersTimeoutError(error);
491
+ if (!klass) {
492
+ await emitRequestEvent(audit, {
493
+ phase: "failed",
494
+ responseObserved: false,
495
+ });
496
+ throw error;
497
+ }
498
+ // An absent response does not prove that the provider never accepted
499
+ // this operation. Until a provider-specific receipt can prove
500
+ // non-acceptance or resume the same operation, never replay it.
501
+ // Audit persistence must not replace the typed transport timeout.
502
+ await emitRequestEvent(audit, {
503
+ phase: "timed_out",
504
+ responseObserved: false,
505
+ timeoutClass: klass,
506
+ willRetry: false,
507
+ }).catch(() => undefined);
508
+ throw new CodexResponseTimeoutError(klass, requestId, false);
509
+ }
181
510
  // Multi-account P4 (Part A): scrape the usage headers ONCE, before the
182
511
  // OK/!res.ok branch, so the same fire-and-forget read also covers the 429
183
512
  // hard-cap path (an exhausted serving account stamps its own fresh
@@ -216,11 +545,22 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
216
545
  return callerWantsStream ? repairCodexStream(res) : await sseToJsonResponse(res);
217
546
  };
218
547
 
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)
548
+ try {
549
+ let res = await attempt(await ctx.getToken(), 0);
550
+ if (res.status === 401) {
551
+ res = await attempt(await ctx.refresh(), 1); // single refresh-on-401 retry (spec §1.9)
552
+ }
553
+ return res;
554
+ } catch (error) {
555
+ const timeout = classifyCodexResponseTimeoutError(error);
556
+ if (!timeout) throw error;
557
+ return timeoutErrorResponse({
558
+ timeoutClass: timeout.timeoutClass,
559
+ requestId: timeout.requestId ?? requestId,
560
+ responseObserved: timeout.responseObserved,
561
+ message: timeout.message,
562
+ });
222
563
  }
223
- return res;
224
564
  };
225
565
  }
226
566
 
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";