@coseung2/opencodex 2.8.0-cs.12 → 2.8.0-cs.14

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.
Files changed (62) hide show
  1. package/gui/dist/assets/index-MUpaVatk.js +67 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -3
  4. package/packages/ocx-notch/README.md +3 -2
  5. package/src/adapters/cursor/discovery.ts +6 -2
  6. package/src/adapters/cursor/effort-map.ts +3 -0
  7. package/src/adapters/google-antigravity-replay.ts +24 -0
  8. package/src/adapters/google.ts +16 -11
  9. package/src/chat/inbound.ts +5 -11
  10. package/src/cli/account-api.ts +9 -1
  11. package/src/cli/account-extended.ts +4 -1
  12. package/src/codex/account-label.ts +14 -1
  13. package/src/codex/account-lifecycle.ts +12 -1
  14. package/src/codex/account-namespaces.ts +21 -0
  15. package/src/codex/account-priority.ts +49 -0
  16. package/src/codex/account-store.ts +73 -10
  17. package/src/codex/auth-api.ts +312 -45
  18. package/src/codex/auth-context.ts +61 -16
  19. package/src/codex/catalog/metadata.ts +34 -12
  20. package/src/codex/catalog/parsing.ts +8 -1
  21. package/src/codex/catalog/provider-fetch.ts +24 -6
  22. package/src/codex/catalog.ts +1 -1
  23. package/src/codex/pool-rotation.ts +51 -4
  24. package/src/codex/quota.ts +154 -35
  25. package/src/codex/routing.ts +133 -33
  26. package/src/codex/warmup.ts +204 -82
  27. package/src/config.ts +84 -1
  28. package/src/lib/bounded-body.ts +13 -6
  29. package/src/lib/bun-stream-caps.ts +5 -6
  30. package/src/lib/redact.ts +13 -0
  31. package/src/oauth/index.ts +79 -12
  32. package/src/oauth/log.ts +3 -1
  33. package/src/oauth/store.ts +31 -8
  34. package/src/oauth/token-guardian.ts +130 -9
  35. package/src/providers/antigravity-models.ts +53 -24
  36. package/src/providers/codex-capacity.ts +303 -0
  37. package/src/providers/model-rename-migration.ts +147 -0
  38. package/src/providers/model-rename-startup.ts +29 -0
  39. package/src/providers/quota.ts +126 -16
  40. package/src/providers/registry.ts +262 -38
  41. package/src/responses/parser.ts +19 -12
  42. package/src/responses/spill-store.ts +14 -1
  43. package/src/responses/state.ts +108 -14
  44. package/src/router.ts +6 -0
  45. package/src/server/index.ts +9 -1
  46. package/src/server/management/logs-usage-routes.ts +1 -0
  47. package/src/server/management/oauth-account-routes.ts +8 -1
  48. package/src/server/relay.ts +10 -42
  49. package/src/server/request-log.ts +42 -1
  50. package/src/server/responses/compact.ts +16 -4
  51. package/src/server/responses/core.ts +217 -59
  52. package/src/server/responses/empty-completion-guard.ts +275 -0
  53. package/src/server/responses/encrypted-payload.ts +54 -39
  54. package/src/server/responses/fetch-helpers.ts +24 -3
  55. package/src/server/responses/ws-upstream.ts +318 -0
  56. package/src/server/sse-frame-buffer.ts +292 -0
  57. package/src/server/ws-bridge.ts +17 -11
  58. package/src/types.ts +13 -1
  59. package/src/usage/log.ts +24 -0
  60. package/src/usage/summary.ts +152 -2
  61. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
  62. package/gui/dist/assets/index-GgF1IpQa.js +0 -67
@@ -1,12 +1,15 @@
1
- import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
1
+ import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, reconcileLiveConfigFromDisk, saveConfigPreservingClaudeCode } from "../config";
2
2
  import { withCodexAccountLogLabel } from "./account-label";
3
3
  import {
4
4
  getCodexAccountCredential,
5
5
  getValidCodexToken,
6
6
  isCodexAccountGenerationLive,
7
+ clearCodexAccountQuotaPauseOwnership,
8
+ markCodexAccountQuotaValidationPending,
7
9
  markCodexAccountValidated,
8
10
  readCodexAccountRecord,
9
11
  saveCodexAccountCredential,
12
+ tombstoneCodexAccountIfGeneration,
10
13
  CodexCredentialGenerationConflictError,
11
14
  CodexCredentialRefreshLockTimeoutError,
12
15
  CodexCredentialRefreshBusyError,
@@ -14,19 +17,36 @@ import {
14
17
  TokenRefreshError,
15
18
  } from "./account-store";
16
19
  import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle";
20
+ import {
21
+ appendDefaultCodexAccountNamespace,
22
+ initializeDefaultCodexAccountNamespaces,
23
+ } from "./account-namespaces";
17
24
  import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause";
25
+ import {
26
+ clearCodexAccountPin,
27
+ getCodexAccountPriority,
28
+ isCodexAccountPriorityKey,
29
+ pinnedCodexAccountId,
30
+ setCodexAccountPin,
31
+ setCodexAccountPriority,
32
+ } from "./account-priority";
18
33
  import {
19
34
  clearCodexAccountCooldown,
20
35
  clearThreadAccountMapForAccount,
21
36
  getEffectiveActiveCodexAccountId,
37
+ isEffectiveCodexAccountPinned,
22
38
  reconcileCodexActiveAfterExclusion,
23
39
  resetCodexRoutingForManualSelection,
24
40
  } from "./routing";
25
41
  import {
42
+ DEFAULT_ACCOUNT_PRIORITY,
43
+ MAX_ACCOUNT_PRIORITY,
44
+ MIN_ACCOUNT_PRIORITY,
26
45
  normalizeAccountPoolStickyLimit,
27
46
  normalizeAccountPoolStrategy,
28
47
  parseAccountPoolStickyLimit,
29
48
  parseAccountPoolStrategy,
49
+ parseAccountPriority,
30
50
  } from "./pool-rotation";
31
51
  import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
32
52
  export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision";
@@ -35,6 +55,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
35
55
  import {
36
56
  clearAccountQuota,
37
57
  getAccountQuota,
58
+ isCodexFiveHourQuotaPlan,
38
59
  isCodexQuotaExhausted,
39
60
  listAccountQuotas,
40
61
  parseUsageQuota,
@@ -63,7 +84,7 @@ import {
63
84
  } from "./main-account-cache";
64
85
  export { clearMainAccountInfoCache } from "./main-account-cache";
65
86
  import { maskEmail } from "../lib/privacy";
66
- import { CodexWarmupError, codexWarmupFailureReason, warmCodexAccount } from "./warmup";
87
+ import { codexWarmupFailureReason, isCodexWarmupQuotaFailure, warmCodexAccount } from "./warmup";
67
88
  export { maskEmail } from "../lib/privacy";
68
89
  import type { CodexAccount, OcxConfig } from "../types";
69
90
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
@@ -83,6 +104,7 @@ import {
83
104
  } from "./account-id";
84
105
  import { codexAccountIdNamespaceCollisionError } from "./account-namespace-match";
85
106
  import { ResourceAdmissionError } from "../lib/admission";
107
+ import { requestCodexQuotaWarmupRetry } from "../oauth/token-guardian";
86
108
 
87
109
  function jsonResponse(data: unknown, status = 200): Response {
88
110
  return new Response(JSON.stringify(data), {
@@ -165,6 +187,7 @@ function poolAccountDto(
165
187
  quotaResult: PoolQuotaResult,
166
188
  hasCredential: boolean,
167
189
  paused: boolean,
190
+ priority: number,
168
191
  ): CodexAuthAccountDto {
169
192
  const quota = quotaForPlan(quotaResult.quota, account.plan);
170
193
  const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id);
@@ -177,6 +200,7 @@ function poolAccountDto(
177
200
  ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}),
178
201
  isMain: false,
179
202
  paused,
203
+ priority,
180
204
  quota: quota ? { ...quota } : null,
181
205
  needsReauth,
182
206
  hasCredential,
@@ -248,19 +272,17 @@ async function verifyCodexAccountWarmup(
248
272
  accountId: string,
249
273
  accessToken: string,
250
274
  chatgptAccountId: string,
251
- ): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response }> {
275
+ ): Promise<{ ok: true; validatedAt: number } | { ok: false; response: Response; error: unknown }> {
252
276
  try {
253
277
  await warmCodexAccount({ accessToken, chatgptAccountId });
254
278
  return { ok: true, validatedAt: Date.now() };
255
279
  } catch (err) {
256
280
  const reason = codexWarmupFailureReason(err);
257
- const upstream = err instanceof CodexWarmupError ? err.upstreamDetail : undefined;
258
281
  return {
259
282
  ok: false,
283
+ error: err,
260
284
  response: jsonResponse({
261
- error: upstream
262
- ? `Codex account warmup failed: ${upstream}`
263
- : "Codex account warmup failed. Reauthenticate the account and try again.",
285
+ error: "Codex account warmup failed. Reauthenticate the account and try again.",
264
286
  code: "codex_warmup_failed",
265
287
  reason,
266
288
  accountId,
@@ -269,6 +291,67 @@ async function verifyCodexAccountWarmup(
269
291
  }
270
292
  }
271
293
 
294
+ interface FreshRegistrationQuota {
295
+ quota: Omit<StoredAccountQuota, "updatedAt">;
296
+ plan?: string;
297
+ email?: string;
298
+ }
299
+
300
+ async function fetchFreshRegistrationQuota(
301
+ accessToken: string,
302
+ chatgptAccountId: string,
303
+ configuredPlan?: string,
304
+ ): Promise<FreshRegistrationQuota | null> {
305
+ try {
306
+ const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
307
+ headers: { Authorization: `Bearer ${accessToken}`, "ChatGPT-Account-Id": chatgptAccountId },
308
+ signal: AbortSignal.timeout(8000),
309
+ });
310
+ if (!resp.ok) return null;
311
+ const data = (await resp.json()) as WhamUsageResponse;
312
+ const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(configuredPlan) ?? undefined;
313
+ const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
314
+ if (!quota) return null;
315
+ return {
316
+ quota,
317
+ ...(plan ? { plan } : {}),
318
+ ...(typeof data.email === "string" && data.email.trim() ? { email: data.email } : {}),
319
+ };
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+
325
+ function exhaustedQuotaRetryAt(
326
+ quota: Omit<StoredAccountQuota, "updatedAt">,
327
+ plan?: string | null,
328
+ nowMs: number = Date.now(),
329
+ ): number | undefined {
330
+ const monthlyOnly = isThirtyDayOnlyPlan(plan);
331
+ const resetSeconds: number[] = [];
332
+ if (!monthlyOnly && isCodexFiveHourQuotaPlan(plan)
333
+ && quota.fiveHourPercent !== undefined && quota.fiveHourPercent >= 100
334
+ && quota.fiveHourResetAt !== undefined) resetSeconds.push(quota.fiveHourResetAt);
335
+ if (!monthlyOnly && quota.weeklyPercent !== undefined && quota.weeklyPercent >= 100
336
+ && quota.weeklyResetAt !== undefined) resetSeconds.push(quota.weeklyResetAt);
337
+ if (quota.monthlyPercent !== undefined && quota.monthlyPercent >= 100
338
+ && quota.monthlyResetAt !== undefined) resetSeconds.push(quota.monthlyResetAt);
339
+ const retryAtMs = resetSeconds.length > 0 ? Math.max(...resetSeconds) * 1000 : undefined;
340
+ return retryAtMs !== undefined && Number.isFinite(retryAtMs) && retryAtMs > nowMs
341
+ ? retryAtMs
342
+ : undefined;
343
+ }
344
+
345
+ function mayDeferQuotaWarmup(
346
+ warmup: { ok: false; error: unknown },
347
+ fresh: FreshRegistrationQuota | null,
348
+ ): fresh is FreshRegistrationQuota {
349
+ return isCodexWarmupQuotaFailure(warmup.error)
350
+ && fresh !== null
351
+ && isCodexQuotaExhausted(fresh.quota, fresh.plan)
352
+ && exhaustedQuotaRetryAt(fresh.quota, fresh.plan) !== undefined;
353
+ }
354
+
272
355
  function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
273
356
  const ids = flowId
274
357
  ? [flowId]
@@ -311,6 +394,71 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void
311
394
  Object.assign(sourceConfig, nextConfig);
312
395
  }
313
396
 
397
+ type CodexAccountConfigCommit = { ok: true } | { ok: false; error: string };
398
+
399
+ function commitCodexAccountConfig(
400
+ sourceConfig: OcxConfig,
401
+ account: CodexAccount,
402
+ mode: "create" | "reauth",
403
+ credentialGeneration: number,
404
+ quotaPause: "add" | "release" | "preserve",
405
+ ): CodexAccountConfigCommit {
406
+ const liveBaseline = structuredClone(sourceConfig);
407
+ const applyMutation = (persisted: OcxConfig) => {
408
+ if (!isCodexAccountGenerationLive(account.id, credentialGeneration)) {
409
+ return { changed: false, value: { ok: false, error: "Codex account changed while login was being saved" } as CodexAccountConfigCommit };
410
+ }
411
+ const conflict = mode === "create"
412
+ ? codexAccountIdNamespaceCollisionError(persisted.codexAccountNamespaces, account.id)
413
+ ?? ((persisted.codexAccounts ?? []).some(candidate => candidate.id === account.id)
414
+ ? `Account id already exists: ${account.id}`
415
+ : undefined)
416
+ : (configuredPoolAccount(persisted, account.id)
417
+ ? undefined
418
+ : "Pool account was removed while login was in progress. Add it again as a new account.");
419
+ if (conflict) {
420
+ return { changed: false, value: { ok: false, error: conflict } as CodexAccountConfigCommit };
421
+ }
422
+
423
+ const accounts = persisted.codexAccounts ?? [];
424
+ const existingIdx = accounts.findIndex(candidate => candidate.id === account.id);
425
+ let committedAccount: CodexAccount;
426
+ if (existingIdx >= 0) {
427
+ committedAccount = withCodexAccountLogLabel({
428
+ ...accounts[existingIdx],
429
+ ...account,
430
+ isMain: false,
431
+ }, accounts);
432
+ accounts[existingIdx] = committedAccount;
433
+ } else {
434
+ committedAccount = withCodexAccountLogLabel({ ...account, isMain: false }, accounts);
435
+ accounts.push(committedAccount);
436
+ }
437
+ persisted.codexAccounts = accounts;
438
+ if (mode === "create" && persisted.codexAccountPickerEnabled !== undefined) {
439
+ initializeDefaultCodexAccountNamespaces(persisted);
440
+ appendDefaultCodexAccountNamespace(persisted, committedAccount);
441
+ }
442
+ if (quotaPause === "add") setCodexAccountPaused(persisted, account.id, true);
443
+ else if (quotaPause === "release") setCodexAccountPaused(persisted, account.id, false);
444
+ return { changed: true, value: { ok: true } as CodexAccountConfigCommit };
445
+ };
446
+ const outcome = mutatePersistedConfig(applyMutation);
447
+ if (outcome.status === "unavailable") {
448
+ if (outcome.reason === "missing") {
449
+ const fallback = structuredClone(sourceConfig);
450
+ const applied = applyMutation(fallback);
451
+ if (!applied.value.ok) return applied.value;
452
+ saveRuntimeConfig(sourceConfig, fallback);
453
+ return { ok: true };
454
+ }
455
+ return { ok: false, error: `Configuration could not be updated (${outcome.reason})` };
456
+ }
457
+ if (!outcome.value.ok) return outcome.value;
458
+ reconcileLiveConfigFromDisk(sourceConfig, liveBaseline);
459
+ return { ok: true };
460
+ }
461
+
314
462
  async function mapWithConcurrency<T, R>(
315
463
  items: T[],
316
464
  concurrency: number,
@@ -364,7 +512,7 @@ interface MainAccountInfoFetchResult {
364
512
  }
365
513
 
366
514
  export async function fetchMainAccountInfo(forceRefresh = false): Promise<MainAccountInfo> {
367
- const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1);
515
+ const { info } = await fetchMainAccountInfoAttempt(forceRefresh, 1, forceRefresh);
368
516
  return info;
369
517
  }
370
518
 
@@ -373,16 +521,21 @@ const EMPTY_MAIN_ACCOUNT_INFO: MainAccountInfo = { email: null, plan: null, quot
373
521
  async function retryMainAccountInfoIfIdentityChanged(
374
522
  requestAccountId: string | null,
375
523
  retriesRemaining: number,
524
+ explicitRefresh: boolean,
376
525
  ): Promise<MainAccountInfoFetchResult | null> {
377
526
  const currentAccountId = getMainChatgptAccountId();
378
527
  if (currentAccountId === null || currentAccountId === requestAccountId) return null;
379
528
  reconcileMainCodexAccountRuntimeState();
380
529
  return retriesRemaining > 0
381
- ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1)
530
+ ? fetchMainAccountInfoAttempt(true, retriesRemaining - 1, explicitRefresh)
382
531
  : { info: EMPTY_MAIN_ACCOUNT_INFO };
383
532
  }
384
533
 
385
- async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaining: number): Promise<MainAccountInfoFetchResult> {
534
+ async function fetchMainAccountInfoAttempt(
535
+ forceRefresh: boolean,
536
+ retriesRemaining: number,
537
+ explicitRefresh = forceRefresh,
538
+ ): Promise<MainAccountInfoFetchResult> {
386
539
  const writerGeneration = captureConfigGeneration();
387
540
  reconcileMainCodexAccountRuntimeState();
388
541
  const tokenRead = readCodexTokensResult();
@@ -409,7 +562,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
409
562
  });
410
563
  if (!resp.ok) {
411
564
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp);
412
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
565
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
413
566
  if (retried) return retried;
414
567
  if (terminalAuthFailure) {
415
568
  clearMainAccountInfoCache();
@@ -418,7 +571,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
418
571
  return { info: EMPTY_MAIN_ACCOUNT_INFO };
419
572
  }
420
573
  const data = (await resp.json()) as WhamUsageResponse;
421
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
574
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
422
575
  if (retried) return retried;
423
576
  const plan = nonEmptyPlan(data.plan_type) ?? nonEmptyPlan(cached?.plan) ?? nonEmptyPlan(getMainAccountPlan());
424
577
  const quota = parseUsageQuota({ ...data, ...(plan ? { plan_type: plan } : {}) });
@@ -430,7 +583,9 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
430
583
  ts: Date.now(),
431
584
  };
432
585
  setMainAccountInfoCache(result);
433
- clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
586
+ // A successful quota probe only proves /wham/usage accepted the token. Only an
587
+ // operator-requested refresh may retract a quarantine raised by Responses traffic.
588
+ if (explicitRefresh) clearAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID);
434
589
  // Mirror main quota + plan into the shared stores so the rotation engine can
435
590
  // score and auto-switch the main account exactly like a pool account (Option A).
436
591
  setMainAccountPlan(result.plan);
@@ -443,7 +598,7 @@ async function fetchMainAccountInfoAttempt(forceRefresh: boolean, retriesRemaini
443
598
  ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
444
599
  };
445
600
  } catch {
446
- const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining);
601
+ const retried = await retryMainAccountInfoIfIdentityChanged(requestAccountId, retriesRemaining, explicitRefresh);
447
602
  return retried ?? { info: EMPTY_MAIN_ACCOUNT_INFO };
448
603
  }
449
604
  }
@@ -514,6 +669,8 @@ export interface CodexAuthAccountDto {
514
669
  logLabel?: string;
515
670
  isMain: boolean;
516
671
  paused: boolean;
672
+ /** Selection order; higher is considered first. */
673
+ priority: number;
517
674
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
518
675
  needsReauth?: boolean;
519
676
  hasCredential: boolean;
@@ -750,6 +907,10 @@ export function clearCodexQuotaPrimeState(): void {
750
907
  primeInFlight = null;
751
908
  }
752
909
 
910
+ export function effectiveCodexAuthAccountId(config: OcxConfig): string {
911
+ return getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID;
912
+ }
913
+
753
914
  export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = false): Promise<CodexAuthAccountDto[]> {
754
915
  const runtimeConfig = getRuntimeConfig(config);
755
916
  const poolAccounts = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
@@ -796,6 +957,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
796
957
  { quota: null, needsReauth: true },
797
958
  false,
798
959
  isCodexAccountPaused(runtimeConfig, accountId),
960
+ getCodexAccountPriority(runtimeConfig, accountId),
799
961
  )];
800
962
  }
801
963
  const resultGeneration = quotaResult.credentialGeneration ?? quotaResult.freshCredentialGeneration;
@@ -814,6 +976,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
814
976
  effectiveQuotaResult,
815
977
  true,
816
978
  isCodexAccountPaused(runtimeConfig, accountId),
979
+ getCodexAccountPriority(runtimeConfig, accountId),
817
980
  )];
818
981
  });
819
982
  const hasMainCredential = readCodexTokens() !== null;
@@ -828,9 +991,15 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa
828
991
  plan: mainInfo.plan,
829
992
  isMain: true,
830
993
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
994
+ priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
831
995
  hasCredential: hasMainCredential,
832
996
  needsReauth: mainNeedsReauth,
833
- quota: mainInfo.quota ? { ...quotaForPlan({ ...mainInfo.quota, updatedAt: Date.now() }, mainInfo.plan) } : null,
997
+ quota: mainInfo.quota ? {
998
+ ...quotaForPlan({
999
+ ...mainInfo.quota,
1000
+ updatedAt: getAccountQuota(MAIN_CODEX_ACCOUNT_ID)?.updatedAt ?? Date.now(),
1001
+ }, mainInfo.plan),
1002
+ } : null,
834
1003
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
835
1004
  };
836
1005
  return [main, ...withQuota];
@@ -937,24 +1106,52 @@ export async function handleCodexAuthAPI(
937
1106
  const payload = decodeJwtPayload(body.accessToken);
938
1107
  const exp = typeof payload?.exp === "number" ? payload.exp * 1000 : Date.now() + 3600_000;
939
1108
  const warmup = await verifyCodexAccountWarmup(body.id, body.accessToken, derivedAccountId);
940
- if (!warmup.ok) return warmup.response;
1109
+ const freshQuota = !warmup.ok && isCodexWarmupQuotaFailure(warmup.error)
1110
+ ? await fetchFreshRegistrationQuota(body.accessToken, derivedAccountId, body.plan)
1111
+ : null;
1112
+ const quotaDeferred = !warmup.ok && mayDeferQuotaWarmup(warmup, freshQuota);
1113
+ if (!warmup.ok && !quotaDeferred) return warmup.response;
941
1114
  const latestConfig = getRuntimeConfig(config);
942
1115
  const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create");
943
1116
  if (commitConflict) return jsonResponse({ error: commitConflict }, 400);
944
- saveCodexAccountCredential(body.id, {
1117
+ const credentialGeneration = saveCodexAccountCredential(body.id, {
945
1118
  accessToken: body.accessToken,
946
1119
  refreshToken: body.refreshToken,
947
1120
  expiresAt: exp,
948
1121
  chatgptAccountId: derivedAccountId,
949
1122
  });
950
- markCodexAccountValidated(body.id, warmup.validatedAt);
1123
+ const commit = commitCodexAccountConfig(
1124
+ config,
1125
+ {
1126
+ id: body.id,
1127
+ email: body.email,
1128
+ plan: freshQuota?.plan ?? body.plan,
1129
+ isMain: false,
1130
+ },
1131
+ "create",
1132
+ credentialGeneration,
1133
+ quotaDeferred ? "add" : "preserve",
1134
+ );
1135
+ if (!commit.ok) {
1136
+ tombstoneCodexAccountIfGeneration(body.id, credentialGeneration);
1137
+ return jsonResponse({ error: commit.error }, 409);
1138
+ }
1139
+ if (quotaDeferred) {
1140
+ markCodexAccountQuotaValidationPending(
1141
+ body.id,
1142
+ codexWarmupFailureReason(warmup.error),
1143
+ exhaustedQuotaRetryAt(freshQuota.quota, freshQuota.plan),
1144
+ );
1145
+ } else if (warmup.ok) {
1146
+ markCodexAccountValidated(body.id, warmup.validatedAt, credentialGeneration);
1147
+ }
951
1148
  clearAccountNeedsReauth(body.id);
952
- const accounts = latestConfig.codexAccounts ?? [];
953
- accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts));
954
- latestConfig.codexAccounts = accounts;
955
- saveRuntimeConfig(config, latestConfig);
956
1149
  reconcileLiveStateStores();
957
- return jsonResponse({ ok: true });
1150
+ if (quotaDeferred) {
1151
+ setAccountQuotaFromParsed(body.id, freshQuota.quota);
1152
+ requestCodexQuotaWarmupRetry();
1153
+ }
1154
+ return jsonResponse({ ok: true, ...(quotaDeferred ? { validation: "pending_quota_reset" } : {}) });
958
1155
  }
959
1156
 
960
1157
  if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") {
@@ -1002,6 +1199,13 @@ export async function handleCodexAuthAPI(
1002
1199
  const exists = id === MAIN_CODEX_ACCOUNT_ID
1003
1200
  || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1004
1201
  if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1202
+ const record = id === MAIN_CODEX_ACCOUNT_ID ? null : readCodexAccountRecord(id);
1203
+ if (body.paused === false && record?.lastCodexValidationStatus === "quota_pending") {
1204
+ return jsonResponse({ error: "Account validation is waiting for its quota reset" }, 409);
1205
+ }
1206
+ if (body.paused === true && record?.codexQuotaPauseOwned === true) {
1207
+ clearCodexAccountQuotaPauseOwnership(id);
1208
+ }
1005
1209
 
1006
1210
  setCodexAccountPaused(runtimeConfig, id, body.paused);
1007
1211
  if (body.paused) {
@@ -1018,6 +1222,42 @@ export async function handleCodexAuthAPI(
1018
1222
  });
1019
1223
  }
1020
1224
 
1225
+ if (url.pathname === "/api/codex-auth/accounts/priority" && req.method === "PUT") {
1226
+ let parsedBody: unknown;
1227
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
1228
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
1229
+ return jsonResponse({ error: "body must be an object" }, 400);
1230
+ }
1231
+ const body = parsedBody as { id?: unknown; priority?: unknown };
1232
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1233
+ if (!isCodexAccountPriorityKey(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
1234
+
1235
+ let priority = DEFAULT_ACCOUNT_PRIORITY;
1236
+ if (body.priority !== null) {
1237
+ const parsed = parseAccountPriority(body.priority);
1238
+ if (parsed === null) {
1239
+ return jsonResponse({
1240
+ error: `priority must be null or an integer ${MIN_ACCOUNT_PRIORITY}-${MAX_ACCOUNT_PRIORITY}`,
1241
+ }, 400);
1242
+ }
1243
+ priority = parsed;
1244
+ }
1245
+
1246
+ const runtimeConfig = getRuntimeConfig(config);
1247
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
1248
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
1249
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
1250
+ setCodexAccountPriority(runtimeConfig, id, priority);
1251
+ clearCodexAccountPin(runtimeConfig);
1252
+ saveRuntimeConfig(config, runtimeConfig);
1253
+ return jsonResponse({
1254
+ ok: true,
1255
+ id,
1256
+ priority,
1257
+ activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1258
+ });
1259
+ }
1260
+
1021
1261
  if (url.pathname === "/api/codex-auth/accounts/pause-exhausted" && req.method === "PUT") {
1022
1262
  const runtimeConfig = getRuntimeConfig(config);
1023
1263
  const result = await pauseExhaustedCodexAccounts(runtimeConfig);
@@ -1078,6 +1318,8 @@ export async function handleCodexAuthAPI(
1078
1318
  if (!exists) return jsonResponse({ error: "Account not found" }, 400);
1079
1319
  }
1080
1320
  runtimeConfig.activeCodexAccountId = body.accountId ?? undefined;
1321
+ if (body.accountId == null) clearCodexAccountPin(runtimeConfig);
1322
+ else setCodexAccountPin(runtimeConfig, targetAccountId);
1081
1323
  resetCodexRoutingForManualSelection(targetAccountId);
1082
1324
  saveRuntimeConfig(config, runtimeConfig);
1083
1325
  return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
@@ -1087,6 +1329,8 @@ export async function handleCodexAuthAPI(
1087
1329
  const runtimeConfig = getRuntimeConfig(config);
1088
1330
  return jsonResponse({
1089
1331
  activeCodexAccountId: getEffectiveActiveCodexAccountId(runtimeConfig) ?? null,
1332
+ pinned: isEffectiveCodexAccountPinned(runtimeConfig),
1333
+ pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
1090
1334
  autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
1091
1335
  upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
1092
1336
  accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
@@ -1377,7 +1621,10 @@ export async function handleCodexAuthAPI(
1377
1621
  }
1378
1622
 
1379
1623
  const warmup = await verifyCodexAccountWarmup(accountId, cred.access, oauthAccountId);
1380
- if (!warmup.ok) {
1624
+ const freshRegistrationQuota = quota ? { quota, ...(plan ? { plan } : {}) } : null;
1625
+ const quotaDeferred = !warmup.ok
1626
+ && mayDeferQuotaWarmup(warmup, freshRegistrationQuota);
1627
+ if (!warmup.ok && !quotaDeferred) {
1381
1628
  const body = await warmup.response.json().catch(() => ({})) as { error?: string; reason?: string };
1382
1629
  setCodexLoginState(flowId, {
1383
1630
  status: "error",
@@ -1391,6 +1638,7 @@ export async function handleCodexAuthAPI(
1391
1638
  const latestConfig = getRuntimeConfig(config);
1392
1639
  const accounts = latestConfig.codexAccounts ?? [];
1393
1640
  const existingIdx = accounts.findIndex(account => account.id === accountId);
1641
+ const existingAccount = existingIdx >= 0 ? accounts[existingIdx] : undefined;
1394
1642
  const commitConflict = codexAccountPersistenceConflict(
1395
1643
  latestConfig,
1396
1644
  accountId,
@@ -1406,37 +1654,56 @@ export async function handleCodexAuthAPI(
1406
1654
  break;
1407
1655
  }
1408
1656
 
1409
- saveCodexAccountCredential(accountId, {
1657
+ const releaseOwnedQuotaPause = reauth
1658
+ && readCodexAccountRecord(accountId)?.codexQuotaPauseOwned === true;
1659
+ const quotaPauseOwned = !reauth
1660
+ || releaseOwnedQuotaPause
1661
+ || !isCodexAccountPaused(latestConfig, accountId);
1662
+ const credentialGeneration = saveCodexAccountCredential(accountId, {
1410
1663
  accessToken: cred.access,
1411
1664
  refreshToken: cred.refresh,
1412
1665
  expiresAt: cred.expires,
1413
1666
  chatgptAccountId: oauthAccountId,
1414
1667
  });
1668
+ const configCommit = commitCodexAccountConfig(
1669
+ config,
1670
+ {
1671
+ id: accountId,
1672
+ email,
1673
+ plan: plan ?? existingAccount?.plan,
1674
+ isMain: false,
1675
+ },
1676
+ reauth ? "reauth" : "create",
1677
+ credentialGeneration,
1678
+ quotaDeferred ? "add" : releaseOwnedQuotaPause ? "release" : "preserve",
1679
+ );
1680
+ if (!configCommit.ok) {
1681
+ if (!reauth) tombstoneCodexAccountIfGeneration(accountId, credentialGeneration);
1682
+ setCodexLoginState(flowId, {
1683
+ status: "error",
1684
+ error: configCommit.error,
1685
+ doneAt: Date.now(),
1686
+ });
1687
+ completed = true;
1688
+ break;
1689
+ }
1415
1690
  // A successful reauthentication replaces the credential generation. Do not let a
1416
1691
  // failed optional WHAM probe make the replacement inherit quota from the old record.
1417
1692
  if (reauth) clearAccountQuota(accountId);
1418
- markCodexAccountValidated(accountId, warmup.validatedAt);
1419
- clearAccountNeedsReauth(accountId);
1420
- if (quota) {
1421
- setAccountQuotaFromParsed(accountId, quota);
1422
- }
1423
-
1424
- if (existingIdx >= 0) {
1425
- // Keep the pool id stable; refresh display metadata after a successful login/reauth.
1426
- accounts[existingIdx] = withCodexAccountLogLabel({
1427
- ...accounts[existingIdx],
1428
- email,
1429
- plan: plan ?? accounts[existingIdx].plan,
1430
- isMain: false,
1431
- }, accounts);
1432
- latestConfig.codexAccounts = accounts;
1433
- saveRuntimeConfig(config, latestConfig);
1434
- } else {
1435
- accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts));
1436
- latestConfig.codexAccounts = accounts;
1437
- saveRuntimeConfig(config, latestConfig);
1693
+ if (quotaDeferred) {
1694
+ markCodexAccountQuotaValidationPending(
1695
+ accountId,
1696
+ codexWarmupFailureReason(warmup.error),
1697
+ exhaustedQuotaRetryAt(freshRegistrationQuota.quota, freshRegistrationQuota.plan),
1698
+ quotaPauseOwned,
1699
+ );
1700
+ } else if (warmup.ok) {
1701
+ markCodexAccountValidated(accountId, warmup.validatedAt, credentialGeneration);
1438
1702
  }
1703
+ clearAccountNeedsReauth(accountId);
1439
1704
  reconcileLiveStateStores();
1705
+ if (quota) setAccountQuotaFromParsed(accountId, quota);
1706
+ if (quotaDeferred) requestCodexQuotaWarmupRetry();
1440
1707
  setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() });
1441
1708
  completed = true;
1442
1709
  }