@opengeni/codex 0.2.1 → 0.2.2
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/LICENSE +190 -0
- package/dist/{chunk-2ESTGQ57.js → chunk-KKKJKW6M.js} +17 -8
- package/dist/chunk-KKKJKW6M.js.map +1 -0
- package/dist/constants.d.ts +8 -3
- package/dist/constants.js +11 -1
- package/dist/index.d.ts +24 -2
- package/dist/index.js +95 -17
- package/dist/index.js.map +1 -1
- package/package.json +15 -18
- package/src/api-client.ts +10 -5
- package/src/constants.ts +31 -18
- package/src/device-code.ts +15 -3
- package/src/fetch.ts +95 -13
- package/src/mcp-sanitize.ts +26 -8
- package/src/normalize.ts +6 -2
- package/src/refresh.ts +24 -7
- package/src/request-context.ts +13 -0
- package/src/usage-normalize.ts +26 -9
- package/dist/chunk-2ESTGQ57.js.map +0 -1
package/src/fetch.ts
CHANGED
|
@@ -12,10 +12,46 @@
|
|
|
12
12
|
|
|
13
13
|
import { CODEX_ORIGINATOR } from "./constants";
|
|
14
14
|
import { normalizeCodexRequestBody } from "./normalize";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
codexRequestStorage,
|
|
17
|
+
type CodexTokenSnapshot,
|
|
18
|
+
type CodexUsageHeaderSnapshot,
|
|
19
|
+
} from "./request-context";
|
|
16
20
|
|
|
17
21
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Internal provenance marker copied onto buffered non-OK Codex responses.
|
|
25
|
+
* OpenAI's APIError preserves response headers, which lets the worker
|
|
26
|
+
* distinguish a model-provider refusal from an unrelated sandbox/MCP HTTP
|
|
27
|
+
* error that happened during the same Codex turn.
|
|
28
|
+
*/
|
|
29
|
+
export const CODEX_TRANSPORT_ERROR_HEADER = "x-opengeni-codex-transport-error";
|
|
30
|
+
|
|
31
|
+
function headersCarryCodexTransportMarker(headers: unknown): boolean {
|
|
32
|
+
if (!headers || typeof headers !== "object") return false;
|
|
33
|
+
const getter = (headers as { get?: unknown }).get;
|
|
34
|
+
if (typeof getter === "function") {
|
|
35
|
+
return getter.call(headers, CODEX_TRANSPORT_ERROR_HEADER) === "1";
|
|
36
|
+
}
|
|
37
|
+
const record = headers as Record<string, unknown>;
|
|
38
|
+
return (
|
|
39
|
+
record[CODEX_TRANSPORT_ERROR_HEADER] === "1" ||
|
|
40
|
+
record[CODEX_TRANSPORT_ERROR_HEADER.toLowerCase()] === "1"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** True only for an error produced from this Codex transport's non-OK response. */
|
|
45
|
+
export function isCodexTransportError(error: unknown): boolean {
|
|
46
|
+
let current: unknown = error;
|
|
47
|
+
for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
|
|
48
|
+
const value = current as Record<string, unknown>;
|
|
49
|
+
if (headersCarryCodexTransportMarker(value.headers)) return true;
|
|
50
|
+
current = value.cause;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
19
55
|
/** Parse an integer header value; null when absent or not a finite integer. */
|
|
20
56
|
function parseIntHeader(value: string | null): number | null {
|
|
21
57
|
if (value === null) {
|
|
@@ -65,9 +101,19 @@ export function parseCodexUsageHeaders(headers: Headers): CodexUsageHeaderSnapsh
|
|
|
65
101
|
const nowMs = Date.now();
|
|
66
102
|
return {
|
|
67
103
|
primaryUsedPercent,
|
|
68
|
-
primaryResetAt: resolveResetAt(
|
|
104
|
+
primaryResetAt: resolveResetAt(
|
|
105
|
+
headers,
|
|
106
|
+
"x-codex-primary-reset-at",
|
|
107
|
+
"x-codex-primary-reset-after-seconds",
|
|
108
|
+
nowMs,
|
|
109
|
+
),
|
|
69
110
|
secondaryUsedPercent,
|
|
70
|
-
secondaryResetAt: resolveResetAt(
|
|
111
|
+
secondaryResetAt: resolveResetAt(
|
|
112
|
+
headers,
|
|
113
|
+
"x-codex-secondary-reset-at",
|
|
114
|
+
"x-codex-secondary-reset-after-seconds",
|
|
115
|
+
nowMs,
|
|
116
|
+
),
|
|
71
117
|
checkedAt: new Date(nowMs),
|
|
72
118
|
};
|
|
73
119
|
}
|
|
@@ -96,6 +142,12 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
96
142
|
headers.set("version", ctx.clientVersion);
|
|
97
143
|
headers.set("accept", "text/event-stream");
|
|
98
144
|
headers.set("content-type", "application/json");
|
|
145
|
+
if (ctx.sessionId) {
|
|
146
|
+
// Backend sticky cache-routing key (see CodexRequestContext.sessionId):
|
|
147
|
+
// without it, byte-identical resends miss the prompt cache ~half the
|
|
148
|
+
// time; with it they pin to a warm shard and hit at the ceiling.
|
|
149
|
+
headers.set("session_id", ctx.sessionId);
|
|
150
|
+
}
|
|
99
151
|
if (auth.isFedramp) {
|
|
100
152
|
headers.set("X-OpenAI-Fedramp", "true");
|
|
101
153
|
}
|
|
@@ -117,8 +169,13 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
117
169
|
}
|
|
118
170
|
}
|
|
119
171
|
if (process.env.CODEX_DEBUG) {
|
|
120
|
-
const keys =
|
|
121
|
-
|
|
172
|
+
const keys =
|
|
173
|
+
typeof nextInit.body === "string"
|
|
174
|
+
? Object.keys(JSON.parse(nextInit.body) as Record<string, unknown>)
|
|
175
|
+
: [];
|
|
176
|
+
console.error(
|
|
177
|
+
`[codex-debug] POST ${rewritten} stream=${callerWantsStream} bodyKeys=[${keys.join(",")}]`,
|
|
178
|
+
);
|
|
122
179
|
}
|
|
123
180
|
const res = await base(rewritten, nextInit);
|
|
124
181
|
// Multi-account P4 (Part A): scrape the usage headers ONCE, before the
|
|
@@ -132,7 +189,12 @@ export function codexSubscriptionFetch(base: FetchLike = globalThis.fetch): Fetc
|
|
|
132
189
|
ctx.onUsageHeaders?.(usage);
|
|
133
190
|
}
|
|
134
191
|
if (process.env.CODEX_DEBUG && !res.ok) {
|
|
135
|
-
|
|
192
|
+
// Never log provider bodies: they can contain request-derived content or
|
|
193
|
+
// account details. Status + request id is sufficient to correlate with
|
|
194
|
+
// the structured worker failure telemetry.
|
|
195
|
+
console.error(
|
|
196
|
+
`[codex-debug] <- ${res.status} requestId=${res.headers.get("x-request-id") ?? "unknown"}`,
|
|
197
|
+
);
|
|
136
198
|
}
|
|
137
199
|
// The codex backend leaves the terminal event's response.output empty and
|
|
138
200
|
// delivers the assistant items via output_item.done events instead. The
|
|
@@ -182,8 +244,12 @@ export function classifyCodexUsageLimitError(error: unknown): CodexUsageLimitInf
|
|
|
182
244
|
let cur: unknown = error;
|
|
183
245
|
for (let depth = 0; depth < 6 && cur && typeof cur === "object"; depth++) {
|
|
184
246
|
const e = cur as Record<string, unknown>;
|
|
185
|
-
const body = (e.error && typeof e.error === "object" ? e.error : undefined) as
|
|
186
|
-
|
|
247
|
+
const body = (e.error && typeof e.error === "object" ? e.error : undefined) as
|
|
248
|
+
| Record<string, unknown>
|
|
249
|
+
| undefined;
|
|
250
|
+
const type =
|
|
251
|
+
(typeof e.type === "string" ? e.type : undefined) ??
|
|
252
|
+
(typeof body?.type === "string" ? body.type : undefined);
|
|
187
253
|
const message = typeof e.message === "string" ? e.message : "";
|
|
188
254
|
const status = Number(e.status);
|
|
189
255
|
if (
|
|
@@ -213,6 +279,7 @@ async function bufferCodexErrorResponse(res: Response): Promise<Response> {
|
|
|
213
279
|
const bodyText = await res.text().catch(() => "");
|
|
214
280
|
const headers = new Headers(res.headers);
|
|
215
281
|
headers.set("content-type", "application/json");
|
|
282
|
+
headers.set(CODEX_TRANSPORT_ERROR_HEADER, "1");
|
|
216
283
|
headers.delete("content-length"); // body re-serialized
|
|
217
284
|
headers.delete("content-encoding"); // text() already decoded any gzip
|
|
218
285
|
let errorType: string | undefined;
|
|
@@ -248,10 +315,18 @@ async function sseToJsonResponse(res: Response): Promise<Response> {
|
|
|
248
315
|
continue;
|
|
249
316
|
}
|
|
250
317
|
try {
|
|
251
|
-
const ev = JSON.parse(data) as {
|
|
318
|
+
const ev = JSON.parse(data) as {
|
|
319
|
+
type?: string;
|
|
320
|
+
response?: Record<string, unknown>;
|
|
321
|
+
item?: unknown;
|
|
322
|
+
};
|
|
252
323
|
if (ev.type === "response.output_item.done" && ev.item !== undefined) {
|
|
253
324
|
items.push(ev.item);
|
|
254
|
-
} else if (
|
|
325
|
+
} else if (
|
|
326
|
+
ev.type === "response.completed" ||
|
|
327
|
+
ev.type === "response.done" ||
|
|
328
|
+
ev.type === "response.incomplete"
|
|
329
|
+
) {
|
|
255
330
|
final = ev.response ?? null;
|
|
256
331
|
}
|
|
257
332
|
} catch {
|
|
@@ -262,7 +337,9 @@ async function sseToJsonResponse(res: Response): Promise<Response> {
|
|
|
262
337
|
final = { ...final, output: items }; // prefer the assembled items over an empty output array
|
|
263
338
|
}
|
|
264
339
|
if (process.env.CODEX_DEBUG) {
|
|
265
|
-
console.error(
|
|
340
|
+
console.error(
|
|
341
|
+
`[codex-debug] sse->json items=${items.length} outputLen=${Array.isArray(final?.output) ? (final.output as unknown[]).length : "?"}`,
|
|
342
|
+
);
|
|
266
343
|
}
|
|
267
344
|
const headers = new Headers(res.headers);
|
|
268
345
|
headers.set("content-type", "application/json");
|
|
@@ -308,7 +385,10 @@ function repairCodexStream(res: Response): Response {
|
|
|
308
385
|
/** Collect output_item.done items (mutating `items`); rewrite the terminal event's empty output. */
|
|
309
386
|
function patchSseBlock(block: string, items: unknown[]): string {
|
|
310
387
|
const lines = block.split("\n");
|
|
311
|
-
const dataStr = lines
|
|
388
|
+
const dataStr = lines
|
|
389
|
+
.filter((l) => l.startsWith("data:"))
|
|
390
|
+
.map((l) => l.slice(5).trim())
|
|
391
|
+
.join("\n");
|
|
312
392
|
if (!dataStr || dataStr === "[DONE]") {
|
|
313
393
|
return block;
|
|
314
394
|
}
|
|
@@ -323,7 +403,9 @@ function patchSseBlock(block: string, items: unknown[]): string {
|
|
|
323
403
|
return block;
|
|
324
404
|
}
|
|
325
405
|
if (
|
|
326
|
-
(ev.type === "response.completed" ||
|
|
406
|
+
(ev.type === "response.completed" ||
|
|
407
|
+
ev.type === "response.done" ||
|
|
408
|
+
ev.type === "response.incomplete") &&
|
|
327
409
|
ev.response
|
|
328
410
|
) {
|
|
329
411
|
const out = ev.response.output;
|
package/src/mcp-sanitize.ts
CHANGED
|
@@ -85,9 +85,10 @@ export class ToolNameMapper {
|
|
|
85
85
|
let n = 2;
|
|
86
86
|
do {
|
|
87
87
|
const suffix = `_${n++}`;
|
|
88
|
-
candidate =
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
candidate =
|
|
89
|
+
(base.length + suffix.length > EFFECTIVE_MAX_TOOL_NAME_LEN
|
|
90
|
+
? base.slice(0, EFFECTIVE_MAX_TOOL_NAME_LEN - suffix.length)
|
|
91
|
+
: base) + suffix;
|
|
91
92
|
} while (this.used.has(candidate));
|
|
92
93
|
}
|
|
93
94
|
this.used.add(candidate);
|
|
@@ -110,7 +111,11 @@ export class ToolNameMapper {
|
|
|
110
111
|
* dotted name BEFORE mapper.sanitize rewrites the dot away. Only dotted names carry a
|
|
111
112
|
* connector namespace; un-dotted (already-legal) names are not connectors and are skipped.
|
|
112
113
|
*/
|
|
113
|
-
function sanitizeToolsInRpcMessage(
|
|
114
|
+
function sanitizeToolsInRpcMessage(
|
|
115
|
+
message: unknown,
|
|
116
|
+
mapper: ToolNameMapper,
|
|
117
|
+
namespaceSink?: Set<string>,
|
|
118
|
+
): void {
|
|
114
119
|
if (!message || typeof message !== "object") {
|
|
115
120
|
return;
|
|
116
121
|
}
|
|
@@ -190,7 +195,11 @@ function inlineStructuredContentInRpcMessage(message: unknown): void {
|
|
|
190
195
|
}
|
|
191
196
|
|
|
192
197
|
/** Sanitize a single JSON body (application/json MCP response). */
|
|
193
|
-
export function sanitizeMcpJsonBody(
|
|
198
|
+
export function sanitizeMcpJsonBody(
|
|
199
|
+
text: string,
|
|
200
|
+
mapper: ToolNameMapper = new ToolNameMapper(),
|
|
201
|
+
namespaceSink?: Set<string>,
|
|
202
|
+
): string {
|
|
194
203
|
try {
|
|
195
204
|
const parsed = JSON.parse(text);
|
|
196
205
|
sanitizeToolsInRpcMessage(parsed, mapper, namespaceSink);
|
|
@@ -202,7 +211,11 @@ export function sanitizeMcpJsonBody(text: string, mapper: ToolNameMapper = new T
|
|
|
202
211
|
}
|
|
203
212
|
|
|
204
213
|
/** Sanitize an SSE body: each JSON-RPC message rides on a `data:` line. */
|
|
205
|
-
export function sanitizeMcpSseBody(
|
|
214
|
+
export function sanitizeMcpSseBody(
|
|
215
|
+
text: string,
|
|
216
|
+
mapper: ToolNameMapper = new ToolNameMapper(),
|
|
217
|
+
namespaceSink?: Set<string>,
|
|
218
|
+
): string {
|
|
206
219
|
return text
|
|
207
220
|
.split("\n")
|
|
208
221
|
.map((line) => {
|
|
@@ -256,7 +269,10 @@ export function remapToolCallRequestBody(body: string, mapper: ToolNameMapper):
|
|
|
256
269
|
* dot is sanitized away). The worker reads the (live, by-reference) Set after the
|
|
257
270
|
* turn to cache the serving account's connector set — packages/codex stays db-free.
|
|
258
271
|
*/
|
|
259
|
-
export function codexAppsSanitizingFetch(
|
|
272
|
+
export function codexAppsSanitizingFetch(
|
|
273
|
+
base: FetchLike = globalThis.fetch,
|
|
274
|
+
namespaceSink?: Set<string>,
|
|
275
|
+
): FetchLike {
|
|
260
276
|
const mapper = new ToolNameMapper();
|
|
261
277
|
return async (input, init) => {
|
|
262
278
|
// Outgoing: reverse a sanitized tools/call name to the server's original.
|
|
@@ -268,7 +284,9 @@ export function codexAppsSanitizingFetch(base: FetchLike = globalThis.fetch, nam
|
|
|
268
284
|
}
|
|
269
285
|
}
|
|
270
286
|
const res = await base(input, nextInit);
|
|
271
|
-
const method = (
|
|
287
|
+
const method = (
|
|
288
|
+
init?.method ?? (input instanceof Request ? input.method : "GET")
|
|
289
|
+
).toUpperCase();
|
|
272
290
|
if (method !== "POST" || !res.ok || !res.body) {
|
|
273
291
|
return res;
|
|
274
292
|
}
|
package/src/normalize.ts
CHANGED
|
@@ -44,7 +44,9 @@ export function normalizeCodexRequestBody(
|
|
|
44
44
|
body.stream = true; // ChatGPT backend REQUIRES stream=true (confirmed live: 400 "Stream must be set to true").
|
|
45
45
|
|
|
46
46
|
// include MUST contain reasoning.encrypted_content (stateless continuity, spec §1.6)
|
|
47
|
-
const include = Array.isArray(body.include)
|
|
47
|
+
const include = Array.isArray(body.include)
|
|
48
|
+
? (body.include as unknown[]).filter((v): v is string => typeof v === "string")
|
|
49
|
+
: [];
|
|
48
50
|
if (!include.includes("reasoning.encrypted_content")) {
|
|
49
51
|
include.push("reasoning.encrypted_content");
|
|
50
52
|
}
|
|
@@ -120,7 +122,9 @@ export function buildModelResolver(
|
|
|
120
122
|
fallbackSlug: string,
|
|
121
123
|
): (slug: string) => string {
|
|
122
124
|
return (requested: string): string => {
|
|
123
|
-
const stripped = requested.includes("/")
|
|
125
|
+
const stripped = requested.includes("/")
|
|
126
|
+
? requested.slice(requested.indexOf("/") + 1)
|
|
127
|
+
: requested;
|
|
124
128
|
let best = "";
|
|
125
129
|
for (const slug of liveSlugs) {
|
|
126
130
|
if (stripped.startsWith(slug) && slug.length > best.length) {
|
package/src/refresh.ts
CHANGED
|
@@ -36,7 +36,11 @@ export async function refreshCodexToken(
|
|
|
36
36
|
const res = await fetchImpl(CODEX_TOKEN_URL, {
|
|
37
37
|
method: "POST",
|
|
38
38
|
headers: { "Content-Type": "application/json" },
|
|
39
|
-
body: JSON.stringify({
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
client_id: CODEX_CLIENT_ID,
|
|
41
|
+
grant_type: "refresh_token",
|
|
42
|
+
refresh_token: refreshToken,
|
|
43
|
+
}),
|
|
40
44
|
});
|
|
41
45
|
const text = await res.text();
|
|
42
46
|
if (!res.ok) {
|
|
@@ -46,20 +50,33 @@ export async function refreshCodexToken(
|
|
|
46
50
|
throw new CodexReloginRequired(msg);
|
|
47
51
|
}
|
|
48
52
|
if (res.status === 401) {
|
|
49
|
-
throw new CodexReloginRequired(
|
|
53
|
+
throw new CodexReloginRequired(
|
|
54
|
+
"Your Codex session could not be refreshed. Please disconnect and sign in again.",
|
|
55
|
+
);
|
|
50
56
|
}
|
|
51
57
|
throw new CodexRefreshTransient(`Failed to refresh Codex token: ${res.status}`);
|
|
52
58
|
}
|
|
53
|
-
const body = JSON.parse(text) as {
|
|
54
|
-
|
|
59
|
+
const body = JSON.parse(text) as {
|
|
60
|
+
id_token?: string;
|
|
61
|
+
access_token?: string;
|
|
62
|
+
refresh_token?: string;
|
|
63
|
+
};
|
|
64
|
+
return {
|
|
65
|
+
idToken: body.id_token,
|
|
66
|
+
accessToken: body.access_token,
|
|
67
|
+
refreshToken: body.refresh_token,
|
|
68
|
+
};
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
// Codes that mean the refresh token is permanently dead -> reconnect required.
|
|
58
72
|
// Includes the standard OAuth `invalid_grant` alongside the Codex-specific codes.
|
|
59
73
|
const PERMANENT_REFRESH_FAILURES: Record<string, string> = {
|
|
60
|
-
refresh_token_expired:
|
|
61
|
-
|
|
62
|
-
|
|
74
|
+
refresh_token_expired:
|
|
75
|
+
"Your Codex refresh token has expired. Please disconnect and sign in again.",
|
|
76
|
+
refresh_token_reused:
|
|
77
|
+
"Your Codex refresh token was already used. Please disconnect and sign in again.",
|
|
78
|
+
refresh_token_invalidated:
|
|
79
|
+
"Your Codex refresh token was revoked. Please disconnect and sign in again.",
|
|
63
80
|
invalid_grant: "Your Codex session is no longer valid. Please disconnect and sign in again.",
|
|
64
81
|
};
|
|
65
82
|
|
package/src/request-context.ts
CHANGED
|
@@ -32,6 +32,19 @@ export type CodexUsageHeaderSnapshot = {
|
|
|
32
32
|
|
|
33
33
|
export type CodexRequestContext = {
|
|
34
34
|
clientVersion: string;
|
|
35
|
+
/**
|
|
36
|
+
* Stable per-session affinity id, sent as the `session_id` header on every
|
|
37
|
+
* request. This is the backend's STICKY CACHE-ROUTING key — measured
|
|
38
|
+
* 2026-07-12 with byte-identical ~99k-token gpt-5.6-sol requests on one idle
|
|
39
|
+
* account: without the header, repeat requests hit the prompt cache ~50% of
|
|
40
|
+
* the time (a per-request routing lottery across cache shards; matches the
|
|
41
|
+
* prod fleet's 48.6%); with a stable session_id, 10/10 requests hit at the
|
|
42
|
+
* 99.0% ceiling — Codex CLI parity (the CLI always sends it; its own last-3d
|
|
43
|
+
* token-weighted rate here is 94%). `prompt_cache_key` in the body only
|
|
44
|
+
* influences routing and does NOT pin it. Use the SAME value as
|
|
45
|
+
* prompt_cache_key (the OpenGeni sessionId) so routing and cache key agree.
|
|
46
|
+
*/
|
|
47
|
+
sessionId?: string;
|
|
35
48
|
/** Worker-supplied: proactive refresh + single-flight + db persist. */
|
|
36
49
|
getToken: () => Promise<CodexTokenSnapshot>;
|
|
37
50
|
/** Forced refresh used for the 401 retry. */
|
package/src/usage-normalize.ts
CHANGED
|
@@ -50,7 +50,9 @@ export type CodexUsagePayload = {
|
|
|
50
50
|
reason?: "needs_relogin" | undefined;
|
|
51
51
|
// forward-compat, populated but unused in P2:
|
|
52
52
|
additionalLimits?: CodexAdditionalLimit[] | undefined;
|
|
53
|
-
credits?:
|
|
53
|
+
credits?:
|
|
54
|
+
| { hasCredits: boolean; unlimited: boolean; overageLimitReached: boolean; balance: string }
|
|
55
|
+
| undefined;
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
/**
|
|
@@ -71,9 +73,10 @@ export function buildCodexUsageWindowFromCache(
|
|
|
71
73
|
const percent = clampPercent(usedPercent);
|
|
72
74
|
const resetDate = resetAt ? new Date(resetAt) : null;
|
|
73
75
|
const resetIso = resetDate && !Number.isNaN(resetDate.getTime()) ? resetDate.toISOString() : null;
|
|
74
|
-
const resetAfterSeconds =
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const resetAfterSeconds =
|
|
77
|
+
resetDate && !Number.isNaN(resetDate.getTime())
|
|
78
|
+
? Math.max(0, Math.round((resetDate.getTime() - Date.now()) / 1000))
|
|
79
|
+
: null;
|
|
77
80
|
return {
|
|
78
81
|
used: percent,
|
|
79
82
|
limit: 100,
|
|
@@ -139,7 +142,10 @@ function normalizeWindow(w: RawWindow): CodexUsageWindow | null {
|
|
|
139
142
|
return null;
|
|
140
143
|
}
|
|
141
144
|
const percent = clampPercent(w.used_percent);
|
|
142
|
-
const resetAfterSeconds =
|
|
145
|
+
const resetAfterSeconds =
|
|
146
|
+
typeof w.reset_after_seconds === "number"
|
|
147
|
+
? Math.max(0, Math.round(w.reset_after_seconds))
|
|
148
|
+
: null;
|
|
143
149
|
let resetAt: string | null = null;
|
|
144
150
|
if (typeof w.reset_at === "number") {
|
|
145
151
|
resetAt = new Date(w.reset_at * 1000).toISOString(); // epoch SECONDS → ms
|
|
@@ -162,14 +168,20 @@ function normalizeWindow(w: RawWindow): CodexUsageWindow | null {
|
|
|
162
168
|
* (18000 vs 604800), NEVER by position; fall back to position (primary ⇒ 5h,
|
|
163
169
|
* secondary ⇒ weekly) only for a window whose limit_window_seconds is absent.
|
|
164
170
|
*/
|
|
165
|
-
function pickWindows(
|
|
171
|
+
function pickWindows(
|
|
172
|
+
primary: RawWindow,
|
|
173
|
+
secondary: RawWindow,
|
|
174
|
+
): { fiveHour: CodexUsageWindow | null; weekly: CodexUsageWindow | null } {
|
|
166
175
|
let fiveHour: CodexUsageWindow | null = null;
|
|
167
176
|
let weekly: CodexUsageWindow | null = null;
|
|
168
177
|
// Track each unplaced window with the slot it came from, so the positional
|
|
169
178
|
// fallback can place it (re-normalizing produces a fresh object that would
|
|
170
179
|
// never match by reference — the bug this replaces).
|
|
171
180
|
const unplaced: Array<{ slot: "primary" | "secondary"; window: CodexUsageWindow }> = [];
|
|
172
|
-
for (const [slot, raw] of [
|
|
181
|
+
for (const [slot, raw] of [
|
|
182
|
+
["primary", primary],
|
|
183
|
+
["secondary", secondary],
|
|
184
|
+
] as const) {
|
|
173
185
|
const nw = normalizeWindow(raw);
|
|
174
186
|
if (!nw) continue;
|
|
175
187
|
if (nw.limitWindowSeconds === CODEX_WEEKLY_WINDOW_SECONDS) {
|
|
@@ -215,9 +227,14 @@ export function normalizeCodexUsage(httpStatus: number, rawPayload: unknown): Co
|
|
|
215
227
|
}
|
|
216
228
|
|
|
217
229
|
const rate = body.rate_limit ?? null;
|
|
218
|
-
const { fiveHour, weekly } = pickWindows(
|
|
230
|
+
const { fiveHour, weekly } = pickWindows(
|
|
231
|
+
rate?.primary_window ?? null,
|
|
232
|
+
rate?.secondary_window ?? null,
|
|
233
|
+
);
|
|
219
234
|
const limitReached =
|
|
220
|
-
!!(rate?.limit_reached || rate?.allowed === false) ||
|
|
235
|
+
!!(rate?.limit_reached || rate?.allowed === false) ||
|
|
236
|
+
(fiveHour?.percent ?? 0) >= 100 ||
|
|
237
|
+
(weekly?.percent ?? 0) >= 100;
|
|
221
238
|
|
|
222
239
|
const additionalLimits: CodexAdditionalLimit[] | undefined = body.additional_limits
|
|
223
240
|
? body.additional_limits.map((al) => {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/constants.ts"],"sourcesContent":["// Wire constants for the ChatGPT/Codex subscription backend.\n// Source: CODEX-SUBSCRIPTION-SPEC.md (verified against openai/codex codex-rs).\n\nexport const CODEX_ISSUER = \"https://auth.openai.com\";\nexport const CODEX_CLIENT_ID = \"app_EMoamEEZ73f0CkXaXp7hrann\"; // spec §1.1 (manager.rs:1444)\nexport const CODEX_AUTH_BASE = `${CODEX_ISSUER}/api/accounts`; // device endpoints (device_code_auth.rs:164)\nexport const CODEX_TOKEN_URL = `${CODEX_ISSUER}/oauth/token`; // exchange (form) + refresh (json)\nexport const CODEX_DEVICE_VERIFICATION_URL = `${CODEX_ISSUER}/codex/device`;\nexport const CODEX_DEVICE_REDIRECT_URI = `${CODEX_ISSUER}/deviceauth/callback`;\n\n// Model requests: base already includes /codex; client appends /responses, /models.\nexport const CODEX_RESPONSES_BASE = \"https://chatgpt.com/backend-api/codex\";\n// Usage lives on the WHAM base — NOT under /codex (verified, spec §1.8a).\nexport const CODEX_WHAM_BASE = \"https://chatgpt.com/backend-api\";\n\nexport const CODEX_ORIGINATOR = \"codex_cli_rs\"; // whitelisted originator (spec §1.2)\nexport const CODEX_ID_TOKEN_AUTH_CLAIM = \"https://api.openai.com/auth\";\n\n// Synthetic registry-provider identity. The provider's baseURL is the bare\n// /backend-api (NOT /codex) — codexSubscriptionFetch rewrites /responses ->\n// /codex/responses. Codex model ids are namespaced `codex/<slug>` so they never\n// collide with the built-in OpenAI provider's model ids; the fetch's resolveModel\n// strips the prefix before the slug reaches the backend.\nexport const CODEX_PROVIDER_ID = \"codex-subscription\";\nexport const CODEX_PROVIDER_BASE_URL = \"https://chatgpt.com/backend-api\";\nexport const CODEX_MODEL_ID_PREFIX = \"codex/\";\n\n// Offline fallback catalog. The live catalog from GET /models supersedes this\n// when wired; until then these are the selectable codex models and the\n// longest-prefix resolution targets. Confirmed live (2026-06) against a pro plan:\n// GET /codex/models returned exactly these (plus the internal codex-auto-review).\nexport const CODEX_FALLBACK_MODEL_SLUGS = [\n \"gpt-5.5\",\n \"gpt-5.4\",\n \"gpt-5.4-mini\",\n \"gpt-5.3-codex-spark\",\n] as const;\n\n// Sent as the `version` header and inside the User-Agent. Confirmed live: the\n// backend accepts the current codex CLI version; an older value risks /models\n// min_client_version filtering. Keep in step with the codex CLI releases.\nexport const CODEX_CLIENT_VERSION = \"0.142.4\";\n\nexport const CODEX_REFRESH_WINDOW_MS = 5 * 60 * 1000; // proactive refresh when within 5 min of exp (spec §1.1)\nexport const CODEX_REFRESH_FALLBACK_MS = 8 * 24 * 60 * 60 * 1000; // 8 days when exp is unparseable\n\n// ── Apps / connectors MCP (spec §1.10, §E) ───────────────────────────────────\n// One server-side MCP exposes ALL the user's ChatGPT/Codex connectors\n// (gmail/github/linear/slack/sentry/drive/calendar/…). Streamable HTTP, always.\nexport const CODEX_APPS_MCP_SERVER_ID = \"codex_apps\"; // tools surface as mcp__codex_apps__<tool>\nexport const CODEX_APPS_MCP_SERVER_NAME = \"codex_apps\"; // MCP `name` — MUST equal the id so the SDK namespaces tools as mcp__codex_apps__*\nexport const CODEX_APPS_MCP_URL = \"https://chatgpt.com/backend-api/ps/mcp\"; // live URL (NOT /codex, NOT the legacy /wham/apps)\nexport const CODEX_APPS_STARTUP_TIMEOUT_MS = 30_000; // startup_timeout 30s (spec §1.10) — maps to timeoutMs on this server only\n// Connector scopes that the apps MCP requires. Present ONLY when granted at\n// browser-authorize time; the device-code path CANNOT be confirmed to grant\n// them, so treat connector availability as runtime-discovered (spec §1.10 / §E).\nexport const CODEX_APPS_REQUIRED_SCOPES = [\"api.connectors.read\", \"api.connectors.invoke\"] as const;\n"],"mappings":";AAGO,IAAM,eAAe;AACrB,IAAM,kBAAkB;AACxB,IAAM,kBAAkB,GAAG,YAAY;AACvC,IAAM,kBAAkB,GAAG,YAAY;AACvC,IAAM,gCAAgC,GAAG,YAAY;AACrD,IAAM,4BAA4B,GAAG,YAAY;AAGjD,IAAM,uBAAuB;AAE7B,IAAM,kBAAkB;AAExB,IAAM,mBAAmB;AACzB,IAAM,4BAA4B;AAOlC,IAAM,oBAAoB;AAC1B,IAAM,0BAA0B;AAChC,IAAM,wBAAwB;AAM9B,IAAM,6BAA6B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAKO,IAAM,uBAAuB;AAE7B,IAAM,0BAA0B,IAAI,KAAK;AACzC,IAAM,4BAA4B,IAAI,KAAK,KAAK,KAAK;AAKrD,IAAM,2BAA2B;AACjC,IAAM,6BAA6B;AACnC,IAAM,qBAAqB;AAC3B,IAAM,gCAAgC;AAItC,IAAM,6BAA6B,CAAC,uBAAuB,uBAAuB;","names":[]}
|