codex-multi-auth 2.12.0 → 2.14.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 (45) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +10 -1
  3. package/dist/lib/accounts.d.ts +75 -0
  4. package/dist/lib/accounts.d.ts.map +1 -1
  5. package/dist/lib/accounts.js +213 -42
  6. package/dist/lib/codex-manager/account-manager-commands.d.ts.map +1 -1
  7. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  8. package/dist/lib/codex-manager/commands/limits.d.ts +21 -0
  9. package/dist/lib/codex-manager/commands/limits.d.ts.map +1 -0
  10. package/dist/lib/codex-manager/commands/limits.js +158 -0
  11. package/dist/lib/codex-manager/help.d.ts.map +1 -1
  12. package/dist/lib/codex-manager/help.js +1 -0
  13. package/dist/lib/codex-manager/persist-selected-account.d.ts.map +1 -1
  14. package/dist/lib/codex-manager/persist-selected-account.js +14 -0
  15. package/dist/lib/codex-manager.d.ts.map +1 -1
  16. package/dist/lib/codex-manager.js +12 -0
  17. package/dist/lib/forecast.d.ts.map +1 -1
  18. package/dist/lib/forecast.js +1 -14
  19. package/dist/lib/preemptive-quota-scheduler.d.ts +12 -0
  20. package/dist/lib/preemptive-quota-scheduler.d.ts.map +1 -1
  21. package/dist/lib/preemptive-quota-scheduler.js +21 -0
  22. package/dist/lib/redaction.d.ts +17 -0
  23. package/dist/lib/redaction.d.ts.map +1 -0
  24. package/dist/lib/redaction.js +28 -0
  25. package/dist/lib/request/error-classification.d.ts +13 -6
  26. package/dist/lib/request/error-classification.d.ts.map +1 -1
  27. package/dist/lib/request/error-classification.js +45 -0
  28. package/dist/lib/request/fetch-helpers.d.ts +1 -1
  29. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  30. package/dist/lib/request/fetch-helpers.js +1 -1
  31. package/dist/lib/runtime/rotation-proxy-state.d.ts +6 -0
  32. package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
  33. package/dist/lib/runtime/rotation-server-types.d.ts +7 -0
  34. package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
  35. package/dist/lib/runtime/rotation-storage-meta.d.ts.map +1 -1
  36. package/dist/lib/runtime/rotation-storage-meta.js +4 -1
  37. package/dist/lib/runtime-rotation-proxy.d.ts +23 -0
  38. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  39. package/dist/lib/runtime-rotation-proxy.js +237 -13
  40. package/dist/lib/storage.d.ts +20 -2
  41. package/dist/lib/storage.d.ts.map +1 -1
  42. package/dist/lib/storage.js +69 -25
  43. package/package.json +1 -1
  44. package/scripts/codex-routing.js +1 -0
  45. package/scripts/codex.js +157 -2
@@ -8,11 +8,34 @@ export { chooseAccount } from "./runtime/rotation-account-selection.js";
8
8
  export { maybeInvalidateAffinityFromDisk, readPinnedAccountIndexFromDisk, readStorageMetaFromDisk, resetPinCacheForTesting, } from "./runtime/rotation-storage-meta.js";
9
9
  export type { StorageMeta } from "./runtime/rotation-storage-meta.js";
10
10
  /** @internal Stable identity key for in-memory quota snapshots across reloads. */
11
+ /**
12
+ * Quota-scheduler key prefix identifying ONE account across every family and
13
+ * model key it owns.
14
+ *
15
+ * The trailing separator is part of the contract: without it the prefix
16
+ * `account:email:foo` also matches `account:email:foobar:codex`, so clearing
17
+ * one account's observations would silently clear a neighbour's.
18
+ */
19
+ export declare function buildQuotaScheduleAccountPrefix(account: Pick<ManagedAccount, "accountId" | "email" | "refreshToken"> & {
20
+ /** Stable per-record discriminator retained across token/account-id updates. */
21
+ addedAt?: number;
22
+ recordId?: string;
23
+ }): string;
11
24
  export declare function buildQuotaScheduleKey(account: Pick<ManagedAccount, "accountId" | "email" | "refreshToken"> & {
12
25
  /** Stable per-record discriminator retained across token/account-id updates. */
13
26
  addedAt?: number;
14
27
  recordId?: string;
15
28
  }, family: ModelFamily, model?: string | null): string;
29
+ /**
30
+ * Total time one request may spend waiting out model capacity.
31
+ *
32
+ * `0` disables the behaviour entirely and restores the pre-#689 handling, where
33
+ * a capacity response rotates the pool and 503s. Anything unparseable falls
34
+ * back to the default rather than disabling, so a typo does not silently turn
35
+ * the feature off.
36
+ */
37
+ export declare function normalizeModelCapacityRetryMs(value: unknown): number;
38
+ export declare function resolveModelCapacityRetryMs(env?: NodeJS.ProcessEnv): number;
16
39
  /**
17
40
  * Coerce a forced-account pin from an option (number) or the launcher's env
18
41
  * string into a normalized 0-based index, or null when absent/invalid. Invalid
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAkCvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAkDtE,OAAO,KAAK,EAKX,2BAA2B,EAC3B,0BAA0B,EAE1B,MAAM,oCAAoC,CAAC;AAe5C,YAAY,EACX,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,GAC1B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACN,+BAA+B,EAC/B,0BAA0B,GAC1B,MAAM,kCAAkC,CAAC;AAC1C,YAAY,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AACxE,OAAO,EACN,+BAA+B,EAC/B,8BAA8B,EAC9B,uBAAuB,EACvB,uBAAuB,GACvB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AA8DtE,kFAAkF;AAClF,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,CAmBR;AA8gBD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,IAAI,CAUf;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,2BAA2B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CAmLrC"}
1
+ {"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAkCvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAqDtE,OAAO,KAAK,EAKX,2BAA2B,EAC3B,0BAA0B,EAE1B,MAAM,oCAAoC,CAAC;AAe5C,YAAY,EACX,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,GAC1B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACN,+BAA+B,EAC/B,0BAA0B,GAC1B,MAAM,kCAAkC,CAAC;AAC1C,YAAY,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AACxE,OAAO,EACN,+BAA+B,EAC/B,8BAA8B,EAC9B,uBAAuB,EACvB,uBAAuB,GACvB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AA8DtE,kFAAkF;AAClF;;;;;;;GAOG;AACH,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,GACC,MAAM,CAmBR;AAED,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,CAER;AA0ED;;;;;;;GAOG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAKpE;AAED,wBAAgB,2BAA2B,CAC1C,GAAG,GAAE,MAAM,CAAC,UAAwB,GAClC,MAAM,CAQR;AAueD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,IAAI,CAUf;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,2BAA2B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CA6LrC"}
@@ -9,7 +9,7 @@ import { CURRENT_CODEX_MODEL } from "./request/helpers/model-map.js";
9
9
  import { mutateRuntimeObservabilitySnapshot, recordRuntimeAccountRecovery, recordRuntimePoolExhaustion, } from "./runtime/runtime-observability.js";
10
10
  import { createRuntimeUsageRecorder, evaluateRuntimePolicy, loadRuntimePolicyState, } from "./policy/runtime-policy.js";
11
11
  import { createUsageStreamScanner } from "./usage/usage-extraction.js";
12
- import { isWorkspaceDisabledError } from "./request/fetch-helpers.js";
12
+ import { isModelAtCapacityError, isWorkspaceDisabledError, } from "./request/fetch-helpers.js";
13
13
  import { PreemptiveQuotaScheduler, readQuotaSchedulerSnapshot, } from "./preemptive-quota-scheduler.js";
14
14
  import { ContextBudgetGuard } from "./context-budget-guard.js";
15
15
  import { buildContextBudgetHeaders, createContextBudgetPauseResponse, } from "./context-budget-response.js";
@@ -80,7 +80,15 @@ const PINNED_PERMANENT_SKIP_REASONS = new Set([
80
80
  ]);
81
81
  const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
82
82
  /** @internal Stable identity key for in-memory quota snapshots across reloads. */
83
- export function buildQuotaScheduleKey(account, family, model) {
83
+ /**
84
+ * Quota-scheduler key prefix identifying ONE account across every family and
85
+ * model key it owns.
86
+ *
87
+ * The trailing separator is part of the contract: without it the prefix
88
+ * `account:email:foo` also matches `account:email:foobar:codex`, so clearing
89
+ * one account's observations would silently clear a neighbour's.
90
+ */
91
+ export function buildQuotaScheduleAccountPrefix(account) {
84
92
  const emailKey = normalizeEmailKey(account.email);
85
93
  const accountId = account.accountId?.trim();
86
94
  const refreshToken = account.refreshToken?.trim() ?? "";
@@ -97,7 +105,10 @@ export function buildQuotaScheduleKey(account, family, model) {
97
105
  : accountId
98
106
  ? `id:${accountId}${recordDiscriminator}`
99
107
  : `refresh:${createHash("sha256").update(refreshToken).digest("hex")}${recordDiscriminator}`;
100
- return `account:${accountIdentity}:${model ?? family}`;
108
+ return `account:${accountIdentity}:`;
109
+ }
110
+ export function buildQuotaScheduleKey(account, family, model) {
111
+ return `${buildQuotaScheduleAccountPrefix(account)}${model ?? family}`;
101
112
  }
102
113
  const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
103
114
  // This is a hard safety ceiling over every pinned selection pass, including
@@ -133,6 +144,58 @@ function pinnedRetryBackoffMs(attempt) {
133
144
  const index = Math.min(Math.max(0, attempt - 1), PINNED_RETRY_BACKOFF_STEPS_MS.length - 1);
134
145
  return PINNED_RETRY_BACKOFF_STEPS_MS[index] ?? 0;
135
146
  }
147
+ /**
148
+ * Backoff schedule for a model-capacity wait (issue #689).
149
+ *
150
+ * Deliberately much longer than `PINNED_RETRY_BACKOFF_STEPS_MS`. That schedule
151
+ * spaces out re-sends of a request whose account might already be fine;
152
+ * capacity pressure is upstream and measured in minutes, so re-sending every
153
+ * 250ms would just be a tight poll against a busy backend. The last step
154
+ * repeats for every attempt past the table.
155
+ */
156
+ const CAPACITY_RETRY_BACKOFF_STEPS_MS = [
157
+ 2_000, 5_000, 15_000, 30_000, 60_000,
158
+ ];
159
+ /**
160
+ * Default wall-clock ceiling on capacity waiting for ONE request.
161
+ *
162
+ * The reporter's case is a long-running task started before stepping away, so
163
+ * the default has to be long enough to outlast a real capacity blip. It is
164
+ * still a hard ceiling: the request ends with the normal pool-exhausted 503
165
+ * rather than hanging forever.
166
+ */
167
+ const DEFAULT_MODEL_CAPACITY_RETRY_MS = 10 * 60_000;
168
+ const MAX_MODEL_CAPACITY_RETRY_MS = 60 * 60_000;
169
+ const MODEL_CAPACITY_RETRY_ENV = "CODEX_MULTI_AUTH_MODEL_CAPACITY_RETRY_MS";
170
+ function capacityRetryBackoffMs(attempt) {
171
+ const index = Math.min(Math.max(0, attempt - 1), CAPACITY_RETRY_BACKOFF_STEPS_MS.length - 1);
172
+ return CAPACITY_RETRY_BACKOFF_STEPS_MS[index] ?? 0;
173
+ }
174
+ /**
175
+ * Total time one request may spend waiting out model capacity.
176
+ *
177
+ * `0` disables the behaviour entirely and restores the pre-#689 handling, where
178
+ * a capacity response rotates the pool and 503s. Anything unparseable falls
179
+ * back to the default rather than disabling, so a typo does not silently turn
180
+ * the feature off.
181
+ */
182
+ export function normalizeModelCapacityRetryMs(value) {
183
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
184
+ return DEFAULT_MODEL_CAPACITY_RETRY_MS;
185
+ }
186
+ return Math.min(Math.floor(value), MAX_MODEL_CAPACITY_RETRY_MS);
187
+ }
188
+ export function resolveModelCapacityRetryMs(env = process.env) {
189
+ const raw = (env[MODEL_CAPACITY_RETRY_ENV] ?? "").trim();
190
+ if (!raw)
191
+ return DEFAULT_MODEL_CAPACITY_RETRY_MS;
192
+ // An explicit 0 is the documented kill switch and must survive normalization,
193
+ // which otherwise treats "not a usable number" as "use the default".
194
+ const parsed = Number(raw);
195
+ if (parsed === 0)
196
+ return 0;
197
+ return normalizeModelCapacityRetryMs(parsed);
198
+ }
136
199
  const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
137
200
  const MAX_THREAD_GOAL_FALLBACKS = 512;
138
201
  const ALLOWED_RESPONSES_PATHS = new Set([
@@ -597,6 +660,14 @@ export async function startRuntimeRotationProxy(options) {
597
660
  const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
598
661
  const networkErrorCooldownMs = getNetworkErrorCooldownMs(pluginConfig);
599
662
  const serverErrorCooldownMs = getServerErrorCooldownMs(pluginConfig);
663
+ // Normalize the explicit option as well as the env var: a caller passing a
664
+ // negative or multi-hour value would otherwise bypass the documented cap.
665
+ // `0` stays 0, since it is the documented kill switch.
666
+ const modelCapacityRetryMs = options.modelCapacityRetryMs === 0
667
+ ? 0
668
+ : options.modelCapacityRetryMs === undefined
669
+ ? resolveModelCapacityRetryMs()
670
+ : normalizeModelCapacityRetryMs(options.modelCapacityRetryMs);
600
671
  const tokenInvalidationCooldownMs = getTokenInvalidationCooldownMs(pluginConfig);
601
672
  const minRotationIntervalMs = getMinRotationIntervalMs(pluginConfig);
602
673
  const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
@@ -643,6 +714,7 @@ export async function startRuntimeRotationProxy(options) {
643
714
  tokenRefreshSkewMs,
644
715
  networkErrorCooldownMs,
645
716
  serverErrorCooldownMs,
717
+ modelCapacityRetryMs,
646
718
  tokenInvalidationCooldownMs,
647
719
  minRotationIntervalMs,
648
720
  pidOffsetEnabled,
@@ -852,6 +924,24 @@ async function handleRequestInner(state, req, res, traceId) {
852
924
  let transientAttemptLimit = Math.max(1, Math.min(accountCount, state.maxRuntimeAccountAttempts));
853
925
  let transientAttempts = 0;
854
926
  let transientExhaustionReason = null;
927
+ // Capacity waits are counted separately from `transientAttempts` and from
928
+ // the pinned selection cap. Both of those bound how many DIFFERENT
929
+ // accounts or re-sends a failing request may burn; a capacity wait is not
930
+ // a failure of any account, it is the same request pausing for an
931
+ // upstream that is busy, and it has its own wall-clock ceiling. See #689.
932
+ let capacityRetries = 0;
933
+ // A wall-clock deadline, not a sum of sleeps. The upstream request and
934
+ // the error-body read also consume the budget, so summing only the
935
+ // planned sleeps let a slow capacity response push one request past the
936
+ // documented ceiling. Request-local, so concurrent requests never share
937
+ // a deadline.
938
+ let capacityDeadlineAt = null;
939
+ // Only the RESPONSE tells us the caller is still there. `req` is an
940
+ // IncomingMessage whose stream Node destroys once the request body has
941
+ // been fully read, which this proxy does up front, so `req.destroyed` is
942
+ // routinely true on a perfectly healthy request and must not be read as a
943
+ // disconnect.
944
+ const clientGone = () => res.destroyed || res.writableEnded;
855
945
  const accountSkipReasons = new Map();
856
946
  let reloadedAfterNoAccount = false;
857
947
  // Read the manual pin and affinity generation from disk (mtime-cached)
@@ -862,7 +952,37 @@ async function handleRequestInner(state, req, res, traceId) {
862
952
  // otherwise glue an in-flight chat thread to the previously selected
863
953
  // account. The proxy itself never bumps the generation, so its own
864
954
  // debounced disk writes do not clear affinity. See issue #474.
865
- const storageMeta = readStorageMetaFromDisk();
955
+ /** Reconcile once before selection or recording a new upstream observation. */
956
+ const reconcileManualSelection = () => {
957
+ const meta = readStorageMetaFromDisk();
958
+ for (const manager of state.knownAccountManagers) {
959
+ manager.applyManualSelection(meta);
960
+ }
961
+ if (meta.affinityGeneration > state.lastObservedAffinityGeneration) {
962
+ const switchedAccount = meta.pinnedAccountIndex === null
963
+ ? null
964
+ : accountManager.getAccountByIndex(meta.pinnedAccountIndex);
965
+ if (switchedAccount) {
966
+ state.preemptiveQuotaScheduler.clearByPrefix(buildQuotaScheduleAccountPrefix(switchedAccount));
967
+ }
968
+ else if (meta.pinnedAccountIndex !== null) {
969
+ // A pin we cannot resolve: this long-lived proxy re-reads only
970
+ // pin/gen, never the account list, so a `login` that appended an
971
+ // account before the `switch` leaves the new index out of range
972
+ // here. The generation only bumps on the NEXT user-initiated
973
+ // switch, so skipping the clear and advancing anyway would strand
974
+ // the pre-switch quota observation forever, which is the exact
975
+ // deferral this reconciliation exists to end. Drop every cached
976
+ // observation instead: it only costs a re-probe, and real 429
977
+ // windows live on the accounts themselves, not in this cache.
978
+ state.preemptiveQuotaScheduler.clearAll();
979
+ }
980
+ state.sessionAffinityStore?.clearAll();
981
+ state.lastObservedAffinityGeneration = meta.affinityGeneration;
982
+ }
983
+ return meta;
984
+ };
985
+ const storageMeta = reconcileManualSelection();
866
986
  // The ephemeral --account pin (issue #623) takes precedence over the
867
987
  // persisted `switch` pin for this invocation, without ever mutating disk
868
988
  // state. Use `??` (not `||`) so a forced index of 0 is honored. When set,
@@ -899,15 +1019,79 @@ async function handleRequestInner(state, req, res, traceId) {
899
1019
  if (isPinned) {
900
1020
  transientAttemptLimit = Math.max(1, Math.min(state.maxRuntimeAccountAttempts, MAX_PINNED_TRANSIENT_ATTEMPTS));
901
1021
  }
902
- if (storageMeta.affinityGeneration > state.lastObservedAffinityGeneration) {
903
- state.sessionAffinityStore?.clearAll();
904
- state.lastObservedAffinityGeneration = storageMeta.affinityGeneration;
905
- }
1022
+ /**
1023
+ * Wait out a "selected model is at capacity" response, then let the loop
1024
+ * re-send the request (issue #689).
1025
+ *
1026
+ * Capacity is a property of the MODEL, not of an account, so the normal
1027
+ * handling is actively wrong for it: rotating spends the pool's transient
1028
+ * budget against accounts that will all fail identically, and the request
1029
+ * ends as a pool-exhausted 503 within seconds. That is what kills a
1030
+ * long-running task started before the user stepped away.
1031
+ *
1032
+ * The responding account is left completely unpenalized: its pool token is
1033
+ * refunded, it is not marked rate limited, not cooled down, and it is
1034
+ * removed from `attemptedIndexes` so it stays selectable. Selection then
1035
+ * runs normally on the next pass, which may pick a different account. That
1036
+ * is deliberate: with capacity being model-wide, no account is a better bet,
1037
+ * and forcing a request-local pin here would duplicate the pinning path for
1038
+ * no gain.
1039
+ *
1040
+ * @param retryAfterMs Upstream hint when one was actually sent, else `null`
1041
+ * so the backoff table is used. Do NOT pass a synthesized default.
1042
+ * @param account Account that just saw the capacity response.
1043
+ * @returns `"retry"` to re-send, `"give-up"` to fall through to normal
1044
+ * handling, `"client-gone"` when the caller must abandon the request.
1045
+ */
1046
+ const waitOutModelCapacity = async (retryAfterMs, account) => {
1047
+ const budgetMs = state.modelCapacityRetryMs;
1048
+ if (!Number.isFinite(budgetMs) || budgetMs <= 0)
1049
+ return "give-up";
1050
+ if (capacityDeadlineAt === null) {
1051
+ capacityDeadlineAt = state.now() + budgetMs;
1052
+ }
1053
+ const remainingMs = capacityDeadlineAt - state.now();
1054
+ if (remainingMs <= 0)
1055
+ return "give-up";
1056
+ if (clientGone())
1057
+ return "client-gone";
1058
+ const hinted = retryAfterMs !== null &&
1059
+ Number.isFinite(retryAfterMs) &&
1060
+ retryAfterMs > 0
1061
+ ? retryAfterMs
1062
+ : capacityRetryBackoffMs(capacityRetries + 1);
1063
+ const waitMs = Math.min(Math.max(0, Math.floor(hinted)), remainingMs);
1064
+ if (waitMs <= 0)
1065
+ return "give-up";
1066
+ capacityRetries += 1;
1067
+ state.status.retries += 1;
1068
+ proxyLog.warn("model at capacity; waiting before re-sending", {
1069
+ traceId,
1070
+ waitMs,
1071
+ attempt: capacityRetries,
1072
+ remainingMs,
1073
+ budgetMs,
1074
+ });
1075
+ // The attempt debited a pool token before the upstream call. The account
1076
+ // did not fail, so refund it: without this a sustained capacity event
1077
+ // drains healthy accounts and later requests are refused admission with
1078
+ // `token-exhausted` well before the retry budget expires.
1079
+ refundConsumedPoolToken(account);
1080
+ attemptedIndexes.delete(account.index);
1081
+ await sleep(waitMs);
1082
+ // A capacity wait runs for tens of seconds. Re-sending an authenticated
1083
+ // upstream request for a response nobody is reading wastes upstream
1084
+ // capacity and extends how long the token is in flight.
1085
+ if (clientGone())
1086
+ return "client-gone";
1087
+ return "retry";
1088
+ };
906
1089
  let runtimeSelectionIterations = 0;
907
1090
  while ((isPinned || attemptedIndexes.size < accountCount) &&
908
1091
  transientAttempts < transientAttemptLimit &&
909
1092
  (!isPinned ||
910
- runtimeSelectionIterations < MAX_PINNED_SELECTION_ITERATIONS)) {
1093
+ runtimeSelectionIterations - capacityRetries <
1094
+ MAX_PINNED_SELECTION_ITERATIONS)) {
911
1095
  // Space out same-account re-sends. The loop has no other delay in it,
912
1096
  // and the pinned retry deliberately waives the account's own cooldown
913
1097
  // (below), so without this two copies of the same non-idempotent
@@ -1153,15 +1337,39 @@ async function handleRequestInner(state, req, res, traceId) {
1153
1337
  noteRotation();
1154
1338
  continue;
1155
1339
  }
1340
+ reconcileManualSelection();
1156
1341
  const quotaSnapshot = readQuotaSchedulerSnapshot(upstream.headers, upstream.status, state.now());
1157
1342
  if (quotaSnapshot) {
1158
1343
  state.preemptiveQuotaScheduler.update(quotaScheduleKey, quotaSnapshot);
1159
1344
  }
1160
1345
  if (upstream.status === HTTP_STATUS.TOO_MANY_REQUESTS) {
1161
1346
  const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
1162
- const retryAfterMs = parseRetryAfterHeaderMs(upstream.headers, state.now()) ??
1163
- parseRetryAfterBodyMs(bodyText, state.now()) ??
1164
- 60_000;
1347
+ // Keep the upstream HINT separate from the 60s fallback. Passing the
1348
+ // synthesized default into the capacity wait would make every
1349
+ // hint-less capacity 429 sleep a full minute and leave the 2s/5s/15s
1350
+ // backoff table dead on this path.
1351
+ const retryAfterHintMs = parseRetryAfterHeaderMs(upstream.headers, state.now()) ??
1352
+ parseRetryAfterBodyMs(bodyText, state.now());
1353
+ const retryAfterMs = retryAfterHintMs ?? 60_000;
1354
+ // Reading the body awaited I/O; a switch may have landed meanwhile.
1355
+ reconcileManualSelection();
1356
+ // A capacity 429 is not this account's quota. Marking it rate
1357
+ // limited would take a healthy account out of the pool for the
1358
+ // retry-after window on an outage that affects every account.
1359
+ if (isModelAtCapacityError(upstream.status, bodyText)) {
1360
+ const outcome = await waitOutModelCapacity(retryAfterHintMs, refreshed.account);
1361
+ if (outcome === "client-gone") {
1362
+ await usageRecorder.record({
1363
+ outcome: "failure",
1364
+ statusCode: upstream.status,
1365
+ errorCode: "client_disconnected_during_capacity_wait",
1366
+ account: refreshed.account,
1367
+ });
1368
+ return;
1369
+ }
1370
+ if (outcome === "retry")
1371
+ continue;
1372
+ }
1165
1373
  state.preemptiveQuotaScheduler.markRateLimited(quotaScheduleKey, retryAfterMs, state.now());
1166
1374
  // A 429 is the upstream quota signal for the attempted account, so
1167
1375
  // keep the consumed runtime token drained.
@@ -1300,7 +1508,23 @@ async function handleRequestInner(state, req, res, traceId) {
1300
1508
  continue;
1301
1509
  }
1302
1510
  if (upstream.status >= 500) {
1303
- await readErrorBody(upstream, state.streamStallTimeoutMs);
1511
+ const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
1512
+ // A capacity 5xx is upstream load, not a broken account, so it
1513
+ // must not cool the account down or count against the pool.
1514
+ if (isModelAtCapacityError(upstream.status, bodyText)) {
1515
+ const outcome = await waitOutModelCapacity(parseRetryAfterHeaderMs(upstream.headers, state.now()), refreshed.account);
1516
+ if (outcome === "client-gone") {
1517
+ await usageRecorder.record({
1518
+ outcome: "failure",
1519
+ statusCode: upstream.status,
1520
+ errorCode: "client_disconnected_during_capacity_wait",
1521
+ account: refreshed.account,
1522
+ });
1523
+ return;
1524
+ }
1525
+ if (outcome === "retry")
1526
+ continue;
1527
+ }
1304
1528
  refundConsumedPoolToken(refreshed.account);
1305
1529
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1306
1530
  accountManager.markAccountCoolingDown(refreshed.account, state.serverErrorCooldownMs, "server-error");
@@ -109,9 +109,27 @@ export declare function bumpStorageAffinityGeneration(storage: {
109
109
  * persisting routine state (rate-limit hits, cooldowns, etc.) so a CLI
110
110
  * `switch`/`unpin` that landed between proxy startup and the save is not
111
111
  * clobbered. Returns `pinnedAccountIndex: undefined` and
112
- * `affinityGeneration: 0` on any failure. See issue #474.
112
+ * `affinityGeneration: 0` on any failure by default. See issue #474.
113
+ *
114
+ * `strict` callers additionally need to know when the metadata could not be
115
+ * OBSERVED AT ALL, because persisting a stale in-memory pin over a newer disk
116
+ * pin is exactly the #474 regression. Strict mode therefore throws for the two
117
+ * cases where a newer selection may be hiding behind the failure:
118
+ * - the file exists but cannot be read (EBUSY/EPERM/EACCES: on Windows an AV
119
+ * scanner or indexer can hold accounts.json open), and
120
+ * - the bytes are not valid JSON (a torn read of a concurrent atomic write).
121
+ *
122
+ * A MISSING file and a successfully parsed file with INVALID field values are
123
+ * deliberately not failures, in either mode. Neither can be concealing a newer
124
+ * pin (there is no file, or we can see the whole file and the field is
125
+ * unusable), and both are self-repaired by the very write that follows. Making
126
+ * them throw would wedge persistence permanently, since recreating or repairing
127
+ * storage goes through the same save path, and it would take every rate-limit
128
+ * window, cooldown and rotated refresh token in memory down with it.
113
129
  */
114
- export declare function readPinAndGenFromDisk(path: string): {
130
+ export declare function readPinAndGenFromDisk(path: string, options?: {
131
+ strict?: boolean;
132
+ }): {
115
133
  pinnedAccountIndex: number | undefined;
116
134
  affinityGeneration: number;
117
135
  };
@@ -1 +1 @@
1
- {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../lib/storage.ts"],"names":[],"mappings":"AA+BA,OAAO,EACN,KAAK,cAAc,EAGnB,KAAK,iBAAiB,EAEtB,MAAM,8BAA8B,CAAC;AAkBtC,OAAO,EAEN,KAAK,oBAAoB,EACzB,MAAM,qBAAqB,CAAC;AAe7B,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EACN,sBAAsB,EACtB,cAAc,GACd,MAAM,0BAA0B,CAAC;AAClC,OAAO,EACN,qBAAqB,EACrB,iBAAiB,GACjB,MAAM,uBAAuB,CAAC;AAa/B,OAAO,KAAK,EACX,iBAAiB,EACjB,gBAAgB,EAChB,cAAc,EACd,wBAAwB,EACxB,uBAAuB,EACvB,gBAAgB,EAChB,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EAEN,KAAK,kBAAkB,EACvB,MAAM,4BAA4B,CAAC;AA2CpC,YAAY,EACX,oBAAoB,EACpB,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,gBAAgB,EAChB,wBAAwB,EACxB,uBAAuB,EACvB,cAAc,EACd,iBAAiB,EACjB,kBAAkB,GAClB,CAAC;AAcF,KAAK,WAAW,GAAG;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAiBF,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAE9D;AAuWD,wBAAgB,4BAA4B,IAAI,MAAM,CAErD;AAED,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CA4C/D;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAO9D;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAMvC;AAED,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,wBAAsB,eAAe,IAAI,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAarE;AAED,wBAAsB,yBAAyB,CAC9C,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAC7B,OAAO,CAAC,gBAAgB,CAAC,CAe3B;AAED,wBAAsB,iBAAiB,CACtC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAC3B,OAAO,CAAC,MAAM,CAAC,CAQjB;AAED,wBAAgB,sBAAsB,IAAI,MAAM,CAE/C;AA6LD,KAAK,mBAAmB,GAAG;IAC1B,wCAAwC,CAAC,EAAE,OAAO,CAAC;CACnD,CAAC;AA8IF,wBAAgB,wBAAwB,CACvC,CAAC,SAAS,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,EAEnE,QAAQ,EAAE,SAAS,CAAC,EAAE,EACtB,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,EACpE,OAAO,GAAE,mBAAwB,GAC/B,MAAM,GAAG,SAAS,CAkBpB;AAED;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,aAAa,EACV,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GACzD,SAAS,EACZ,YAAY,EAAE,SAAS,IAAI,CAC1B,WAAW,EACX,WAAW,GAAG,OAAO,GAAG,cAAc,CACtC,EAAE,GACD,MAAM,GAAG,SAAS,CAGpB;AAED,wBAAgB,4BAA4B,CAC3C,CAAC,SAAS,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,EAEnE,QAAQ,EAAE,SAAS,CAAC,EAAE,EACtB,SAAS,EAAE,IAAI,CAAC,WAAW,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,EACpE,aAAa,SAAI,GACf,MAAM,CAOR;AAsCD;;;GAGG;AACH,wBAAgB,mBAAmB,CAClC,CAAC,SAAS;IACT,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,EACA,QAAQ,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAEpB;AAED,wBAAgB,0BAA0B,CACzC,CAAC,SAAS;IACT,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB,EACA,QAAQ,EAAE,CAAC,EAAE,GAAG,CAAC,EAAE,CAEpB;AAoBD;;;;;GAKG;AACH,wBAAgB,uBAAuB,CACtC,IAAI,EAAE,OAAO,GACX,gBAAgB,GAAG,IAAI,CAoJzB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,8BAA8B,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAoBnE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE;IAAE,kBAAkB,CAAC,EAAE,MAAM,CAAA;CAAE,EACxC,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,GACzB,MAAM,CAaR;AAED;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG;IACpD,kBAAkB,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,kBAAkB,EAAE,MAAM,CAAC;CAC3B,CA8BA;AAED;;;;GAIG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAErE;AAED,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,cAAc,CAAC,CAajE;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,iBAAiB,CAAC,CASvE;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,oBAAoB,CAAC,CAiG1E;AAyaD,wBAAsB,6BAA6B,CAAC,CAAC,EACpD,OAAO,EAAE,CACR,OAAO,EAAE,gBAAgB,GAAG,IAAI,EAChC,OAAO,EAAE,CAAC,OAAO,EAAE,gBAAgB,KAAK,OAAO,CAAC,IAAI,CAAC,KACjD,OAAO,CAAC,CAAC,CAAC,GACb,OAAO,CAAC,CAAC,CAAC,CAMZ;AAED,wBAAsB,uCAAuC,CAAC,CAAC,EAC9D,OAAO,EAAE,CACR,OAAO,EAAE,gBAAgB,GAAG,IAAI,EAChC,OAAO,EAAE,CACR,cAAc,EAAE,gBAAgB,EAChC,cAAc,EAAE,uBAAuB,KACnC,OAAO,CAAC,IAAI,CAAC,EAClB,cAAc,EAAE,uBAAuB,KACnC,OAAO,CAAC,CAAC,CAAC,GACb,OAAO,CAAC,CAAC,CAAC,CAgDZ;AAED,wBAAsB,6BAA6B,CAAC,CAAC,EACpD,OAAO,EAAE,CACR,OAAO,EAAE,uBAAuB,EAChC,OAAO,EAAE,CAAC,OAAO,EAAE,uBAAuB,KAAK,OAAO,CAAC,IAAI,CAAC,KACxD,OAAO,CAAC,CAAC,CAAC,GACb,OAAO,CAAC,CAAC,CAAC,CAiCZ;AAED;;;;;;GAMG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAM3E;AAED;;;GAGG;AACH,wBAAsB,aAAa,IAAI,OAAO,CAAC,IAAI,CAAC,CAcnD;AAED,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,uBAAuB,CAAC,CAgD5E;AAyBD,wBAAsB,mBAAmB,CACxC,OAAO,EAAE,uBAAuB,GAC9B,OAAO,CAAC,IAAI,CAAC,CAMf;AAED,wBAAsB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAa1D;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CACnC,QAAQ,EAAE,MAAM,EAChB,KAAK,UAAO,EACZ,YAAY,CAAC,EAAE,CAAC,YAAY,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,GAC3D,OAAO,CAAC,IAAI,CAAC,CAgBf;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CACnC,QAAQ,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CAe/D"}
1
+ {"version":3,"file":"storage.d.ts","sourceRoot":"","sources":["../../lib/storage.ts"],"names":[],"mappings":"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"}
@@ -898,7 +898,8 @@ export function normalizeAccountStorage(data) {
898
898
  return undefined;
899
899
  if (typeof raw !== "number" ||
900
900
  !Number.isFinite(raw) ||
901
- !Number.isInteger(raw) ||
901
+ // See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
902
+ !Number.isSafeInteger(raw) ||
902
903
  raw < 0) {
903
904
  log.warn("Dropping invalid affinityGeneration from storage", {
904
905
  value: raw,
@@ -941,7 +942,11 @@ export function readAffinityGenerationFromDisk(path) {
941
942
  const generation = parsed.affinityGeneration;
942
943
  if (typeof generation === "number" &&
943
944
  Number.isFinite(generation) &&
944
- Number.isInteger(generation) &&
945
+ // Safe, not merely integral: past 2^53 `Math.max(gen, disk) + 1 === gen`,
946
+ // so `bumpStorageAffinityGeneration` silently stops advancing and no
947
+ // running proxy ever observes another `switch`. Rejecting the value
948
+ // lets the next bump write 1 and repair the file.
949
+ Number.isSafeInteger(generation) &&
945
950
  generation >= 0) {
946
951
  return generation;
947
952
  }
@@ -978,7 +983,12 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
978
983
  // No resolvable storage path (never configured). The in-memory counter
979
984
  // is still bumped below; only the lost-update guard is unavailable.
980
985
  }
981
- const inMemoryGeneration = storage.affinityGeneration ?? 0;
986
+ // Guard the in-memory side too, not just the disk read. Past 2^53
987
+ // `Math.max(gen, disk) + 1 === gen`, so an unsafe value here would write the
988
+ // same generation forever and no running proxy would observe another
989
+ // `switch`. Treating it as 0 makes the next write repair the counter.
990
+ const rawInMemory = storage.affinityGeneration ?? 0;
991
+ const inMemoryGeneration = Number.isSafeInteger(rawInMemory) && rawInMemory >= 0 ? rawInMemory : 0;
982
992
  const next = Math.max(inMemoryGeneration, diskGeneration) + 1;
983
993
  storage.affinityGeneration = next;
984
994
  return next;
@@ -990,34 +1000,68 @@ export function bumpStorageAffinityGeneration(storage, storagePath) {
990
1000
  * persisting routine state (rate-limit hits, cooldowns, etc.) so a CLI
991
1001
  * `switch`/`unpin` that landed between proxy startup and the save is not
992
1002
  * clobbered. Returns `pinnedAccountIndex: undefined` and
993
- * `affinityGeneration: 0` on any failure. See issue #474.
1003
+ * `affinityGeneration: 0` on any failure by default. See issue #474.
1004
+ *
1005
+ * `strict` callers additionally need to know when the metadata could not be
1006
+ * OBSERVED AT ALL, because persisting a stale in-memory pin over a newer disk
1007
+ * pin is exactly the #474 regression. Strict mode therefore throws for the two
1008
+ * cases where a newer selection may be hiding behind the failure:
1009
+ * - the file exists but cannot be read (EBUSY/EPERM/EACCES: on Windows an AV
1010
+ * scanner or indexer can hold accounts.json open), and
1011
+ * - the bytes are not valid JSON (a torn read of a concurrent atomic write).
1012
+ *
1013
+ * A MISSING file and a successfully parsed file with INVALID field values are
1014
+ * deliberately not failures, in either mode. Neither can be concealing a newer
1015
+ * pin (there is no file, or we can see the whole file and the field is
1016
+ * unusable), and both are self-repaired by the very write that follows. Making
1017
+ * them throw would wedge persistence permanently, since recreating or repairing
1018
+ * storage goes through the same save path, and it would take every rate-limit
1019
+ * window, cooldown and rotated refresh token in memory down with it.
994
1020
  */
995
- export function readPinAndGenFromDisk(path) {
1021
+ export function readPinAndGenFromDisk(path, options) {
1022
+ const unselected = {
1023
+ pinnedAccountIndex: undefined,
1024
+ affinityGeneration: 0,
1025
+ };
996
1026
  if (!existsSync(path)) {
997
- return { pinnedAccountIndex: undefined, affinityGeneration: 0 };
1027
+ return { ...unselected };
998
1028
  }
1029
+ let parsed;
999
1030
  try {
1000
- const bytes = readFileSync(path);
1001
- const parsed = JSON.parse(bytes.toString("utf8"));
1002
- const rawPin = parsed.pinnedAccountIndex;
1003
- const pinnedAccountIndex = typeof rawPin === "number" &&
1004
- Number.isFinite(rawPin) &&
1005
- Number.isInteger(rawPin) &&
1006
- rawPin >= 0
1007
- ? rawPin
1008
- : undefined;
1009
- const rawGen = parsed.affinityGeneration;
1010
- const affinityGeneration = typeof rawGen === "number" &&
1011
- Number.isFinite(rawGen) &&
1012
- Number.isInteger(rawGen) &&
1013
- rawGen >= 0
1014
- ? rawGen
1015
- : 0;
1016
- return { pinnedAccountIndex, affinityGeneration };
1031
+ parsed = JSON.parse(readFileSync(path, "utf8"));
1017
1032
  }
1018
- catch {
1019
- return { pinnedAccountIndex: undefined, affinityGeneration: 0 };
1033
+ catch (error) {
1034
+ // The file was deleted or renamed between existsSync and the read; same
1035
+ // reasoning as the missing-file branch above.
1036
+ if (error.code === "ENOENT") {
1037
+ return { ...unselected };
1038
+ }
1039
+ if (options?.strict) {
1040
+ throw new Error("Unable to read account selection metadata", {
1041
+ cause: error,
1042
+ });
1043
+ }
1044
+ return { ...unselected };
1045
+ }
1046
+ if (!isRecord(parsed)) {
1047
+ return { ...unselected };
1020
1048
  }
1049
+ const rawPin = parsed.pinnedAccountIndex;
1050
+ const pinnedAccountIndex = typeof rawPin === "number" &&
1051
+ Number.isFinite(rawPin) &&
1052
+ Number.isInteger(rawPin) &&
1053
+ rawPin >= 0
1054
+ ? rawPin
1055
+ : undefined;
1056
+ const rawGen = parsed.affinityGeneration;
1057
+ const affinityGeneration = typeof rawGen === "number" &&
1058
+ Number.isFinite(rawGen) &&
1059
+ // See readAffinityGenerationFromDisk: an unsafe integer wedges the bump.
1060
+ Number.isSafeInteger(rawGen) &&
1061
+ rawGen >= 0
1062
+ ? rawGen
1063
+ : 0;
1064
+ return { pinnedAccountIndex, affinityGeneration };
1021
1065
  }
1022
1066
  /**
1023
1067
  * Loads OAuth accounts from disk storage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.12.0",
3
+ "version": "2.14.0",
4
4
  "description": "Codex CLI multi-account OAuth manager with account switching, health checks, runtime rotation, diagnostics, and recovery tools for @openai/codex",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -2,6 +2,7 @@ const AUTH_SUBCOMMANDS = new Set([
2
2
  "login",
3
3
  "list",
4
4
  "status",
5
+ "limits",
5
6
  "switch",
6
7
  "unpin",
7
8
  "workspace",