@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/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 +7 -1
- package/dist/constants.js +13 -1
- package/dist/index.d.ts +143 -5
- package/dist/index.js +637 -66
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/api-client.ts +131 -22
- package/src/bounded-operation.ts +43 -0
- package/src/constants.ts +18 -0
- package/src/fetch.ts +351 -11
- package/src/index.ts +2 -0
- package/src/refresh.ts +21 -10
- package/src/request-context.ts +38 -0
- package/src/reset-credits.ts +157 -0
- package/src/response-timeout.ts +159 -0
- package/src/usage-normalize.ts +20 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/codex",
|
|
3
|
-
"version": "0.2.
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
method: "GET",
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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";
|