oc-codex-multi-auth 6.14.1 → 6.14.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AA2V/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAq5H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,qBAAqB,CAAC;AAsU/D;;;;;;;;;;;;;;;GAeG;AAEH,eAAO,MAAM,iBAAiB,EAAE,MAq8H/B,CAAC;AAEF,eAAO,MAAM,gBAAgB,QAAoB,CAAC;AAElD,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -103,27 +103,14 @@ function isLoopbackGatewayHost(hostname) {
103
103
  }
104
104
  return LOOPBACK_GATEWAY_HOSTS.has(hostname);
105
105
  }
106
- /**
107
- * Stable per-account identity for the terminal-error diagnostics counters.
108
- *
109
- * `account.index` is not usable here: `AccountManager.removeAccount` reindexes
110
- * the survivors, so an index recorded before a mid-traversal removal silently
111
- * refers to a different account afterwards. The model-pool identity survives
112
- * that reindex; the refresh token is the last-resort fallback for records that
113
- * have not resolved an account id yet.
114
- */
106
+ /** Stable per-seat identity used only for in-memory traversal diagnostics. */
115
107
  function getAccountDiagnosticsKey(account) {
116
- return getModelPoolAccountKey(account) ?? `refresh:${account.refreshToken}`;
117
- }
118
- /**
119
- * How many live accounts the configured pool keys actually resolve to.
120
- *
121
- * Pool keys and accounts are not 1:1 — a legacy `accountId` key matches every
122
- * Business seat in that workspace, and two keys can resolve to the same seat —
123
- * so pool size must be counted over accounts, never over `poolKeys.length`.
124
- */
125
- function countResolvedPoolAccounts(accounts, poolKeys) {
126
- return accounts.filter((account) => poolKeys.some((key) => matchesModelPoolAccountKey(account, key))).length;
108
+ return JSON.stringify([
109
+ getModelPoolAccountKey(account) ?? "unresolved",
110
+ account.organizationId ?? null,
111
+ account.email ?? null,
112
+ account.addedAt,
113
+ ]);
127
114
  }
128
115
  /** Configured pool keys matching no live account (typo, or account removed). */
129
116
  function countUnresolvedPoolKeys(accounts, poolKeys) {
@@ -442,26 +429,43 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
442
429
  * Take an account out of rotation whenever the backend reports one of its
443
430
  * quota windows as fully spent (issue #218).
444
431
  *
445
- * Every Codex response — success or 429 — carries `x-codex-*-used-percent`
446
- * and the matching reset time, so the moment an account hits 0% left we
447
- * can record it instead of rediscovering it with a failed request on every
448
- * subsequent prompt. The block lands on the persisted `rateLimitResetTimes`
449
- * map, so it is remembered across restarts and shared with other processes,
450
- * and it clears itself once the window rolls over.
432
+ * Codex carries `x-codex-*-used-percent` and the matching reset time on a
433
+ * served request and on a refused one alike, so the moment an account hits
434
+ * 0% left we can record it instead of rediscovering it with a failed request
435
+ * on every subsequent prompt. The block lands on the persisted
436
+ * `rateLimitResetTimes` map, so it is remembered across restarts and shared
437
+ * with other processes, and it clears itself once the window rolls over.
438
+ *
439
+ * Call this only for responses whose headers are authoritative: one the
440
+ * backend served, or one it refused for a confirmed usage limit. Every other
441
+ * error class — entitlement (issues #16/#17), auth, 5xx, or an upstream
442
+ * overload dressed up as a 429 — can echo a stale quota snapshot next to an
443
+ * unrelated failure, and writing that echo locked healthy accounts out for
444
+ * hours. `handleErrorResponse` makes that call once and reports it back as
445
+ * `quotaHeadersAuthoritative`.
446
+ *
447
+ * The resulting gap is deliberate: an account that really is spent but gets
448
+ * a 5xx stays selectable until its next genuine 429, which costs one wasted
449
+ * request — cheaper than a false multi-hour block on a healthy account.
450
+ *
451
+ * @returns true when the headers report a spent window, whether or not the
452
+ * stored block moved. Callers use it to stop retrying this account.
451
453
  */
452
454
  const applyQuotaExhaustion = (manager, headers, account, family, model) => {
453
455
  try {
454
456
  const resetAtMs = getQuotaExhaustedResetAtMs(headers);
455
457
  if (resetAtMs === undefined)
456
- return;
458
+ return false;
457
459
  if (!manager.markQuotaExhausted(account, resetAtMs, family, model))
458
- return;
460
+ return true;
459
461
  account.lastSwitchReason = "rate-limit";
460
462
  manager.saveToDiskDebounced();
461
463
  logWarn(`Account ${account.index + 1} (${account.email ?? "unknown"}) has no ${family} quota left; skipping it for ${formatWaitTime(resetAtMs - Date.now())}.`);
464
+ return true;
462
465
  }
463
466
  catch (error) {
464
467
  logDebug(`[${PLUGIN_NAME}] Failed to apply quota exhaustion: ${error instanceof Error ? error.message : String(error)}`);
468
+ return false;
465
469
  }
466
470
  };
467
471
  const resolveActiveIndex = (storage, family = "codex") => {
@@ -1610,9 +1614,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1610
1614
  while (true) {
1611
1615
  let accountCount = accountManager.getAccountCount();
1612
1616
  const attempted = new Set();
1613
- // Diagnostics for the terminal error message below. Keyed on a stable
1614
- // account identity, not `account.index`, so the two `attempted.clear()`
1615
- // sites that follow a mid-traversal account removal cannot desync them.
1617
+ // Diagnostics for the terminal error message below. The composite key keeps
1618
+ // legacy Business seats distinct and can also be compared with snapshots
1619
+ // after a mid-traversal account removal.
1616
1620
  // Deliberately scoped to this traversal: a fallback restart re-enters
1617
1621
  // the loop with a different model, and the message names that model.
1618
1622
  const fetchedAccountKeys = new Set();
@@ -1950,17 +1954,27 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1950
1954
  latencyMs: fetchLatencyMs,
1951
1955
  headers: Object.fromEntries(response.headers.entries()),
1952
1956
  });
1953
- void recordPromptQuotaHeaders(response, account, accountCount);
1954
- applyQuotaExhaustion(accountManager, response.headers, account, modelFamily, model);
1955
- if (!response.ok) {
1957
+ // The TUI snapshot and the durable rotation block read the same
1958
+ // `x-codex-*` headers, so they share one authority decision — gating
1959
+ // only the block would leave the status line reporting "0% left" for
1960
+ // an account the router considers healthy.
1961
+ const recordQuotaHeaders = () => {
1962
+ void recordPromptQuotaHeaders(response, account, accountCount);
1963
+ return applyQuotaExhaustion(accountManager, response.headers, account, modelFamily, model);
1964
+ };
1965
+ if (response.ok) {
1966
+ recordQuotaHeaders();
1967
+ }
1968
+ else {
1956
1969
  const contextOverflowResult = await handleContextOverflow(response, model);
1957
1970
  if (contextOverflowResult.handled) {
1958
1971
  return contextOverflowResult.response;
1959
1972
  }
1960
- const { response: errorResponse, rateLimit, errorBody, retryAsServerError } = await handleErrorResponse(response, {
1973
+ const { response: errorResponse, rateLimit, errorBody, retryAsServerError, quotaHeadersAuthoritative, } = await handleErrorResponse(response, {
1961
1974
  requestCorrelationId,
1962
1975
  threadId: threadIdCandidate,
1963
1976
  });
1977
+ const quotaExhausted = quotaHeadersAuthoritative === true && recordQuotaHeaders();
1964
1978
  const workspaceDeactivated = isDeactivatedWorkspaceError(errorBody, response.status);
1965
1979
  if (workspaceDeactivated) {
1966
1980
  const accountLabel = formatAccountLabel(account, account.index, {
@@ -2203,7 +2217,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2203
2217
  runtimeMetrics.rateLimitedResponses++;
2204
2218
  const { attempt, delayMs } = getRateLimitBackoff(account.index, quotaKey, rateLimit.retryAfterMs);
2205
2219
  const waitLabel = formatWaitTime(delayMs);
2206
- if (delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS &&
2220
+ // A spent window will still be spent in a second, and the block
2221
+ // just written for it is monotonic — a short retry that happened
2222
+ // to succeed could not walk it back, so the account would serve
2223
+ // traffic while rotation still considers it blocked. Rotate.
2224
+ if (!quotaExhausted &&
2225
+ delayMs <= RATE_LIMIT_SHORT_RETRY_THRESHOLD_MS &&
2207
2226
  consumeRetryBudget("rateLimitShort", `Short 429 retry for account ${account.index + 1} after ${delayMs}ms`)) {
2208
2227
  if (accountManager.shouldShowAccountToast(account.index, rateLimitToastDebounceMs)) {
2209
2228
  await showToast(`Rate limited. Retrying in ${waitLabel} (attempt ${attempt})...`, "warning", { duration: toastDurationMs });
@@ -2384,10 +2403,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2384
2403
  if (strictAccountPool) {
2385
2404
  const poolWaitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model, preferredAccountIds);
2386
2405
  const effectiveModel = model ?? requestedModel ?? "requested model";
2387
- const poolAccounts = accountManager.getAccountsSnapshot();
2388
- const poolAccountCount = countResolvedPoolAccounts(poolAccounts, preferredAccountIds);
2389
- const unresolvedKeyCount = countUnresolvedPoolKeys(poolAccounts, preferredAccountIds);
2390
- const unavailableCount = Math.max(0, poolAccountCount - attemptedCount);
2406
+ const accountSnapshot = accountManager.getAccountsSnapshot();
2407
+ const poolAccounts = accountSnapshot.filter((account) => preferredAccountIds.some((key) => matchesModelPoolAccountKey(account, key)));
2408
+ const poolAccountCount = poolAccounts.length;
2409
+ const unresolvedKeyCount = countUnresolvedPoolKeys(accountSnapshot, preferredAccountIds);
2410
+ const unavailableCount = poolAccounts.filter((account) => !fetchedAccountKeys.has(getAccountDiagnosticsKey(account))).length;
2391
2411
  const waitDetail = poolWaitMs > 0
2392
2412
  ? ` Try again in ${formatWaitTime(poolWaitMs)}.`
2393
2413
  : " Check the pooled accounts with `codex-health`.";
@@ -2431,7 +2451,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2431
2451
  }
2432
2452
  const waitMs = accountManager.getMinWaitTimeForFamily(modelFamily, model);
2433
2453
  const count = accountManager.getAccountCount();
2434
- const unavailableCount = Math.max(0, count - attemptedCount);
2454
+ // Counted over the accounts that are still live rather than as
2455
+ // `count - attemptedCount`: an account removed mid-traversal was
2456
+ // genuinely attempted but is no longer one of the `count`
2457
+ // configured accounts, and that arithmetic would hide a live
2458
+ // account that never got a turn.
2459
+ const unavailableCount = accountManager
2460
+ .getAccountsSnapshot()
2461
+ .filter((account) => !fetchedAccountKeys.has(getAccountDiagnosticsKey(account))).length;
2435
2462
  if (retryAllAccountsRateLimited &&
2436
2463
  count > 0 &&
2437
2464
  waitMs > 0 &&