codex-multi-auth 2.8.5 → 2.8.7

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.
@@ -7,7 +7,74 @@ export declare function resolveActiveIndex(storage: {
7
7
  export declare function getRateLimitResetTimeForFamily(account: {
8
8
  rateLimitResetTimes?: Record<string, number | undefined>;
9
9
  }, now: number, family: ModelFamily): number | null;
10
+ export interface AccountRecoveryBounds {
11
+ /**
12
+ * The moment the account becomes usable again: `rateLimitAtMs` widened by
13
+ * any active cooldown, because the account stays skipped while ANY gating
14
+ * record is active. Null when nothing bounds recovery.
15
+ */
16
+ recoveryAtMs: number | null;
17
+ /**
18
+ * The rate-limit records' own contribution to `recoveryAtMs`, cooldowns
19
+ * excluded. The pinned-503 uses it to decide whether the deadline it
20
+ * advertises is in fact the rate limit's own reset — with a breaker or
21
+ * cooldown ending later, the full recovery bound outlives the rate limit
22
+ * and must not be worded as its reset. Null when no rate-limit record
23
+ * gates the request.
24
+ */
25
+ rateLimitAtMs: number | null;
26
+ }
27
+ /**
28
+ * Both recovery bounds for a `family`/`model` request, from ONE pass over
29
+ * `rateLimitResetTimes` against a single `now`.
30
+ *
31
+ * Two deliberate differences from getRateLimitResetTimeForFamily, whose
32
+ * earliest-reset answer feeds wait displays: the latest bound wins, because
33
+ * the earliest reset would send clients back into a 503 — and only the keys
34
+ * selection consults (`family`, plus `family:<model>` when a model is known;
35
+ * see isRateLimitedForFamily) may contribute, because another model's record
36
+ * does not block this request and would overstate its recovery.
37
+ *
38
+ * A caller that needs both bounds must take them from one call. Measuring
39
+ * them separately lets a record expire between the two walks, which reports
40
+ * a rate-limited account as if a cooldown bounded its recovery.
41
+ */
42
+ export declare function getAccountRecoveryBoundsForFamily(account: {
43
+ rateLimitResetTimes?: Record<string, number | undefined>;
44
+ coolingDownUntil?: number;
45
+ }, now: number, family: ModelFamily, model?: string | null): AccountRecoveryBounds;
46
+ /** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
47
+ export declare function getAccountRecoveryTimeForFamily(account: {
48
+ rateLimitResetTimes?: Record<string, number | undefined>;
49
+ coolingDownUntil?: number;
50
+ }, now: number, family: ModelFamily, model?: string | null): number | null;
51
+ /** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
52
+ export declare function getRateLimitRecoveryTimeForFamily(account: {
53
+ rateLimitResetTimes?: Record<string, number | undefined>;
54
+ }, now: number, family: ModelFamily, model?: string | null): number | null;
10
55
  export declare function formatRateLimitEntry(account: {
11
56
  rateLimitResetTimes?: Record<string, number | undefined>;
12
57
  }, now: number, formatWaitTime: (ms: number) => string, family?: ModelFamily): string | null;
58
+ /**
59
+ * When a request for `family`/`model` stops being rate limited: the LATEST
60
+ * active bound among exactly the two keys selection consults — the family-wide
61
+ * key and `family:<model>` (see `isRateLimitedForFamily`). Null when neither is
62
+ * active.
63
+ *
64
+ * Deliberately narrower and later than `getRateLimitResetTimeForFamily`, whose
65
+ * earliest-reset-across-every-`family:*`-key answer feeds wait displays and
66
+ * model-less callers:
67
+ *
68
+ * - narrower, because `markRateLimitedWithReason` keys token/concurrency limits
69
+ * under `family:<model>`, and a sibling model's record does not gate this
70
+ * request — folding it in reports a delay the runtime proxy would not impose;
71
+ * - later, because the account stays skipped while EITHER key is active, so the
72
+ * earliest reset understates the wait when both are set.
73
+ *
74
+ * Requires a model by construction: a caller without one cannot know which
75
+ * model key applies and should keep the family-wide union above.
76
+ */
77
+ export declare function getRateLimitResetTimeForModel(account: {
78
+ rateLimitResetTimes?: Record<string, number | undefined>;
79
+ }, now: number, family: ModelFamily, model: string): number | null;
13
80
  //# sourceMappingURL=account-status.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAED,wBAAgB,oBAAoB,CACnC,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,EACtC,MAAM,GAAE,WAAqB,GAC3B,MAAM,GAAG,IAAI,CAMf"}
1
+ {"version":3,"file":"account-status.d.ts","sourceRoot":"","sources":["../../../lib/runtime/account-status.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,wBAAgB,kBAAkB,CACjC,OAAO,EAAE;IACR,WAAW,EAAE,MAAM,CAAC;IACpB,mBAAmB,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC,CAAC;IAC3D,QAAQ,EAAE,OAAO,EAAE,CAAC;CACpB,EACD,MAAM,GAAE,WAAqB,GAC3B,MAAM,CAOR;AAED,wBAAgB,8BAA8B,CAC7C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,GACjB,MAAM,GAAG,IAAI,CAgBf;AAeD,MAAM,WAAW,qBAAqB;IACrC;;;;OAIG;IACH,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,qBAAqB,CAevB;AAED,6EAA6E;AAC7E,wBAAgB,+BAA+B,CAC9C,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IACzD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC1B,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,8EAA8E;AAC9E,wBAAgB,iCAAiC,CAChD,OAAO,EAAE;IACR,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;CACzD,EACD,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,GAAG,IAAI,CAGf;AAED,wBAAgB,oBAAoB,CACnC,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,cAAc,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,MAAM,EACtC,MAAM,GAAE,WAAqB,GAC3B,MAAM,GAAG,IAAI,CAMf;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE;IAAE,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;CAAE,EACrE,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,WAAW,EACnB,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,IAAI,CAGf"}
@@ -1,3 +1,4 @@
1
+ import { getQuotaKey } from "../accounts/rate-limits.js";
1
2
  export function resolveActiveIndex(storage, family = "codex") {
2
3
  const total = storage.accounts.length;
3
4
  if (total === 0)
@@ -25,6 +26,55 @@ export function getRateLimitResetTimeForFamily(account, now, family) {
25
26
  }
26
27
  return minReset;
27
28
  }
29
+ /** The value when it is still a live bound at `now`, otherwise null. */
30
+ function activeBound(value, now) {
31
+ if (typeof value !== "number" || !Number.isFinite(value))
32
+ return null;
33
+ return value > now ? value : null;
34
+ }
35
+ /** The later of two bounds, treating null as "does not bound". */
36
+ function laterBound(a, b) {
37
+ if (a === null)
38
+ return b;
39
+ if (b === null)
40
+ return a;
41
+ return a > b ? a : b;
42
+ }
43
+ /**
44
+ * Both recovery bounds for a `family`/`model` request, from ONE pass over
45
+ * `rateLimitResetTimes` against a single `now`.
46
+ *
47
+ * Two deliberate differences from getRateLimitResetTimeForFamily, whose
48
+ * earliest-reset answer feeds wait displays: the latest bound wins, because
49
+ * the earliest reset would send clients back into a 503 — and only the keys
50
+ * selection consults (`family`, plus `family:<model>` when a model is known;
51
+ * see isRateLimitedForFamily) may contribute, because another model's record
52
+ * does not block this request and would overstate its recovery.
53
+ *
54
+ * A caller that needs both bounds must take them from one call. Measuring
55
+ * them separately lets a record expire between the two walks, which reports
56
+ * a rate-limited account as if a cooldown bounded its recovery.
57
+ */
58
+ export function getAccountRecoveryBoundsForFamily(account, now, family, model) {
59
+ const times = account.rateLimitResetTimes;
60
+ const rateLimitAtMs = times
61
+ ? laterBound(activeBound(times[getQuotaKey(family)], now), model ? activeBound(times[getQuotaKey(family, model)], now) : null)
62
+ : null;
63
+ return {
64
+ rateLimitAtMs,
65
+ recoveryAtMs: laterBound(rateLimitAtMs, activeBound(account.coolingDownUntil, now)),
66
+ };
67
+ }
68
+ /** The `recoveryAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
69
+ export function getAccountRecoveryTimeForFamily(account, now, family, model) {
70
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
71
+ .recoveryAtMs;
72
+ }
73
+ /** The `rateLimitAtMs` bound alone; see getAccountRecoveryBoundsForFamily. */
74
+ export function getRateLimitRecoveryTimeForFamily(account, now, family, model) {
75
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
76
+ .rateLimitAtMs;
77
+ }
28
78
  export function formatRateLimitEntry(account, now, formatWaitTime, family = "codex") {
29
79
  const resetAt = getRateLimitResetTimeForFamily(account, now, family);
30
80
  if (typeof resetAt !== "number")
@@ -34,3 +84,26 @@ export function formatRateLimitEntry(account, now, formatWaitTime, family = "cod
34
84
  return null;
35
85
  return `resets in ${formatWaitTime(remaining)}`;
36
86
  }
87
+ /**
88
+ * When a request for `family`/`model` stops being rate limited: the LATEST
89
+ * active bound among exactly the two keys selection consults — the family-wide
90
+ * key and `family:<model>` (see `isRateLimitedForFamily`). Null when neither is
91
+ * active.
92
+ *
93
+ * Deliberately narrower and later than `getRateLimitResetTimeForFamily`, whose
94
+ * earliest-reset-across-every-`family:*`-key answer feeds wait displays and
95
+ * model-less callers:
96
+ *
97
+ * - narrower, because `markRateLimitedWithReason` keys token/concurrency limits
98
+ * under `family:<model>`, and a sibling model's record does not gate this
99
+ * request — folding it in reports a delay the runtime proxy would not impose;
100
+ * - later, because the account stays skipped while EITHER key is active, so the
101
+ * earliest reset understates the wait when both are set.
102
+ *
103
+ * Requires a model by construction: a caller without one cannot know which
104
+ * model key applies and should keep the family-wide union above.
105
+ */
106
+ export function getRateLimitResetTimeForModel(account, now, family, model) {
107
+ return getAccountRecoveryBoundsForFamily(account, now, family, model)
108
+ .rateLimitAtMs;
109
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAGN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AA8BvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AA2CtE,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;AAiDtE,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;AA8bD;;;;;;;;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,CA4KrC"}
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;AA8BvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AA4CtE,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;AA8bD;;;;;;;;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,CA4KrC"}
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
- import { AccountManager, extractAccountId, } from "./accounts.js";
3
+ import { AccountManager, AUTH_INVALIDATION_MARKER, extractAccountId, } from "./accounts.js";
4
4
  import { withRoutingMutex } from "./routing-mutex.js";
5
5
  import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getPreemptiveQuotaEnabled, getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getRoutingMutexMode, getSchedulingStrategy, loadPluginConfig, } from "./config.js";
6
6
  import { CODEX_BASE_URL, HTTP_STATUS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
@@ -15,6 +15,7 @@ import { CodexValidationError } from "./errors.js";
15
15
  import { normalizeEmailKey } from "./storage/identity.js";
16
16
  import { buildPinnedUnavailableErrorBody, buildTokenInvalidationBody, extractErrorCodeFromBody, isTokenInvalidationError, normalizeExhaustionStatus, parseRetryAfterBodyMs, parseRetryAfterHeaderMs, } from "./request/rate-limit-decision.js";
17
17
  import { forwardStreamingResponse, HOP_BY_HOP_HEADERS, readErrorBody, responseHeadersForClient, withTimeout, } from "./request/stream-failover-runtime.js";
18
+ import { getAccountRecoveryBoundsForFamily } from "./runtime/account-status.js";
18
19
  import { chooseAccount } from "./runtime/rotation-account-selection.js";
19
20
  import { createRotationProxyState, recoverStaleRuntimeState, } from "./runtime/rotation-proxy-state.js";
20
21
  import { readStorageMetaFromDisk } from "./runtime/rotation-storage-meta.js";
@@ -62,6 +63,18 @@ function toUrlHost(host) {
62
63
  // failures surfaced only as a last-write-wins status.lastError string. Logs are
63
64
  // level-gated and carry the per-request correlation id set in handleRequest.
64
65
  const proxyLog = createLogger("runtime-proxy");
66
+ /**
67
+ * Pinned skip reasons that no timer clears: the account stays unselectable
68
+ * after any concurrent rate-limit record or cooldown expires, so the pinned
69
+ * 503 must not advertise that record's expiry as a recovery time.
70
+ */
71
+ const PINNED_PERMANENT_SKIP_REASONS = new Set([
72
+ "missing",
73
+ "disabled",
74
+ "workspace-disabled",
75
+ "policy-blocked",
76
+ AUTH_INVALIDATION_MARKER,
77
+ ]);
65
78
  const DEFAULT_QUOTA_REMAINING_THRESHOLD = 10;
66
79
  /** @internal Stable identity key for in-memory quota snapshots across reloads. */
67
80
  export function buildQuotaScheduleKey(account, family, model) {
@@ -1170,7 +1183,82 @@ async function handleRequestInner(state, req, res, traceId) {
1170
1183
  // null reason indicates a forecast/runtime state desync (the pinned
1171
1184
  // account was selected but no skip reason was recorded) — see #486.
1172
1185
  if (isPinned) {
1173
- const errorBody = buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons);
1186
+ const pinnedAccount = typeof pinnedIndex === "number"
1187
+ ? accountManager.getAccountByIndex(pinnedIndex)
1188
+ : null;
1189
+ const pinnedSkipReason = typeof pinnedIndex === "number"
1190
+ ? accountSkipReasons.get(pinnedIndex) ?? null
1191
+ : null;
1192
+ // A permanent blocker (disabled, no enabled workspace, invalidated
1193
+ // auth, policy block, out-of-range pin) outlives every timed record,
1194
+ // so advertising a record's expiry would invite a retry into another
1195
+ // 503.
1196
+ //
1197
+ // The recorded reason alone is not enough to detect one. It is the
1198
+ // SELECTION verdict, and this request can make the pin permanently
1199
+ // unselectable after selection already ran: a workspace-disabled
1200
+ // 402/403 calls setAccountEnabled(index, false) above and then
1201
+ // continues, so the next pass records "already-attempted" and the
1202
+ // disable never surfaces. With a breaker tripped by the same failure
1203
+ // the 503 would then advertise the circuit's ~30s reset for an
1204
+ // account no timer will ever re-admit. Re-read the pin's CURRENT
1205
+ // runtime state so a permanent blocker cannot hide behind the
1206
+ // verdict; the recorded reason still covers the selection-only
1207
+ // verdicts ("missing", "policy-blocked") that state cannot express.
1208
+ const pinnedCurrentSkipReason = pinnedAccount === null
1209
+ ? null
1210
+ : accountManager.getManagedAccountRuntimeSkipReason(pinnedAccount, context.family, context.model);
1211
+ const pinnedBlockedPermanently = (pinnedSkipReason !== null &&
1212
+ PINNED_PERMANENT_SKIP_REASONS.has(pinnedSkipReason)) ||
1213
+ (pinnedCurrentSkipReason !== null &&
1214
+ PINNED_PERMANENT_SKIP_REASONS.has(pinnedCurrentSkipReason));
1215
+ // Otherwise recovery comes from the pinned account's persisted state, not the
1216
+ // recorded skip reason: a direct 429/cooldown on the pinned account
1217
+ // reaches this 503 as "already-attempted" (the retry loop's selection
1218
+ // verdict) while the record it just wrote is what actually bounds
1219
+ // recovery — and with several overlapping records the account stays
1220
+ // skipped until the LAST one expires, so the latest bound is the one
1221
+ // worth advertising.
1222
+ //
1223
+ // One clock for the whole body. The recovery bound and the
1224
+ // rate-limit bound are compared against each other below, so
1225
+ // reading state.now() per lookup would let a record expire between
1226
+ // them and report a rate-limited pin as bounded by something else.
1227
+ const evaluatedAtMs = state.now();
1228
+ // Both bounds come from a single pass over the account's records.
1229
+ const pinnedRecoveryBounds = pinnedAccount === null
1230
+ ? null
1231
+ : getAccountRecoveryBoundsForFamily(pinnedAccount, evaluatedAtMs, context.family, context.model);
1232
+ const pinnedStateRecoveryAtMs = pinnedRecoveryBounds?.recoveryAtMs ?? null;
1233
+ // An open circuit outlives the short failure cooldowns that tripped
1234
+ // it; its deadline lives in the breaker, not the account record.
1235
+ const pinnedCircuitRecoveryAtMs = pinnedAccount === null
1236
+ ? null
1237
+ : accountManager.getCircuitRecoveryTime(pinnedAccount, evaluatedAtMs);
1238
+ const pinnedResetAtMs = pinnedBlockedPermanently ||
1239
+ (pinnedStateRecoveryAtMs === null && pinnedCircuitRecoveryAtMs === null)
1240
+ ? null
1241
+ : Math.max(pinnedStateRecoveryAtMs ?? 0, pinnedCircuitRecoveryAtMs ?? 0);
1242
+ // The message only words the recovery deadline as a rate-limit reset
1243
+ // when the rate-limit records are in fact what supplies it — a
1244
+ // breaker or cooldown can end later and then bounds it instead.
1245
+ const pinnedRateLimitResetAtMs = pinnedRecoveryBounds?.rateLimitAtMs ?? null;
1246
+ const recoveryBound = pinnedResetAtMs !== null &&
1247
+ pinnedRateLimitResetAtMs !== null &&
1248
+ pinnedRateLimitResetAtMs >= pinnedResetAtMs
1249
+ ? "rate-limit"
1250
+ : "other";
1251
+ const errorBody = buildPinnedUnavailableErrorBody(pinnedIndex, accountSkipReasons, {
1252
+ // typeof check so a forced index of 0 still reads as forced.
1253
+ pinSource: typeof state.forcedAccountIndex === "number" ? "forced" : "manual",
1254
+ resetAtMs: pinnedResetAtMs,
1255
+ recoveryBound,
1256
+ // The recorded verdict above is the retry loop's
1257
+ // "already-attempted" on the dominant path; the re-read runtime
1258
+ // state is what the operator-facing sentence should describe.
1259
+ currentSkipReason: pinnedCurrentSkipReason,
1260
+ now: evaluatedAtMs,
1261
+ });
1174
1262
  if (errorBody.reason === null) {
1175
1263
  state.status.lastError = `pinned-503 missing skip reason (pinnedIndex=${pinnedIndex})`;
1176
1264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-multi-auth",
3
- "version": "2.8.5",
3
+ "version": "2.8.7",
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",