@bitkyc08/opencodex 2.1.7 → 2.1.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.
@@ -0,0 +1,446 @@
1
+ import { loadConfig, saveConfig } from "./config";
2
+ import { withCodexAccountLogLabel } from "./codex-account-label";
3
+ import {
4
+ getCodexAccountCredential,
5
+ getValidCodexToken,
6
+ saveCodexAccountCredential,
7
+ CodexCredentialGenerationConflictError,
8
+ CodexCredentialRefreshLockTimeoutError,
9
+ TokenRefreshError,
10
+ } from "./codex-account-store";
11
+ import { deleteCodexAccount } from "./codex-account-lifecycle";
12
+ import { checkAccountIdCollision, readCodexTokens } from "./codex-auth-collision";
13
+ export { checkAccountIdCollision, getMainChatgptAccountId } from "./codex-auth-collision";
14
+ export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./codex-account-runtime-state";
15
+ import { clearAccountNeedsReauth, isAccountNeedsReauth } from "./codex-account-runtime-state";
16
+ import {
17
+ clearAccountQuota,
18
+ getAccountQuota,
19
+ listAccountQuotas,
20
+ parseUsageQuota,
21
+ updateAccountQuota,
22
+ type StoredAccountQuota,
23
+ type WhamUsageResponse,
24
+ } from "./codex-quota";
25
+ export { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./codex-quota";
26
+ import { extractAccountId, decodeJwtPayload } from "./oauth/chatgpt";
27
+ import { maskEmail } from "./privacy";
28
+ export { maskEmail } from "./privacy";
29
+ import type { OcxConfig } from "./types";
30
+
31
+ function jsonResponse(data: unknown, status = 200): Response {
32
+ return new Response(JSON.stringify(data), {
33
+ status,
34
+ headers: { "Content-Type": "application/json" },
35
+ });
36
+ }
37
+
38
+ const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/;
39
+ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT";
40
+
41
+ const codexAuthLoginState = new Map<string, { status: string; accountId?: string; email?: string; error?: string; doneAt?: number }>();
42
+
43
+ export function isUnverifiedCodexImportEnabled(): boolean {
44
+ return process.env[MANUAL_IMPORT_ENV] === "1";
45
+ }
46
+
47
+ function manualImportDisabledResponse(): Response {
48
+ return jsonResponse({
49
+ error: "Manual Codex account import is disabled. Use OAuth login to add a pool account.",
50
+ code: "manual_import_disabled",
51
+ }, 403);
52
+ }
53
+
54
+ function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
55
+ if (!flowId) return;
56
+ codexAuthLoginState.set(flowId, { status: "error", error, doneAt: Date.now() });
57
+ setTimeout(() => codexAuthLoginState.delete(flowId), 30_000);
58
+ }
59
+
60
+ let mainAccountCache: { email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null; ts: number } | null = null;
61
+ const MAIN_CACHE_TTL = 5 * 60_000;
62
+ const POOL_CACHE_TTL = 5 * 60_000;
63
+ const POOL_QUOTA_REFRESH_CONCURRENCY = 4;
64
+
65
+ function isRuntimeConfig(config: OcxConfig): boolean {
66
+ return !!config && typeof config === "object" && !!config.providers;
67
+ }
68
+
69
+ function getRuntimeConfig(config: OcxConfig): OcxConfig {
70
+ return isRuntimeConfig(config) ? config : loadConfig();
71
+ }
72
+
73
+ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void {
74
+ saveConfig(nextConfig);
75
+ if (sourceConfig === nextConfig || !isRuntimeConfig(sourceConfig)) return;
76
+ for (const key of Object.keys(sourceConfig) as Array<keyof OcxConfig>) {
77
+ delete sourceConfig[key];
78
+ }
79
+ Object.assign(sourceConfig, nextConfig);
80
+ }
81
+
82
+ async function mapWithConcurrency<T, R>(
83
+ items: T[],
84
+ concurrency: number,
85
+ mapper: (item: T) => Promise<R>,
86
+ ): Promise<R[]> {
87
+ const results = new Array<R>(items.length);
88
+ let next = 0;
89
+ const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
90
+ while (next < items.length) {
91
+ const index = next++;
92
+ results[index] = await mapper(items[index]!);
93
+ }
94
+ });
95
+ await Promise.all(workers);
96
+ return results;
97
+ }
98
+
99
+ async function fetchMainAccountInfo(forceRefresh = false): Promise<{ email: string | null; plan: string | null; quota: Omit<StoredAccountQuota, "updatedAt"> | null }> {
100
+ if (!forceRefresh && mainAccountCache && Date.now() - mainAccountCache.ts < MAIN_CACHE_TTL) {
101
+ return mainAccountCache;
102
+ }
103
+ const tokens = readCodexTokens();
104
+ if (!tokens) return { email: null, plan: null, quota: null };
105
+ try {
106
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
107
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
108
+ signal: AbortSignal.timeout(8000),
109
+ });
110
+ if (!resp.ok) return { email: null, plan: null, quota: null };
111
+ const data = (await resp.json()) as WhamUsageResponse;
112
+ const result = {
113
+ email: data.email ?? null,
114
+ plan: data.plan_type ?? null,
115
+ quota: parseUsageQuota(data),
116
+ ts: Date.now(),
117
+ };
118
+ mainAccountCache = result;
119
+ return result;
120
+ } catch {
121
+ return { email: null, plan: null, quota: null };
122
+ }
123
+ }
124
+
125
+ interface PoolQuotaResult {
126
+ quota: StoredAccountQuota | null;
127
+ needsReauth: boolean;
128
+ }
129
+
130
+ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false): Promise<PoolQuotaResult> {
131
+ const existing = getAccountQuota(accountId);
132
+ if (!forceRefresh && existing && Date.now() - existing.updatedAt < POOL_CACHE_TTL) {
133
+ return { quota: existing, needsReauth: false };
134
+ }
135
+ try {
136
+ const { accessToken, chatgptAccountId } = await getValidCodexToken(accountId);
137
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
138
+ headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
139
+ signal: AbortSignal.timeout(8000),
140
+ });
141
+ if (!resp.ok) return { quota: existing ?? null, needsReauth: resp.status === 401 };
142
+ const data = (await resp.json()) as WhamUsageResponse;
143
+ const quota = parseUsageQuota(data);
144
+ if (!quota) return { quota: existing ?? null, needsReauth: false };
145
+ updateAccountQuota(
146
+ accountId,
147
+ quota.weeklyPercent,
148
+ quota.fiveHourPercent,
149
+ quota.weeklyResetAt,
150
+ quota.fiveHourResetAt,
151
+ quota.monthlyPercent,
152
+ quota.monthlyResetAt,
153
+ );
154
+ return { quota: getAccountQuota(accountId), needsReauth: false };
155
+ } catch (e) {
156
+ if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
157
+ if (e instanceof TokenRefreshError) return { quota: existing ?? null, needsReauth: true };
158
+ return { quota: existing ?? null, needsReauth: false };
159
+ }
160
+ }
161
+
162
+ export async function handleCodexAuthAPI(
163
+ req: Request,
164
+ url: URL,
165
+ config: OcxConfig,
166
+ ): Promise<Response | null> {
167
+
168
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "GET") {
169
+ const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
170
+ const runtimeConfig = getRuntimeConfig(config);
171
+ const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(a => !a.isMain);
172
+ const mainInfo = await fetchMainAccountInfo(forceRefresh);
173
+ const withQuota = await mapWithConcurrency(poolAccounts, POOL_QUOTA_REFRESH_CONCURRENCY, async a => {
174
+ const cred = getCodexAccountCredential(a.id);
175
+ const quotaResult = cred
176
+ ? await fetchPoolAccountQuota(a.id, forceRefresh)
177
+ : { quota: null, needsReauth: true };
178
+ return {
179
+ ...a,
180
+ email: maskEmail(a.email) ?? a.email,
181
+ quota: quotaResult.quota ? { ...quotaResult.quota } : null,
182
+ needsReauth: !cred || quotaResult.needsReauth || isAccountNeedsReauth(a.id),
183
+ hasCredential: !!cred,
184
+ };
185
+ });
186
+ const main = {
187
+ id: "__main__",
188
+ email: maskEmail(mainInfo.email) ?? "Codex App login",
189
+ plan: mainInfo.plan,
190
+ isMain: true,
191
+ hasCredential: true,
192
+ quota: mainInfo.quota ? { ...mainInfo.quota, updatedAt: Date.now() } : null,
193
+ };
194
+ return jsonResponse({ accounts: [main, ...withQuota] });
195
+ }
196
+
197
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") {
198
+ if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse();
199
+
200
+ let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string };
201
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
202
+ if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) {
203
+ return jsonResponse({ error: "Missing required fields" }, 400);
204
+ }
205
+ if (!ACCOUNT_ID_RE.test(body.id)) {
206
+ return jsonResponse({ error: "Invalid account id format" }, 400);
207
+ }
208
+ if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) {
209
+ return jsonResponse({ error: "Input too large" }, 400);
210
+ }
211
+ const runtimeConfig = getRuntimeConfig(config);
212
+ const accounts = runtimeConfig.codexAccounts ?? [];
213
+ if (accounts.some(a => a.id === body.id) || getCodexAccountCredential(body.id)) {
214
+ return jsonResponse({ error: `Account id already exists: ${body.id}` }, 400);
215
+ }
216
+ // 1.1: JWT-derived account ID is authoritative; collision check
217
+ const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId;
218
+ const collision = checkAccountIdCollision(derivedAccountId, body.email);
219
+ if (collision.collision) {
220
+ return jsonResponse({ error: collision.reason }, 400);
221
+ }
222
+ // 4.2: use JWT exp for expiresAt instead of hardcoded 1 hour
223
+ const payload = decodeJwtPayload(body.accessToken);
224
+ const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
225
+ saveCodexAccountCredential(body.id, {
226
+ accessToken: body.accessToken,
227
+ refreshToken: body.refreshToken,
228
+ expiresAt: exp,
229
+ chatgptAccountId: derivedAccountId,
230
+ });
231
+ clearAccountNeedsReauth(body.id);
232
+ accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
233
+ runtimeConfig.codexAccounts = accounts;
234
+ saveRuntimeConfig(config, runtimeConfig);
235
+ return jsonResponse({ ok: true });
236
+ }
237
+
238
+ if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
239
+ const id = url.searchParams.get("id");
240
+ if (!id) return jsonResponse({ error: "Missing id" }, 400);
241
+ const runtimeConfig = getRuntimeConfig(config);
242
+ deleteCodexAccount(runtimeConfig, id);
243
+ saveRuntimeConfig(config, runtimeConfig);
244
+ return jsonResponse({ ok: true });
245
+ }
246
+
247
+ if (url.pathname === "/api/codex-auth/active" && req.method === "PUT") {
248
+ let body: { accountId: string | null };
249
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
250
+ const runtimeConfig = getRuntimeConfig(config);
251
+ if (body.accountId != null) {
252
+ const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === body.accountId);
253
+ if (!exists) return jsonResponse({ error: "Account not found" }, 400);
254
+ }
255
+ runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
256
+ saveRuntimeConfig(config, runtimeConfig);
257
+ return jsonResponse({ ok: true, activeCodexAccountId: body.accountId });
258
+ }
259
+
260
+ if (url.pathname === "/api/codex-auth/active" && req.method === "GET") {
261
+ const runtimeConfig = getRuntimeConfig(config);
262
+ return jsonResponse({
263
+ activeCodexAccountId: runtimeConfig.activeCodexAccountId ?? null,
264
+ autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
265
+ upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
266
+ });
267
+ }
268
+
269
+ if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
270
+ let body: { threshold: number };
271
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
272
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
273
+ return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
274
+ }
275
+ const runtimeConfig = getRuntimeConfig(config);
276
+ runtimeConfig.autoSwitchThreshold = body.threshold;
277
+ saveRuntimeConfig(config, runtimeConfig);
278
+ return jsonResponse({ ok: true });
279
+ }
280
+
281
+ if (url.pathname === "/api/codex-auth/failover" && req.method === "PUT") {
282
+ let body: { threshold: number };
283
+ try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
284
+ if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 20) {
285
+ return jsonResponse({ error: "Threshold must be an integer 0-20" }, 400);
286
+ }
287
+ const runtimeConfig = getRuntimeConfig(config);
288
+ runtimeConfig.upstreamFailoverThreshold = body.threshold;
289
+ saveRuntimeConfig(config, runtimeConfig);
290
+ return jsonResponse({ ok: true });
291
+ }
292
+
293
+ if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
294
+ const quotas: Record<string, unknown> = {};
295
+ for (const [id, q] of listAccountQuotas()) quotas[id] = q;
296
+ return jsonResponse({ quotas });
297
+ }
298
+
299
+ if (url.pathname === "/api/codex-auth/login" && req.method === "POST") {
300
+ const body = (await req.json().catch(() => ({}))) as { id?: string };
301
+ const requestedAccountId = body.id?.trim();
302
+ if (requestedAccountId && !ACCOUNT_ID_RE.test(requestedAccountId)) {
303
+ return jsonResponse({ error: "Invalid account id format" }, 400);
304
+ }
305
+ const accountId = requestedAccountId || `chatgpt-${Date.now()}`;
306
+ const runtimeConfig = getRuntimeConfig(config);
307
+ if ((runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || getCodexAccountCredential(accountId)) {
308
+ return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400);
309
+ }
310
+ const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
311
+ try {
312
+ const { startLoginFlow, getLoginStatus } = await import("./oauth/index");
313
+ const result = await startLoginFlow("chatgpt", { forceLogin: true });
314
+
315
+ (async () => {
316
+ let completed = false;
317
+ for (let i = 0; i < 150; i++) {
318
+ await new Promise(r => setTimeout(r, 2000));
319
+ const st = getLoginStatus("chatgpt");
320
+ if (st.done && st.loggedIn) {
321
+ const { getCredential } = await import("./oauth/store");
322
+ const cred = getCredential("chatgpt");
323
+ if (cred) {
324
+ // 1.2: account-ID-based collision check (JWT-derived, not email)
325
+ const oauthAccountId = cred.accountId;
326
+ if (!oauthAccountId) {
327
+ codexAuthLoginState.set(flowId, {
328
+ status: "error",
329
+ error: "Could not determine account identity from OAuth tokens. Please retry OAuth login.",
330
+ doneAt: Date.now(),
331
+ });
332
+ completed = true;
333
+ break;
334
+ }
335
+ const collision = checkAccountIdCollision(oauthAccountId, cred.email);
336
+ if (collision.collision) {
337
+ codexAuthLoginState.set(flowId, {
338
+ status: "error", error: collision.reason, doneAt: Date.now(),
339
+ });
340
+ completed = true;
341
+ break;
342
+ }
343
+
344
+ let email = cred.email || accountId;
345
+ let plan: string | undefined;
346
+ let quota: Omit<StoredAccountQuota, "updatedAt"> | null = null;
347
+ try {
348
+ const tokens = { access_token: cred.access, account_id: oauthAccountId };
349
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
350
+ headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
351
+ signal: AbortSignal.timeout(8000),
352
+ });
353
+ if (resp.ok) {
354
+ const data = (await resp.json()) as WhamUsageResponse;
355
+ email = data.email ?? email;
356
+ plan = data.plan_type ?? undefined;
357
+ quota = parseUsageQuota(data);
358
+ }
359
+ } catch { /* wham fetch is non-blocking */ }
360
+
361
+ saveCodexAccountCredential(accountId, {
362
+ accessToken: cred.access,
363
+ refreshToken: cred.refresh,
364
+ expiresAt: cred.expires,
365
+ chatgptAccountId: oauthAccountId,
366
+ });
367
+ clearAccountNeedsReauth(accountId);
368
+ if (quota) {
369
+ updateAccountQuota(
370
+ accountId,
371
+ quota.weeklyPercent,
372
+ quota.fiveHourPercent,
373
+ quota.weeklyResetAt,
374
+ quota.fiveHourResetAt,
375
+ quota.monthlyPercent,
376
+ quota.monthlyResetAt,
377
+ );
378
+ }
379
+
380
+ const latestConfig = getRuntimeConfig(config);
381
+ const accounts = latestConfig.codexAccounts ?? [];
382
+ if (!accounts.find(a => a.id === accountId)) {
383
+ accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
384
+ latestConfig.codexAccounts = accounts;
385
+ saveRuntimeConfig(config, latestConfig);
386
+ }
387
+ codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() });
388
+ completed = true;
389
+ }
390
+ break;
391
+ }
392
+ if (st.done && st.error) {
393
+ codexAuthLoginState.set(flowId, { status: "error", error: st.error, doneAt: Date.now() });
394
+ completed = true;
395
+ break;
396
+ }
397
+ }
398
+ if (!completed) {
399
+ codexAuthLoginState.set(flowId, {
400
+ status: "error",
401
+ error: "Login timed out before OAuth completed.",
402
+ doneAt: Date.now(),
403
+ });
404
+ }
405
+ // TTL: keep completed flow state available for clients that miss a short polling window.
406
+ setTimeout(() => codexAuthLoginState.delete(flowId), 300_000);
407
+ })();
408
+
409
+ codexAuthLoginState.set(flowId, { status: "pending" });
410
+ return jsonResponse({ ok: true, flowId, url: result.url, instructions: result.instructions });
411
+ } catch (e) {
412
+ const msg = e instanceof Error ? e.message : String(e);
413
+ if (msg.includes("already in progress")) {
414
+ return jsonResponse({ error: msg, status: "pending" }, 409);
415
+ }
416
+ return jsonResponse({ error: msg }, 500);
417
+ }
418
+ }
419
+
420
+ if (url.pathname === "/api/codex-auth/login/cancel" && req.method === "POST") {
421
+ const body = (await req.json().catch(() => ({}))) as { flowId?: string };
422
+ const { cancelLoginFlow } = await import("./oauth/index");
423
+ const cancelled = cancelLoginFlow("chatgpt");
424
+ expireCodexAuthFlow(body.flowId ?? null);
425
+ return jsonResponse({ ok: true, cancelled });
426
+ }
427
+
428
+ if (url.pathname === "/api/codex-auth/login-status" && req.method === "GET") {
429
+ const flowId = url.searchParams.get("flowId");
430
+ const accountId = url.searchParams.get("accountId")?.trim();
431
+ if (flowId) {
432
+ const st = codexAuthLoginState.get(flowId);
433
+ if (!st && accountId && getCodexAccountCredential(accountId)) {
434
+ return jsonResponse({ status: "done", accountId });
435
+ }
436
+ return jsonResponse(st ? { ...st, email: maskEmail(st.email) ?? undefined } : { status: "expired" });
437
+ }
438
+ // Legacy fallback: return latest pending flow
439
+ for (const [, st] of codexAuthLoginState) {
440
+ if (st.status === "pending") return jsonResponse({ ...st, email: maskEmail(st.email) ?? undefined });
441
+ }
442
+ return jsonResponse({ status: "idle" });
443
+ }
444
+
445
+ return null;
446
+ }
@@ -0,0 +1,66 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import os from "node:os";
4
+ import { getCodexAccountCredential, listCodexAccountIds } from "./codex-account-store";
5
+ import { loadConfig } from "./config";
6
+ import { extractAccountId, extractEmail } from "./oauth/chatgpt";
7
+
8
+ export function readCodexTokens(): { access_token: string; account_id: string; id_token?: string } | null {
9
+ try {
10
+ const codexHome = process.env["CODEX_HOME"] || join(os.homedir(), ".codex");
11
+ const authPath = join(codexHome, "auth.json");
12
+ if (!existsSync(authPath)) return null;
13
+ const j = JSON.parse(readFileSync(authPath, "utf-8")) as {
14
+ tokens?: { access_token?: string; account_id?: string; id_token?: string };
15
+ };
16
+ if (!j?.tokens?.access_token) return null;
17
+ return {
18
+ access_token: j.tokens.access_token,
19
+ account_id: j.tokens.account_id ?? "",
20
+ id_token: j.tokens.id_token,
21
+ };
22
+ } catch { return null; }
23
+ }
24
+
25
+ export function getMainChatgptAccountId(): string | null {
26
+ const tokens = readCodexTokens();
27
+ if (!tokens) return null;
28
+ return extractAccountId(tokens.id_token, tokens.access_token) ?? (tokens.account_id || null);
29
+ }
30
+
31
+ function getMainChatgptEmail(): string | null {
32
+ const tokens = readCodexTokens();
33
+ if (!tokens) return null;
34
+ return extractEmail(tokens.id_token, tokens.access_token) ?? null;
35
+ }
36
+
37
+ function normalizedEmail(email: string | undefined | null): string | null {
38
+ const trimmed = email?.trim().toLowerCase();
39
+ return trimmed || null;
40
+ }
41
+
42
+ function poolEmailForId(id: string): string | null {
43
+ const account = (loadConfig().codexAccounts ?? []).find(a => a.id === id);
44
+ return normalizedEmail(account?.email);
45
+ }
46
+
47
+ // Business/Team members can share chatgpt_account_id, so require email match too.
48
+ export function checkAccountIdCollision(
49
+ chatgptAccountId: string,
50
+ email?: string | null,
51
+ ): { collision: true; reason: string } | { collision: false } {
52
+ const candidateEmail = normalizedEmail(email);
53
+ const mainId = getMainChatgptAccountId();
54
+ const mainEmail = getMainChatgptEmail();
55
+ if (mainId && mainId === chatgptAccountId && (!candidateEmail || !mainEmail || mainEmail === candidateEmail)) {
56
+ return { collision: true, reason: "This account is your main Codex login. Use a different account for the pool." };
57
+ }
58
+ for (const poolId of listCodexAccountIds()) {
59
+ const cred = getCodexAccountCredential(poolId);
60
+ const poolEmail = poolEmailForId(poolId);
61
+ if (cred && cred.chatgptAccountId === chatgptAccountId && (!candidateEmail || !poolEmail || poolEmail === candidateEmail)) {
62
+ return { collision: true, reason: `Account is already in the pool (${poolId}).` };
63
+ }
64
+ }
65
+ return { collision: false };
66
+ }
@@ -0,0 +1,136 @@
1
+ import {
2
+ CodexCredentialGenerationConflictError,
3
+ CodexCredentialRefreshLockTimeoutError,
4
+ getValidCodexToken,
5
+ isCodexAccountGenerationLive,
6
+ } from "./codex-account-store";
7
+ import { markAccountNeedsReauth } from "./codex-account-runtime-state";
8
+ import { isCodexAccountUsable } from "./codex-account-usability";
9
+ import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./codex-routing";
10
+ import type { OcxConfig, OcxProviderConfig } from "./types";
11
+ import { FORWARD_HEADERS } from "./adapters/openai-responses";
12
+
13
+ export type CodexAuthContext =
14
+ | { kind: "main"; accountId: null }
15
+ | {
16
+ kind: "pool";
17
+ accountId: string;
18
+ generation: number;
19
+ accessToken: string;
20
+ chatgptAccountId: string;
21
+ };
22
+
23
+ export type OcxRuntimeProviderConfig = OcxProviderConfig & {
24
+ _codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
25
+ _codexAccountRequired?: boolean;
26
+ };
27
+
28
+ export class CodexAuthContextError extends Error {
29
+ accountId: string;
30
+
31
+ constructor(accountId: string, cause: unknown) {
32
+ super("Codex pool account auth failed", { cause });
33
+ this.name = "CodexAuthContextError";
34
+ this.accountId = accountId;
35
+ }
36
+ }
37
+
38
+ export class CodexAccountCooldownError extends Error {
39
+ accountId: string;
40
+ cooldownUntil: number;
41
+
42
+ constructor(accountId: string, cooldownUntil: number) {
43
+ super("Selected Codex account is cooling down");
44
+ this.name = "CodexAccountCooldownError";
45
+ this.accountId = accountId;
46
+ this.cooldownUntil = cooldownUntil;
47
+ }
48
+ }
49
+
50
+ export class CodexThreadAffinityExpiredError extends Error {
51
+ accountId: string;
52
+
53
+ constructor(accountId: string) {
54
+ super("Codex thread account affinity expired");
55
+ this.name = "CodexThreadAffinityExpiredError";
56
+ this.accountId = accountId;
57
+ }
58
+ }
59
+
60
+ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
61
+ return !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError);
62
+ }
63
+
64
+ export async function resolveCodexAuthContext(headers: Headers, config: OcxConfig): Promise<CodexAuthContext> {
65
+ const threadId = headers.get("x-codex-parent-thread-id");
66
+ const resolution = resolveCodexAccountForThreadDetailed(threadId, config);
67
+ if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
68
+ const accountId = resolution.status === "selected" ? resolution.accountId : null;
69
+ if (!accountId) return { kind: "main", accountId: null };
70
+ const cooldownUntil = getCodexAccountCooldownUntil(accountId);
71
+ if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
72
+
73
+ try {
74
+ const token = await getValidCodexToken(accountId);
75
+ return {
76
+ kind: "pool",
77
+ accountId,
78
+ generation: token.generation,
79
+ accessToken: token.accessToken,
80
+ chatgptAccountId: token.chatgptAccountId,
81
+ };
82
+ } catch (cause) {
83
+ if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
84
+ markAccountNeedsReauth(accountId);
85
+ }
86
+ throw new CodexAuthContextError(accountId, cause);
87
+ }
88
+ }
89
+
90
+ export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
91
+ if (ctx?.kind !== "pool") return;
92
+ const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
93
+ if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
94
+ }
95
+
96
+ export function applyCodexAuthContextToProvider(
97
+ provider: OcxProviderConfig,
98
+ ctx: CodexAuthContext,
99
+ ): OcxRuntimeProviderConfig {
100
+ if (ctx.kind !== "pool" || provider.authMode !== "forward") return provider;
101
+ return {
102
+ ...provider,
103
+ _codexAccountOverride: {
104
+ accessToken: ctx.accessToken,
105
+ chatgptAccountId: ctx.chatgptAccountId,
106
+ },
107
+ _codexAccountRequired: true,
108
+ };
109
+ }
110
+
111
+ export function headersForCodexAuthContext(headers: Headers, ctx: CodexAuthContext): Headers {
112
+ const selected = new Headers();
113
+ for (const name of FORWARD_HEADERS) {
114
+ const value = headers.get(name);
115
+ if (value) selected.set(name, value);
116
+ }
117
+ if (ctx.kind === "pool") {
118
+ selected.set("authorization", `Bearer ${ctx.accessToken}`);
119
+ selected.set("chatgpt-account-id", ctx.chatgptAccountId);
120
+ }
121
+ return selected;
122
+ }
123
+
124
+ export function isCodexAuthContextUsable(ctx: CodexAuthContext, config: OcxConfig): boolean {
125
+ if (ctx.kind === "main") return true;
126
+ return isCodexAccountUsable(config, ctx.accountId) && isCodexAccountGenerationLive(ctx.accountId, ctx.generation);
127
+ }
128
+
129
+ export function stripCodexRuntimeProviderFields(provider: OcxProviderConfig): OcxProviderConfig {
130
+ const {
131
+ _codexAccountOverride: _override,
132
+ _codexAccountRequired: _required,
133
+ ...safeProvider
134
+ } = provider as OcxRuntimeProviderConfig;
135
+ return safeProvider;
136
+ }
@@ -419,13 +419,16 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
419
419
  if (prov.authMode === "forward") return []; // ChatGPT backend has no /models
420
420
  const apiKey = await resolveModelsAuthToken(name, prov);
421
421
  if (prov.authMode === "oauth" && !apiKey) return []; // not logged in → skip
422
- const fresh = getFreshCached(name, ttlMs);
423
- if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh); // dedups Codex's frequent /v1/models polling within the TTL
424
422
  const configured: CatalogModel[] = (prov.models ?? []).map(id => ({
425
423
  id,
426
424
  provider: name,
427
425
  ...catalogHintsFromProviderConfig(name, prov, id),
428
426
  }));
427
+ if (prov.liveModels === false) {
428
+ return configured;
429
+ }
430
+ const fresh = getFreshCached(name, ttlMs);
431
+ if (fresh) return applyConfigHintsToCachedModels(name, prov, fresh); // dedups Codex's frequent /v1/models polling within the TTL
429
432
  const { url, headers } = buildModelsRequest(prov, apiKey);
430
433
  try {
431
434
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
@@ -475,6 +478,7 @@ export function augmentRoutedModelsWithJawcodeMetadata(models: CatalogModel[], p
475
478
  const seen = new Set(out.map(m => `${m.provider}/${m.id}`));
476
479
  for (const provider of providerNames) {
477
480
  if (!JAWCODE_CATALOG_AUGMENT_PROVIDERS.has(provider)) continue;
481
+ if (providers?.[provider]?.liveModels === false) continue;
478
482
  const jawcodeProvider = resolveJawcodeProvider(provider);
479
483
  if (!jawcodeProvider) continue;
480
484
  for (const meta of listJawcodeModelMetadata(jawcodeProvider)) {
@@ -592,7 +596,13 @@ export function invalidateCodexModelsCache(): void {
592
596
  try {
593
597
  const catalogPath = readCodexCatalogPath();
594
598
  if (!existsSync(catalogPath)) return;
595
- const catalog = readFileSync(catalogPath, "utf8");
596
- atomicWriteFile(CODEX_MODELS_CACHE_PATH, catalog.endsWith("\n") ? catalog : `${catalog}\n`);
599
+ const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
600
+ const models = catalog.models ?? catalog;
601
+ const wrapper = {
602
+ fetched_at: "2000-01-01T00:00:00Z",
603
+ client_version: "0.0.0",
604
+ models,
605
+ };
606
+ atomicWriteFile(CODEX_MODELS_CACHE_PATH, JSON.stringify(wrapper, null, 2) + "\n");
597
607
  } catch { /* best-effort */ }
598
608
  }