@bitkyc08/opencodex 2.38.0 → 2.39.0

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 (81) hide show
  1. package/bin/ocx.mjs +58 -6
  2. package/gui/dist/assets/index-D-lchsPw.js +112 -0
  3. package/gui/dist/assets/index-uvENYLin.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/baseten.svg +13 -0
  6. package/gui/dist/provider-icons/bizrouter.svg +41 -0
  7. package/gui/dist/provider-icons/cerebras.svg +26 -0
  8. package/gui/dist/provider-icons/deepinfra.svg +75 -0
  9. package/gui/dist/provider-icons/digitalocean.svg +10 -0
  10. package/gui/dist/provider-icons/featherless.svg +4 -0
  11. package/gui/dist/provider-icons/gajae-code.svg +410 -0
  12. package/gui/dist/provider-icons/hermes-agent.svg +207 -0
  13. package/gui/dist/provider-icons/hyperbolic.svg +18 -0
  14. package/gui/dist/provider-icons/kilo.svg +13 -0
  15. package/gui/dist/provider-icons/litellm.svg +1 -0
  16. package/gui/dist/provider-icons/minimax.svg +1 -0
  17. package/gui/dist/provider-icons/nanogpt.svg +74 -0
  18. package/gui/dist/provider-icons/nebius.svg +1 -0
  19. package/gui/dist/provider-icons/neuralwatt.svg +27 -0
  20. package/gui/dist/provider-icons/nous.svg +149 -0
  21. package/gui/dist/provider-icons/novita.svg +32 -0
  22. package/gui/dist/provider-icons/orcarouter.svg +175 -0
  23. package/gui/dist/provider-icons/parallel.svg +13 -0
  24. package/gui/dist/provider-icons/sambanova.svg +276 -0
  25. package/gui/dist/provider-icons/scaleway.svg +11 -0
  26. package/gui/dist/provider-icons/siliconflow.svg +18 -0
  27. package/gui/dist/provider-icons/synthetic.svg +12 -0
  28. package/gui/dist/provider-icons/together.svg +18 -0
  29. package/gui/dist/provider-icons/umans.svg +30 -0
  30. package/gui/dist/provider-icons/venice.svg +165 -0
  31. package/gui/dist/provider-icons/vultr.svg +15 -0
  32. package/gui/dist/provider-icons/zai.svg +218 -0
  33. package/gui/dist/provider-icons/zenmux.svg +1 -0
  34. package/package.json +1 -1
  35. package/src/adapters/cursor/live-models.ts +1 -0
  36. package/src/adapters/openai-responses.ts +72 -10
  37. package/src/bridge.ts +15 -15
  38. package/src/cli/dispatch.ts +54 -18
  39. package/src/cli/index.ts +420 -30
  40. package/src/cli/integrations.ts +24 -2
  41. package/src/cli/uninstall-plan.ts +86 -0
  42. package/src/codex/account-store.ts +121 -8
  43. package/src/codex/auth-api.ts +202 -33
  44. package/src/codex/catalog/provider-fetch.ts +76 -9
  45. package/src/codex/history-job.ts +10 -0
  46. package/src/codex/history-manifest.ts +35 -2
  47. package/src/codex/history-provider.ts +196 -19
  48. package/src/codex/history-worker.ts +3 -0
  49. package/src/codex/quota-401-recovery.ts +190 -0
  50. package/src/codex/quota-recovery-timing.ts +28 -0
  51. package/src/codex/quota.ts +6 -0
  52. package/src/codex/routing.ts +68 -16
  53. package/src/codex/subagent-model-fallback.ts +4 -1
  54. package/src/config/pending-teardown-names.d.mts +8 -0
  55. package/src/config/pending-teardown-names.mjs +69 -0
  56. package/src/config/pending-teardown.ts +286 -0
  57. package/src/generated/compatibility-version.json +84 -32
  58. package/src/integrations/journal.ts +12 -1
  59. package/src/integrations/writer.ts +82 -7
  60. package/src/lib/process-control.ts +30 -5
  61. package/src/lib/state-store-registrations.ts +8 -0
  62. package/src/oauth/index.ts +220 -13
  63. package/src/oauth/store.ts +220 -18
  64. package/src/responses/spill-store.ts +20 -0
  65. package/src/responses/state.ts +288 -2
  66. package/src/server/management/integration-routes.ts +36 -5
  67. package/src/server/management-api.ts +66 -12
  68. package/src/server/responses/encrypted-payload.ts +18 -1
  69. package/src/server/stop-teardown.ts +84 -0
  70. package/src/service.ts +155 -20
  71. package/src/update/index.ts +36 -4
  72. package/src/update/proxy-liveness-probe.d.mts +6 -0
  73. package/src/update/proxy-liveness-probe.mjs +84 -0
  74. package/src/update/stop-contract.d.mts +2 -0
  75. package/src/update/stop-contract.mjs +15 -0
  76. package/src/update/stop-decision.d.mts +10 -0
  77. package/src/update/stop-decision.mjs +34 -0
  78. package/src/vision/eligibility.ts +19 -1
  79. package/src/vision/index.ts +4 -18
  80. package/gui/dist/assets/index-C14iCj_Q.js +0 -112
  81. package/gui/dist/assets/index-D7PIz7_g.css +0 -1
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Whether an uninstall may take shared client config down (#3008).
3
+ *
4
+ * Extracted from `handleUninstall` because the rule is a decision, and a decision that
5
+ * only exists inside a long imperative command can only be tested by reading its source —
6
+ * which is how this shipped wrong twice: first trusting a boolean that collapsed "not
7
+ * installed" with "still running", then trusting a missing pid file as proof no proxy was
8
+ * serving.
9
+ *
10
+ * Native Codex and the Grok fence are SHARED. Restoring them while something may still be
11
+ * serving leaves the client and the proxy pointing at each other, so every step that could
12
+ * leave a live proxy behind has to be accounted for first.
13
+ */
14
+ export type UninstallObservation = {
15
+ /** Detailed service-stop outcome, or null when the step threw. */
16
+ serviceStop: "absent" | "stopped" | "stopped-respawnable" | "failed" | "state-unknown" | null;
17
+ /**
18
+ * Did the proxy step PROVE nothing is serving?
19
+ *
20
+ * A `findLiveProxy` miss is not that proof: it collapses a timeout and a transport
21
+ * failure into the same null as a dead endpoint, so an unresponsive proxy read as absent.
22
+ */
23
+ proxyProvenDown: boolean;
24
+ /** Service removal outcome, or null when the step threw. */
25
+ serviceRemoval: "absent" | "removed" | "failed" | null;
26
+ /**
27
+ * For a Task Scheduler backend: was the restart window verified AFTER removal?
28
+ *
29
+ * Deleting the registration does not prove an already-running `:loop` wrapper died —
30
+ * killing it is best-effort (#764). `ocx stop` polls across the window; uninstall has
31
+ * to do the same before it may take shared config down.
32
+ */
33
+ respawnWindowVerified: boolean;
34
+ };
35
+
36
+ export function sharedTeardownAuthorized(o: UninstallObservation): boolean {
37
+ if (o.serviceStop === null) return false;
38
+ // "absent" and a clean stop are the only service states that prove nothing is managing
39
+ // the proxy.
40
+ if (o.serviceStop === "failed" || o.serviceStop === "state-unknown") return false;
41
+ // Removing the registration is not the same as proving the running wrapper is gone.
42
+ if (o.serviceStop === "stopped-respawnable" && !o.respawnWindowVerified) return false;
43
+ if (o.serviceRemoval === null || o.serviceRemoval === "failed") return false;
44
+ return o.proxyProvenDown;
45
+ }
46
+
47
+ /** An endpoint an uninstall must account for before shared config comes down. */
48
+ export type ProbeEndpoint = { hostname: string; port: number };
49
+
50
+ /**
51
+ * Every DISTINCT endpoint this home could be serving on.
52
+ *
53
+ * A runtime record and the configured port can disagree — a stale record pointing at a
54
+ * closed port while the live proxy sits on the configured one. Probing only the runtime
55
+ * candidate then reports "dead" for a port nobody is using and authorizes the teardown
56
+ * (#3008). `findLiveProxy` already probes both; the proof has to cover both too.
57
+ */
58
+ export function endpointsToProve(
59
+ runtime: { port?: number; hostname?: string } | null,
60
+ config: { port?: number; hostname?: string },
61
+ ): ProbeEndpoint[] {
62
+ const out: ProbeEndpoint[] = [];
63
+ const push = (port: number | undefined, hostname: string | undefined) => {
64
+ if (!port || port <= 0 || port > 65535) return;
65
+ const endpoint = { hostname: hostname ?? "127.0.0.1", port };
66
+ if (out.some(e => e.port === endpoint.port && e.hostname === endpoint.hostname)) return;
67
+ out.push(endpoint);
68
+ };
69
+ push(runtime?.port, runtime?.hostname);
70
+ push(typeof config.port === "number" && config.port > 0 ? config.port : 10100, config.hostname);
71
+ return out;
72
+ }
73
+
74
+ /**
75
+ * Proof requires EVERY candidate to be definitively dead.
76
+ *
77
+ * "unknown" is not absence: a listener that accepts connections but withholds /healthz, or
78
+ * one that times out, is exactly the state where restoring shared config is most harmful.
79
+ */
80
+ export function everyEndpointProvenDown(
81
+ endpoints: readonly ProbeEndpoint[],
82
+ probe: (e: ProbeEndpoint) => "live" | "dead" | "unknown",
83
+ ): boolean {
84
+ if (endpoints.length === 0) return false;
85
+ return endpoints.every(e => probe(e) === "dead");
86
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
14
14
  import type { CodexAccountCredentialRecord, CodexAccountCredentials } from "../types";
15
15
  import { advanceCodexCredentialMutationEpoch } from "./credential-mutation-epoch";
16
+ import { CODEX_REFRESH_FLIGHT_CEILING_MS } from "./quota-recovery-timing";
16
17
 
17
18
  type LegacyCodexAccountStore = Record<string, CodexAccountCredentials>;
18
19
  type CodexAccountStore = Record<string, CodexAccountCredentialRecord>;
@@ -407,7 +408,29 @@ type CodexRefreshResult = CodexTokenResult & {
407
408
  * refresh of the one the caller was holding, not somebody else's replacement.
408
409
  */
409
410
  selfRefreshed?: boolean;
411
+ /**
412
+ * Three-way form of {@link selfRefreshed}, kept alongside it so existing callers are
413
+ * unaffected (#3019). `selfRefreshed` is `provenance === "self-refresh"`.
414
+ */
415
+ provenance?: CodexRefreshProvenance;
410
416
  };
417
+
418
+ /**
419
+ * How THIS caller arrived at the credential it is returning (#3019).
420
+ *
421
+ * `selfRefreshed` is a boolean, and a boolean cannot carry three cases. Its `false` means
422
+ * both "somebody else replaced the credential" and "I joined an in-flight refresh of the
423
+ * same grant and adopted its result" — and a recovery budget has to treat those opposite
424
+ * ways. Joining is the same lineage getting its one refresh; replacement is a NEW lineage
425
+ * that has not had one yet, and charging it for somebody else's attempt would deny the
426
+ * fresh credential the recovery this exists to grant.
427
+ */
428
+ export type CodexRefreshProvenance = "self-refresh" | "joined-lineage" | "external-replacement";
429
+
430
+ /** Terminal outcome of one forced refresh, as seen by the caller that requested it. */
431
+ export type ForcedRefreshOutcome =
432
+ | { kind: "resolved"; provenance: CodexRefreshProvenance; generation: number; rotated: boolean }
433
+ | { kind: "failed"; error: unknown };
411
434
  const MAX_CODEX_REFRESH_FLIGHTS = 32;
412
435
  const CODEX_REFRESH_FLIGHT_STALE_MS = 120_000;
413
436
  interface RefreshFlight {
@@ -587,22 +610,81 @@ function awaitOwnCancellation<T>(work: Promise<T>, callerSignal?: AbortSignal):
587
610
  */
588
611
  export async function forceRefreshCodexPoolToken(
589
612
  id: string,
590
- options: { rejectedGeneration: number; rejectedAccessToken: string; signal?: AbortSignal },
591
- ): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean }> {
592
- const result = await resolveCodexToken(
613
+ options: {
614
+ rejectedGeneration: number;
615
+ rejectedAccessToken: string;
616
+ signal?: AbortSignal;
617
+ /**
618
+ * Fires with THIS caller's classified outcome, regardless of `signal` (#3019).
619
+ *
620
+ * Cancellation rejects what the caller awaits; the shared flight keeps running and
621
+ * commits. A recovery budget claimed before the refresh therefore has no one left to
622
+ * settle it — the claim expires and the already-refreshed lineage gets a second
623
+ * refresh, which is the loop the budget exists to close. This callback is attached to
624
+ * the resolution itself, so it fires with no waiter present.
625
+ *
626
+ * It is called exactly once per call, for both success and failure, and its own
627
+ * failures are swallowed: settlement bookkeeping must never reject a credential the
628
+ * caller successfully obtained, nor disturb another waiter on the same flight.
629
+ */
630
+ onSettled?: (outcome: ForcedRefreshOutcome) => void | Promise<void>;
631
+ },
632
+ ): Promise<CodexTokenResult & { rotated: boolean; selfRefreshed: boolean; provenance: CodexRefreshProvenance }> {
633
+ const settle = (outcome: ForcedRefreshOutcome) => {
634
+ // Both halves matter: a synchronous throw and a rejected thenable are equally capable
635
+ // of turning settlement bookkeeping into an unhandled rejection that fails the process.
636
+ try { void Promise.resolve(options.onSettled?.(outcome)).catch(() => {}); } catch { /* ignore */ }
637
+ };
638
+ const classify = (result: CodexRefreshResult): CodexRefreshProvenance =>
639
+ // Default to the conservative reading. A path that did not classify itself is not
640
+ // assumed to be this caller's own lineage: charging a replacement for somebody else's
641
+ // attempt is the failure mode, so an unlabelled path leaves the returned lineage its
642
+ // own budget.
643
+ result.provenance ?? (result.selfRefreshed === true ? "self-refresh" : "external-replacement");
644
+
645
+ // The completion is NOT the caller's await.
646
+ //
647
+ // `options.signal` cancels what this function returns, while the shared flight keeps
648
+ // running and commits. Settling from the cancelled await therefore reported "failed" for
649
+ // a refresh that was about to succeed — releasing the budget, and letting the newly
650
+ // refreshed lineage claim again moments later. So the settlement rides an uncancelled
651
+ // resolution and the caller's cancellation is layered on top of it.
652
+ // A caller that is already gone must not start work. `resolveCodexToken` is called
653
+ // without the caller signal below, which bypasses its own pre-abort guard, so a
654
+ // pre-aborted request would otherwise rotate a credential nobody is waiting for.
655
+ if (options.signal?.aborted) {
656
+ settle({ kind: "failed", error: options.signal.reason });
657
+ throw options.signal.reason;
658
+ }
659
+ const completion = resolveCodexToken(
593
660
  id,
594
661
  { rejectedGeneration: options.rejectedGeneration, rejectedAccessToken: options.rejectedAccessToken },
595
- options.signal,
662
+ // Deliberately no caller signal: the flight is shared and this settlement speaks for
663
+ // the credential, not for whoever happened to be waiting.
664
+ undefined,
596
665
  );
666
+ completion.then(
667
+ resolved => settle({
668
+ kind: "resolved",
669
+ provenance: classify(resolved),
670
+ generation: resolved.generation,
671
+ rotated: resolved.accessToken !== options.rejectedAccessToken,
672
+ }),
673
+ error => settle({ kind: "failed", error }),
674
+ );
675
+ const result = await awaitOwnCancellation(completion, options.signal);
676
+ const provenance = classify(result);
677
+ const rotated = result.accessToken !== options.rejectedAccessToken;
597
678
  return {
598
679
  accessToken: result.accessToken,
599
680
  chatgptAccountId: result.chatgptAccountId,
600
681
  generation: result.generation,
601
- rotated: result.accessToken !== options.rejectedAccessToken,
682
+ rotated,
602
683
  // Only a CAS this call performed itself proves the new credential descends from the
603
684
  // rejected one; anything else is somebody else's replacement and must not be treated
604
685
  // as this request's own lineage.
605
- selfRefreshed: result.selfRefreshed === true,
686
+ selfRefreshed: provenance === "self-refresh",
687
+ provenance,
606
688
  };
607
689
  }
608
690
 
@@ -633,7 +715,15 @@ async function resolveCodexToken(
633
715
  // correct again and refreshing would burn a rotation for nothing.
634
716
  const forcedTargetsStoredCredential = forced !== undefined && !forcedFenceSuperseded(record.generation, forced);
635
717
  if (cred.expiresAt > Date.now() + REFRESH_SKEW_MS && !forcedTargetsStoredCredential) {
636
- return { accessToken: cred.accessToken, chatgptAccountId: cred.chatgptAccountId, generation: record.generation };
718
+ // The freshness shortcut: nothing was refreshed and nothing was adopted. A forced
719
+ // caller reaches it only once its fence was superseded, which is a replacement by
720
+ // definition; an ordinary caller does not read this field.
721
+ return {
722
+ accessToken: cred.accessToken,
723
+ chatgptAccountId: cred.chatgptAccountId,
724
+ generation: record.generation,
725
+ provenance: "external-replacement",
726
+ };
637
727
  }
638
728
 
639
729
  const existing = refreshLocks.get(refreshGrantFingerprint);
@@ -658,6 +748,14 @@ async function resolveCodexToken(
658
748
  accessToken: currentCred.accessToken,
659
749
  chatgptAccountId: currentCred.chatgptAccountId,
660
750
  generation: current.generation,
751
+ // Adopted the stored result of a flight this caller joined: same grant, same
752
+ // lineage. Not a replacement — that distinction is the whole point of #3019.
753
+ //
754
+ // Only `external-replacement` is inherited. The flight's own success is tagged
755
+ // `self-refresh` for the caller that performed the CAS, and copying that here
756
+ // would tell a caller that did no CAS that the credential is its own lineage.
757
+ // Everything this branch adopts is, by definition, a join.
758
+ provenance: refreshed.provenance === "external-replacement" ? "external-replacement" : "joined-lineage",
661
759
  };
662
760
  }
663
761
  }
@@ -693,6 +791,9 @@ async function resolveCodexToken(
693
791
  accessToken: currentCred.accessToken,
694
792
  chatgptAccountId: currentCred.chatgptAccountId,
695
793
  generation: current.generation,
794
+ // `forcedFenceSuperseded` is exactly "somebody else moved this credential past
795
+ // the generation I was holding" — a new lineage, entitled to its own budget.
796
+ provenance: "external-replacement",
696
797
  };
697
798
  }
698
799
  if (
@@ -720,6 +821,7 @@ async function resolveCodexToken(
720
821
  // This joiner performed its own CAS onto its own record, so the resulting
721
822
  // generation is its own lineage even though another caller drove the fetch.
722
823
  selfRefreshed: true,
824
+ provenance: "self-refresh",
723
825
  resolvedGrantFingerprint: refreshGrantFingerprint,
724
826
  };
725
827
  }
@@ -747,7 +849,7 @@ async function resolveCodexToken(
747
849
  * eviction) and the 30s ceiling remain, because those bound the flight itself.
748
850
  */
749
851
  const abort = new AbortController();
750
- const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(30_000)]);
852
+ const signal = AbortSignal.any([abort.signal, AbortSignal.timeout(CODEX_REFRESH_FLIGHT_CEILING_MS)]);
751
853
  let flight!: RefreshFlight;
752
854
  const fetchPromise = withCodexRefreshFileLock(refreshGrantFingerprint, signal, async (): Promise<CodexRefreshResult> => {
753
855
  const current = readCodexAccountRecord(id);
@@ -765,6 +867,9 @@ async function resolveCodexToken(
765
867
  credential: lockedCred,
766
868
  // This credential belongs to a DIFFERENT grant than the flight was opened
767
869
  // for. Tagging it keeps a joiner from adopting it as its own.
870
+ // It is also somebody else's credential by definition, so a joiner that ends up
871
+ // adopting it must not charge it to this lineage's budget (#3019).
872
+ provenance: "external-replacement",
768
873
  ...(lockedRefreshGrantFingerprint !== undefined
769
874
  ? { resolvedGrantFingerprint: lockedRefreshGrantFingerprint }
770
875
  : {}),
@@ -783,6 +888,9 @@ async function resolveCodexToken(
783
888
  chatgptAccountId: lockedCred.chatgptAccountId,
784
889
  generation: startGeneration,
785
890
  credential: lockedCred,
891
+ // The stored credential is fresh and no forced fence still targets it: whoever
892
+ // wrote it, it was not this call. A joiner adopting it inherits that provenance.
893
+ provenance: "external-replacement",
786
894
  resolvedGrantFingerprint: refreshGrantFingerprint,
787
895
  };
788
896
  }
@@ -803,6 +911,7 @@ async function resolveCodexToken(
803
911
  credential: sameGrantFreshCredential,
804
912
  resolvedGrantFingerprint: refreshGrantFingerprint,
805
913
  selfRefreshed: true,
914
+ provenance: "self-refresh",
806
915
  };
807
916
  }
808
917
  const res = await fetch(CHATGPT_TOKEN_URL, {
@@ -882,6 +991,7 @@ async function resolveCodexToken(
882
991
  // token — tagging the new grant would make every legitimate joiner look foreign.
883
992
  resolvedGrantFingerprint: refreshGrantFingerprint,
884
993
  selfRefreshed: true,
994
+ provenance: "self-refresh",
885
995
  };
886
996
  });
887
997
  /*
@@ -923,6 +1033,9 @@ async function resolveCodexToken(
923
1033
  // produced this generation, and a forced caller needs that to know whether the new
924
1034
  // credential descends from the one it was holding.
925
1035
  ...(result.selfRefreshed !== undefined ? { selfRefreshed: result.selfRefreshed } : {}),
1036
+ // Provenance rides out with the rest: a joiner that adopts this result needs the
1037
+ // flight's own classification, not a guess made at the adoption site (#3019).
1038
+ ...(result.provenance !== undefined ? { provenance: result.provenance } : {}),
926
1039
  ...(result.resolvedGrantFingerprint !== undefined
927
1040
  ? { resolvedGrantFingerprint: result.resolvedGrantFingerprint }
928
1041
  : {}),
@@ -10,6 +10,7 @@ import {
10
10
  getCodexAccountCredential,
11
11
  getValidCodexToken,
12
12
  isCodexAccountGenerationLive,
13
+ forceRefreshCodexPoolToken,
13
14
  markCodexAccountValidated,
14
15
  readCodexAccountRecord,
15
16
  saveCodexAccountCredential,
@@ -124,6 +125,14 @@ import { tryAcquireNativeMainProfileClaim } from "./native-main-admission";
124
125
  import { withNativeMainSharedClaim } from "./native-main-claim";
125
126
  import { resolveNativeProfileContext } from "./native-profile-store";
126
127
  import { NativeProfileError } from "./native-profile-types";
128
+ import { WHAM_REQUEST_TIMEOUT_MS } from "./quota-recovery-timing";
129
+ import {
130
+ claimQuotaRecovery,
131
+ quotaRecoveryTerminalFor,
132
+ releaseQuotaRecovery,
133
+ settleQuotaRecovery,
134
+ settleQuotaRecoveryTerminal,
135
+ } from "./quota-401-recovery";
127
136
 
128
137
  function isNativeMainClaimUnavailable(error: unknown): error is NativeProfileError {
129
138
  return error instanceof NativeProfileError
@@ -766,7 +775,7 @@ async function fetchMainAccountInfoWhileOwned(
766
775
  try {
767
776
  const resp = await fetch("https://chatgpt.com/backend-api/wham/usage", {
768
777
  headers: { Authorization: `Bearer ${tokens.access_token}`, "ChatGPT-Account-Id": tokens.account_id },
769
- signal: AbortSignal.timeout(8000),
778
+ signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
770
779
  });
771
780
  if (!resp.ok) {
772
781
  const terminalAuthFailure = await isTerminalMainAuthResponse(resp, isMainAccountTokenVerifiablyLive());
@@ -959,6 +968,180 @@ function reconcileFreshPoolAccountPlans(runtimeConfig: OcxConfig, updates: Fresh
959
968
  }
960
969
  }
961
970
 
971
+
972
+
973
+ /**
974
+ * One refresh-and-replay for a pool account whose WHAM request came back 401 (#3019).
975
+ *
976
+ * The account list used to convert any 401 straight into `needsReauth`, and a bare 401 is
977
+ * exactly what a stale-but-refreshable bearer produces after a plan change — so a healthy
978
+ * credential was thrown away and the operator was told to log in again.
979
+ *
980
+ * Bounded by the recovery store: one attempt per credential lineage. An unbounded retry
981
+ * against an upstream 401 is a self-inflicted credential-stuffing loop, which is why the
982
+ * claim is taken BEFORE the refresh and settled by the flight rather than by this caller.
983
+ */
984
+ async function recoverPoolQuotaFrom401(ctx: {
985
+ accountId: string;
986
+ existing: StoredAccountQuota | null;
987
+ configuredPlan: string | undefined;
988
+ rejectedAccessToken: string;
989
+ rejectedGeneration: number;
990
+ resp: Response;
991
+ onCredentialGeneration?: (generation: number) => void;
992
+ }): Promise<PoolQuotaResult> {
993
+ const { accountId, existing, configuredPlan, rejectedAccessToken, rejectedGeneration, resp } = ctx;
994
+
995
+ // Structured terminal evidence short-circuits everything: the same allowlist and bounded
996
+ // parser the main account uses, because it is the same endpoint answering.
997
+ if (await isTerminalPoolAuthResponse(resp)) {
998
+ // Durable, not just this response: the account list re-polls, and without a recorded
999
+ // mark the next bare 401 finds nothing terminal and reports the account healthy.
1000
+ //
1001
+ // Scoped to the generation this evidence is ABOUT. An account-wide mark would outlive
1002
+ // the credential it condemned, so a late terminal response arriving after the operator
1003
+ // re-authenticated would quarantine the replacement.
1004
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
1005
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1006
+ }
1007
+
1008
+ const claim = claimQuotaRecovery(accountId, rejectedGeneration);
1009
+ if (!claim.granted) {
1010
+ // A lineage fenced by a TERMINAL refresh failure stays terminal. Without this, the
1011
+ // budget being used would make the next bare 401 report a dead credential as healthy.
1012
+ if (quotaRecoveryTerminalFor(accountId, rejectedGeneration)) {
1013
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1014
+ }
1015
+ // Otherwise: this lineage spent its attempt, another caller is mid-refresh, or a
1016
+ // transient failure is backing off. Report transient and let the next poll try —
1017
+ // quarantining here would undo the whole point of the budget.
1018
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
1019
+ }
1020
+
1021
+ let refreshed: Awaited<ReturnType<typeof forceRefreshCodexPoolToken>>;
1022
+ try {
1023
+ refreshed = await forceRefreshCodexPoolToken(accountId, {
1024
+ rejectedGeneration,
1025
+ rejectedAccessToken,
1026
+ // Settlement rides the flight, not this await: a cancelled caller would otherwise
1027
+ // leave the claim to expire while the shared refresh commits, and the already
1028
+ // refreshed lineage would get a second attempt.
1029
+ onSettled: outcome => {
1030
+ if (outcome.kind === "resolved") {
1031
+ settleQuotaRecovery(accountId, claim.claimId, outcome);
1032
+ } else if (outcome.error instanceof TokenRefreshError && isTerminalRefreshError(outcome.error)) {
1033
+ // A revoked or expired grant does not become valid on the next poll. Releasing it
1034
+ // into backoff would let the following bare 401 find a non-terminal record and
1035
+ // report a dead credential as healthy.
1036
+ settleQuotaRecoveryTerminal(accountId, claim.claimId);
1037
+ } else {
1038
+ releaseQuotaRecovery(accountId, claim.claimId, QUOTA_RECOVERY_BACKOFF_MS);
1039
+ }
1040
+ },
1041
+ });
1042
+ } catch (e) {
1043
+ // A refresh that failed terminally is the one case where the credential really is gone.
1044
+ // Everything else is unknown, and unknown is not proof.
1045
+ if (e instanceof TokenRefreshError && isTerminalRefreshError(e)) {
1046
+ markAccountNeedsReauth(accountId, captureConfigGeneration(), rejectedGeneration);
1047
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: rejectedGeneration };
1048
+ }
1049
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: rejectedGeneration };
1050
+ }
1051
+
1052
+ // A byte-identical access token means replaying earns the same 401. Report transient
1053
+ // rather than burning the replay; the fence already moved to the returned generation.
1054
+ if (!refreshed.rotated) {
1055
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1056
+ }
1057
+
1058
+ // The flight may have moved the generation while this request was in the air. Tell the
1059
+ // coalescing layer where the credential actually is, or a late caller joins on a stale
1060
+ // generation and opens a redundant flight.
1061
+ ctx.onCredentialGeneration?.(refreshed.generation);
1062
+
1063
+ const writerGeneration = captureConfigGeneration();
1064
+ const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1065
+ headers: {
1066
+ Authorization: `Bearer ${refreshed.accessToken}`,
1067
+ "ChatGPT-Account-Id": refreshed.chatgptAccountId,
1068
+ },
1069
+ signal: AbortSignal.timeout(WHAM_REQUEST_TIMEOUT_MS),
1070
+ });
1071
+ if (!replay.ok) {
1072
+ if (replay.status === 401 && await isTerminalPoolAuthResponse(replay)) {
1073
+ // The refresh already settled this claim non-terminally, so the record alone would
1074
+ // let the next poll call a dead credential healthy. The evidence is about the
1075
+ // REFRESHED credential, which is what the replay used.
1076
+ markAccountNeedsReauth(accountId, writerGeneration, refreshed.generation);
1077
+ return { quota: existing ?? null, needsReauth: true, credentialGeneration: refreshed.generation };
1078
+ }
1079
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1080
+ }
1081
+ return await commitPoolQuotaResponse(replay, {
1082
+ accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
1083
+ });
1084
+ }
1085
+
1086
+ /** Backoff after a refresh failure that proved nothing about the credential. */
1087
+ const QUOTA_RECOVERY_BACKOFF_MS = 60_000;
1088
+
1089
+ /** Same allowlist and bounded parser as the main account: it is the same endpoint. */
1090
+ async function isTerminalPoolAuthResponse(resp: Response): Promise<boolean> {
1091
+ // Consume the original rather than a clone. `resp.clone()` tees the body, and the
1092
+ // bounded parser's timeout cancels only its own reader — the unread original branch
1093
+ // keeps buffering. Nothing needs this response afterwards, so there is nothing to tee.
1094
+ const code = await readMainAuthErrorCode(resp);
1095
+ return typeof code === "string" && MAIN_TERMINAL_AUTH_CODES.has(code);
1096
+ }
1097
+
1098
+ /** A revoked or expired grant is terminal; an unknown or transport failure is not. */
1099
+ function isTerminalRefreshError(error: TokenRefreshError): boolean {
1100
+ // Read the discriminator, not the message. TokenRefreshError carries `reason`, and
1101
+ // matching on human text would let a durable quarantine decision change the next time
1102
+ // somebody rewords an error string.
1103
+ return error.reason === "revoked" || error.reason === "expired";
1104
+ }
1105
+
1106
+ /** Parse and store a successful WHAM response. Shared by the first attempt and the replay. */
1107
+ async function commitPoolQuotaResponse(
1108
+ resp: Response,
1109
+ ctx: {
1110
+ accountId: string;
1111
+ existing: StoredAccountQuota | null;
1112
+ configuredPlan: string | undefined;
1113
+ generation: number;
1114
+ writerGeneration: number;
1115
+ },
1116
+ ): Promise<PoolQuotaResult> {
1117
+ const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
1118
+ const data = (await resp.json()) as WhamUsageResponse;
1119
+ const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
1120
+ const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
1121
+ const freshResetCredits = quota?.resetCredits;
1122
+ if (!quota) {
1123
+ return {
1124
+ quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
1125
+ needsReauth: false,
1126
+ credentialGeneration: generation,
1127
+ ...(freshPlan !== undefined ? { freshPlan, freshCredentialGeneration: generation } : {}),
1128
+ };
1129
+ }
1130
+ if (!isCodexAccountGenerationLive(accountId, generation)) {
1131
+ return { quota: null, needsReauth: false, credentialGeneration: generation };
1132
+ }
1133
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration);
1134
+ return {
1135
+ quota: getAccountQuota(accountId),
1136
+ needsReauth: false,
1137
+ credentialGeneration: generation,
1138
+ freshQuota: quota,
1139
+ freshCredentialGeneration: generation,
1140
+ ...(freshPlan !== undefined ? { freshPlan } : {}),
1141
+ ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
1142
+ };
1143
+ }
1144
+
962
1145
  async function fetchFreshPoolAccountQuota(
963
1146
  accountId: string,
964
1147
  existing: StoredAccountQuota | null,
@@ -976,39 +1159,25 @@ async function fetchFreshPoolAccountQuota(
976
1159
  signal: AbortSignal.timeout(8000),
977
1160
  });
978
1161
  if (!resp.ok) {
979
- return {
980
- quota: existing ?? null,
981
- needsReauth: resp.status === 401,
982
- credentialGeneration: generation,
983
- };
984
- }
985
- const data = (await resp.json()) as WhamUsageResponse;
986
- const freshPlan = nonEmptyPlan(data.plan_type) ?? undefined;
987
- const quota = parseUsageQuota({ ...data, plan_type: freshPlan ?? configuredPlan });
988
- const freshResetCredits = quota?.resetCredits;
989
- if (!quota) {
990
- return {
991
- quota: isCodexAccountGenerationLive(accountId, generation) ? existing ?? null : getAccountQuota(accountId),
992
- needsReauth: false,
993
- credentialGeneration: generation,
994
- ...(freshPlan !== undefined
995
- ? { freshPlan, freshCredentialGeneration: generation }
996
- : {}),
997
- };
998
- }
999
- if (!isCodexAccountGenerationLive(accountId, generation)) {
1000
- return { quota: null, needsReauth: false, credentialGeneration: generation };
1162
+ if (resp.status !== 401) {
1163
+ return { quota: existing ?? null, needsReauth: false, credentialGeneration: generation };
1164
+ }
1165
+ // A bare 401 is what a stale-but-refreshable bearer produces after a plan change, so
1166
+ // quarantining on it tells the operator to re-authenticate an account that was fine
1167
+ // (#3019). Refresh once, replay once, and only then decide.
1168
+ return await recoverPoolQuotaFrom401({
1169
+ accountId,
1170
+ existing,
1171
+ configuredPlan,
1172
+ rejectedAccessToken: accessToken,
1173
+ rejectedGeneration: generation,
1174
+ resp,
1175
+ onCredentialGeneration,
1176
+ });
1001
1177
  }
1002
- setAccountQuotaFromParsed(accountId, quota, writerGeneration);
1003
- return {
1004
- quota: getAccountQuota(accountId),
1005
- needsReauth: false,
1006
- credentialGeneration: generation,
1007
- freshQuota: quota,
1008
- freshCredentialGeneration: generation,
1009
- ...(freshPlan !== undefined ? { freshPlan } : {}),
1010
- ...(freshResetCredits !== undefined ? { freshResetCredits } : {}),
1011
- };
1178
+ return await commitPoolQuotaResponse(resp, {
1179
+ accountId, existing, configuredPlan, generation, writerGeneration,
1180
+ });
1012
1181
  } catch (e) {
1013
1182
  if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError
1014
1183
  || e instanceof CodexCredentialRefreshBusyError || e instanceof CodexCredentialRefreshStaleError) {