oc-codex-multi-auth 6.19.0 → 6.21.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 (133) hide show
  1. package/README.md +15 -12
  2. package/config/README.md +8 -8
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +473 -145
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/account-display.d.ts.map +1 -1
  7. package/dist/lib/account-display.js +3 -1
  8. package/dist/lib/account-display.js.map +1 -1
  9. package/dist/lib/accounts/persistence.d.ts +25 -1
  10. package/dist/lib/accounts/persistence.d.ts.map +1 -1
  11. package/dist/lib/accounts/persistence.js +105 -2
  12. package/dist/lib/accounts/persistence.js.map +1 -1
  13. package/dist/lib/accounts/rate-limits.d.ts +22 -0
  14. package/dist/lib/accounts/rate-limits.d.ts.map +1 -1
  15. package/dist/lib/accounts/rate-limits.js +25 -0
  16. package/dist/lib/accounts/rate-limits.js.map +1 -1
  17. package/dist/lib/accounts/rotation.d.ts +22 -23
  18. package/dist/lib/accounts/rotation.d.ts.map +1 -1
  19. package/dist/lib/accounts/rotation.js +62 -37
  20. package/dist/lib/accounts/rotation.js.map +1 -1
  21. package/dist/lib/accounts/stale-state.d.ts +32 -0
  22. package/dist/lib/accounts/stale-state.d.ts.map +1 -1
  23. package/dist/lib/accounts/stale-state.js +86 -1
  24. package/dist/lib/accounts/stale-state.js.map +1 -1
  25. package/dist/lib/accounts/state.d.ts +6 -0
  26. package/dist/lib/accounts/state.d.ts.map +1 -1
  27. package/dist/lib/accounts/state.js +15 -1
  28. package/dist/lib/accounts/state.js.map +1 -1
  29. package/dist/lib/accounts/warm-recovery.d.ts +8 -0
  30. package/dist/lib/accounts/warm-recovery.d.ts.map +1 -0
  31. package/dist/lib/accounts/warm-recovery.js +70 -0
  32. package/dist/lib/accounts/warm-recovery.js.map +1 -0
  33. package/dist/lib/accounts/warm-request.d.ts +2 -0
  34. package/dist/lib/accounts/warm-request.d.ts.map +1 -1
  35. package/dist/lib/accounts/warm-request.js +4 -2
  36. package/dist/lib/accounts/warm-request.js.map +1 -1
  37. package/dist/lib/accounts.d.ts +1 -1
  38. package/dist/lib/accounts.d.ts.map +1 -1
  39. package/dist/lib/accounts.js +2 -2
  40. package/dist/lib/accounts.js.map +1 -1
  41. package/dist/lib/auth/login-runner.d.ts +3 -0
  42. package/dist/lib/auth/login-runner.d.ts.map +1 -1
  43. package/dist/lib/auth/login-runner.js +35 -0
  44. package/dist/lib/auth/login-runner.js.map +1 -1
  45. package/dist/lib/circuit-breaker.d.ts +5 -0
  46. package/dist/lib/circuit-breaker.d.ts.map +1 -1
  47. package/dist/lib/circuit-breaker.js +23 -6
  48. package/dist/lib/circuit-breaker.js.map +1 -1
  49. package/dist/lib/codex-reset.d.ts +7 -1
  50. package/dist/lib/codex-reset.d.ts.map +1 -1
  51. package/dist/lib/codex-reset.js +9 -2
  52. package/dist/lib/codex-reset.js.map +1 -1
  53. package/dist/lib/codex-usage.d.ts +16 -6
  54. package/dist/lib/codex-usage.d.ts.map +1 -1
  55. package/dist/lib/codex-usage.js +68 -23
  56. package/dist/lib/codex-usage.js.map +1 -1
  57. package/dist/lib/config.d.ts +2 -1
  58. package/dist/lib/config.d.ts.map +1 -1
  59. package/dist/lib/config.js +38 -14
  60. package/dist/lib/config.js.map +1 -1
  61. package/dist/lib/parallel-probe.d.ts.map +1 -1
  62. package/dist/lib/parallel-probe.js +5 -2
  63. package/dist/lib/parallel-probe.js.map +1 -1
  64. package/dist/lib/quota-notifications.d.ts.map +1 -1
  65. package/dist/lib/quota-notifications.js +10 -1
  66. package/dist/lib/quota-notifications.js.map +1 -1
  67. package/dist/lib/request/fetch-helpers.d.ts +24 -0
  68. package/dist/lib/request/fetch-helpers.d.ts.map +1 -1
  69. package/dist/lib/request/fetch-helpers.js +88 -38
  70. package/dist/lib/request/fetch-helpers.js.map +1 -1
  71. package/dist/lib/request/response-handler.d.ts.map +1 -1
  72. package/dist/lib/request/response-handler.js +85 -43
  73. package/dist/lib/request/response-handler.js.map +1 -1
  74. package/dist/lib/rotation.d.ts +1 -1
  75. package/dist/lib/schemas.d.ts +9 -0
  76. package/dist/lib/schemas.d.ts.map +1 -1
  77. package/dist/lib/schemas.js +3 -0
  78. package/dist/lib/schemas.js.map +1 -1
  79. package/dist/lib/storage/flagged.d.ts.map +1 -1
  80. package/dist/lib/storage/flagged.js +5 -0
  81. package/dist/lib/storage/flagged.js.map +1 -1
  82. package/dist/lib/storage/load-save.d.ts +1 -0
  83. package/dist/lib/storage/load-save.d.ts.map +1 -1
  84. package/dist/lib/storage/load-save.js +11 -0
  85. package/dist/lib/storage/load-save.js.map +1 -1
  86. package/dist/lib/storage/migrations.d.ts +21 -0
  87. package/dist/lib/storage/migrations.d.ts.map +1 -1
  88. package/dist/lib/storage/migrations.js.map +1 -1
  89. package/dist/lib/storage/normalize.d.ts.map +1 -1
  90. package/dist/lib/storage/normalize.js +93 -1
  91. package/dist/lib/storage/normalize.js.map +1 -1
  92. package/dist/lib/storage/state.d.ts +1 -0
  93. package/dist/lib/storage/state.d.ts.map +1 -1
  94. package/dist/lib/storage/state.js +12 -0
  95. package/dist/lib/storage/state.js.map +1 -1
  96. package/dist/lib/tools/codex-doctor.d.ts.map +1 -1
  97. package/dist/lib/tools/codex-doctor.js +8 -66
  98. package/dist/lib/tools/codex-doctor.js.map +1 -1
  99. package/dist/lib/tools/codex-health.d.ts.map +1 -1
  100. package/dist/lib/tools/codex-health.js +7 -1
  101. package/dist/lib/tools/codex-health.js.map +1 -1
  102. package/dist/lib/tools/codex-limits.d.ts.map +1 -1
  103. package/dist/lib/tools/codex-limits.js +13 -1
  104. package/dist/lib/tools/codex-limits.js.map +1 -1
  105. package/dist/lib/tools/codex-list.d.ts.map +1 -1
  106. package/dist/lib/tools/codex-list.js +14 -1
  107. package/dist/lib/tools/codex-list.js.map +1 -1
  108. package/dist/lib/tools/codex-reset.d.ts.map +1 -1
  109. package/dist/lib/tools/codex-reset.js +40 -1
  110. package/dist/lib/tools/codex-reset.js.map +1 -1
  111. package/dist/lib/tools/codex-status.d.ts.map +1 -1
  112. package/dist/lib/tools/codex-status.js +6 -1
  113. package/dist/lib/tools/codex-status.js.map +1 -1
  114. package/dist/lib/tools/codex-warm.d.ts +2 -1
  115. package/dist/lib/tools/codex-warm.d.ts.map +1 -1
  116. package/dist/lib/tools/codex-warm.js +48 -5
  117. package/dist/lib/tools/codex-warm.js.map +1 -1
  118. package/dist/lib/tools/doctor-repair.d.ts +10 -0
  119. package/dist/lib/tools/doctor-repair.d.ts.map +1 -0
  120. package/dist/lib/tools/doctor-repair.js +86 -0
  121. package/dist/lib/tools/doctor-repair.js.map +1 -0
  122. package/dist/lib/tools/index.d.ts +4 -1
  123. package/dist/lib/tools/index.d.ts.map +1 -1
  124. package/dist/lib/tools/index.js.map +1 -1
  125. package/dist/lib/tui-quota-cache.d.ts.map +1 -1
  126. package/dist/lib/tui-quota-cache.js +3 -1
  127. package/dist/lib/tui-quota-cache.js.map +1 -1
  128. package/dist/tui.d.ts +4 -1
  129. package/dist/tui.d.ts.map +1 -1
  130. package/dist/tui.js +24 -14
  131. package/dist/tui.js.map +1 -1
  132. package/package.json +1 -1
  133. package/scripts/install-oc-codex-multi-auth-core.js +168 -11
package/dist/index.js CHANGED
@@ -23,6 +23,12 @@
23
23
 
24
24
  */
25
25
  import { mkdir, readFile, writeFile } from "node:fs/promises";
26
+ import { watchFile, unwatchFile } from "node:fs";
27
+ import { consumeLastWrittenAccountsDigest } from "./lib/storage/load-save.js";
28
+ import { subscribeToStoragePathChanges } from "./lib/storage/state.js";
29
+ import { isKeychainOptInEnabled } from "./lib/storage/keychain.js";
30
+ import { AnyAccountStorageSchema } from "./lib/schemas.js";
31
+ import { registerCleanup, unregisterCleanup } from "./lib/shutdown.js";
26
32
  import { createHash } from "node:crypto";
27
33
  import { homedir } from "node:os";
28
34
  import { join } from "node:path";
@@ -45,7 +51,7 @@ import { resolveDisplayEmail } from "./lib/account-display.js";
45
51
  import { CodexAuthError } from "./lib/errors.js";
46
52
  import { getStoragePath, loadAccounts, withAccountStorageTransaction, clearAccounts, setStoragePath, loadFlaggedAccounts, withFlaggedAccountStorageTransaction, clearFlaggedAccounts, StorageError, formatStorageErrorHint, } from "./lib/storage.js";
47
53
  import { getWorkspaceIdentityKey } from "./lib/storage/identity.js";
48
- import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, isInvalidatedAuthTokenError, createAbortError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
54
+ import { createCodexHeaders, extractRequestUrl, handleErrorResponse, handleSuccessResponse, isDeactivatedWorkspaceError, isInvalidatedAuthTokenError, createAbortError, getUnsupportedCodexModelInfo, resolveUnsupportedCodexFallbackModel, isDefaultAutoFallbackModel, pickFallbackChainTarget, refreshAndUpdateToken, rewriteUrlForCodex, shouldRefreshToken, transformRequestForCodex, } from "./lib/request/fetch-helpers.js";
49
55
  import { shapeBodyForModel } from "./lib/request/helpers/responses-lite.js";
50
56
  import { DEACTIVATED_WORKSPACE_ERROR_CODE, isDeactivatedWorkspaceErrorMessage, isInvalidatedAuthTokenMessage, } from "./lib/error-sentinels.js";
51
57
  import { applyFastSessionDefaults, clampReasoningForModel, upsertBackendModelIdentityMessage, } from "./lib/request/request-transformer.js";
@@ -468,8 +474,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
468
474
  * served request and on a refused one alike, so the moment an account hits
469
475
  * 0% left we can record it instead of rediscovering it with a failed request
470
476
  * on every subsequent prompt. The block lands on the persisted
471
- * `rateLimitResetTimes` map, so it is remembered across restarts and shared
472
- * with other processes, and it clears itself once the window rolls over.
477
+ * account-wide `quotaExhaustedUntil` field, so it is remembered across
478
+ * restarts and clears itself once the window rolls over.
473
479
  *
474
480
  * Call this only for responses whose headers are authoritative: one the
475
481
  * backend served, or one it refused for a confirmed usage limit. Every other
@@ -495,7 +501,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
495
501
  return true;
496
502
  account.lastSwitchReason = "rate-limit";
497
503
  manager.saveToDiskDebounced();
498
- logWarn(`Account ${account.index + 1} (${account.email ?? "unknown"}) has no ${family} quota left; skipping it for ${formatWaitTime(resetAtMs - Date.now())}.`);
504
+ logWarn(`Account ${account.index + 1} has no shared subscription quota left; skipping it for ${formatWaitTime(resetAtMs - Date.now())}.`);
499
505
  return true;
500
506
  }
501
507
  catch (error) {
@@ -750,6 +756,23 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
750
756
  return null;
751
757
  return `resets in ${formatWaitTime(remaining)}`;
752
758
  };
759
+ // Account-wide subscription-quota exhaustion is a DIFFERENT block from the
760
+ // per-family rate limits above: it lives on its own field and is reported
761
+ // with its own label so a spent weekly quota is never shown as a transient
762
+ // 429 ("rate limit").
763
+ const getQuotaExhaustedUntil = (account, now) => {
764
+ const until = account.quotaExhaustedUntil;
765
+ if (typeof until !== "number" || !Number.isFinite(until) || until <= now) {
766
+ return null;
767
+ }
768
+ return until;
769
+ };
770
+ const formatQuotaExhaustionEntry = (account, now) => {
771
+ const until = getQuotaExhaustedUntil(account, now);
772
+ if (until === null)
773
+ return null;
774
+ return `quota exhausted, resets in ${formatWaitTime(until - now)}`;
775
+ };
753
776
  const applyUiRuntimeFromConfig = (pluginConfig) => {
754
777
  return setUiRuntimeOptions({
755
778
  v2Enabled: getCodexTuiV2(pluginConfig),
@@ -816,6 +839,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
816
839
  .filter((entry) => entry.length > 0)));
817
840
  };
818
841
  const supportsInteractiveMenus = () => {
842
+ if (process.env.FORCE_INTERACTIVE_MODE === "1")
843
+ return true;
819
844
  if (!process.stdin.isTTY || !process.stdout.isTTY)
820
845
  return false;
821
846
  if (process.env.OPENCODE_TUI === "1")
@@ -824,6 +849,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
824
849
  return false;
825
850
  if (process.env.TERM_PROGRAM === "opencode")
826
851
  return false;
852
+ if (process.env.ELECTRON_RUN_AS_NODE === "1")
853
+ return false;
827
854
  return true;
828
855
  };
829
856
  const promptAccountIndexSelection = async (ui, storage, title) => {
@@ -1095,22 +1122,18 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1095
1122
  logDebug(`[${PLUGIN_NAME}] Startup preflight skipped: ${error instanceof Error ? error.message : String(error)}`);
1096
1123
  }
1097
1124
  };
1098
- const invalidateAccountManagerCache = () => {
1099
- // Dispose the outgoing manager so we don't leak its shutdown handler
1100
- // into the global cleanup queue or leave a pending debounce timer
1101
- // pointing at a stale instance. Flush first (best-effort, in the
1102
- // background) so any queued debounced save is not silently dropped.
1125
+ const invalidateAccountManagerCache = (clearedSnapshots) => {
1126
+ // Retire before flushing: keep disk membership authoritative while
1127
+ // publishing queued rate-limit evidence through the volatile merge.
1103
1128
  const previous = cachedAccountManager;
1104
1129
  cachedAccountManager = null;
1105
1130
  accountManagerPromise = null;
1106
1131
  if (previous) {
1132
+ previous.disposeShutdownHandler(false, clearedSnapshots);
1107
1133
  void previous
1108
1134
  .flushPendingSave()
1109
1135
  .catch((error) => {
1110
1136
  logWarn(`Failed to flush pending save while invalidating account manager: ${error instanceof Error ? error.message : String(error)}`);
1111
- })
1112
- .finally(() => {
1113
- previous.disposeShutdownHandler();
1114
1137
  });
1115
1138
  }
1116
1139
  };
@@ -1143,6 +1166,142 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1143
1166
  logWarn(`Failed to reload account manager: ${error instanceof Error ? error.message : String(error)}`);
1144
1167
  }
1145
1168
  };
1169
+ let watchedAccountsPath;
1170
+ let observedAccountsDigest;
1171
+ let accountsReloadTimer;
1172
+ let accountsWatcherDisposed = false;
1173
+ let accountsWatchGeneration = 0;
1174
+ let unsubscribeAccountsPath;
1175
+ const stopAccountsWatcher = () => {
1176
+ accountsWatchGeneration += 1;
1177
+ if (watchedAccountsPath)
1178
+ unwatchFile(watchedAccountsPath, onAccountsStatChanged);
1179
+ watchedAccountsPath = undefined;
1180
+ observedAccountsDigest = undefined;
1181
+ clearTimeout(accountsReloadTimer);
1182
+ accountsReloadTimer = undefined;
1183
+ };
1184
+ const disposeAccountsWatcher = () => {
1185
+ accountsWatcherDisposed = true;
1186
+ unsubscribeAccountsPath?.();
1187
+ stopAccountsWatcher();
1188
+ unregisterCleanup(disposeAccountsWatcher);
1189
+ };
1190
+ const readAccountsDigest = async (path) => {
1191
+ try {
1192
+ const content = await readFile(path, "utf8");
1193
+ if (!AnyAccountStorageSchema.safeParse(JSON.parse(content)).success)
1194
+ return;
1195
+ return createHash("sha256").update(content).digest("hex");
1196
+ }
1197
+ catch {
1198
+ return;
1199
+ }
1200
+ };
1201
+ const reloadForExternalAccountsChange = async (path, generation, attempt = 0, retired) => {
1202
+ const digest = await readAccountsDigest(path);
1203
+ if (generation !== accountsWatchGeneration || !digest || path !== getStoragePath())
1204
+ return;
1205
+ if (digest === consumeLastWrittenAccountsDigest(path))
1206
+ return;
1207
+ const previous = cachedAccountManager;
1208
+ try {
1209
+ // A null cache means an invalidation retired the incumbent; the
1210
+ // reload still must adopt the external change. Bailing here would
1211
+ // strand it: onAccountsFileChanged already marked this digest
1212
+ // observed, and an in-flight load started before the write can
1213
+ // still resolve and install a pre-change snapshot whose
1214
+ // full-membership save then deletes the imported accounts.
1215
+ if (previous) {
1216
+ if (previous !== retired) {
1217
+ previous.disposeShutdownHandler(true);
1218
+ retired = previous;
1219
+ }
1220
+ await previous.flushPendingSave();
1221
+ }
1222
+ const reloaded = await AccountManager.loadFromDisk();
1223
+ if (generation !== accountsWatchGeneration || accountsWatcherDisposed || path !== getStoragePath()) {
1224
+ reloaded.disposeShutdownHandler();
1225
+ return;
1226
+ }
1227
+ const outgoing = cachedAccountManager;
1228
+ if (outgoing && outgoing !== retired) {
1229
+ // Another actor replaced the cached manager while this reload
1230
+ // was in flight (concurrent fetch reload or tool mutation).
1231
+ // Retire the incumbent with the same external-reload
1232
+ // semantics, or its queued membership save can clobber the
1233
+ // external change this reload is about to adopt.
1234
+ outgoing.disposeShutdownHandler(true);
1235
+ retired = outgoing;
1236
+ }
1237
+ cachedAccountManager = reloaded;
1238
+ accountManagerPromise = Promise.resolve(reloaded);
1239
+ observedAccountsDigest = digest;
1240
+ }
1241
+ catch {
1242
+ logWarn("Could not reload externally updated account storage");
1243
+ if (attempt < 2 && generation === accountsWatchGeneration && !accountsWatcherDisposed) {
1244
+ accountsReloadTimer = setTimeout(() => {
1245
+ accountsReloadTimer = undefined;
1246
+ void reloadForExternalAccountsChange(path, generation, attempt + 1, retired);
1247
+ }, 1500);
1248
+ accountsReloadTimer.unref();
1249
+ }
1250
+ return;
1251
+ }
1252
+ logDebug("Reloaded cached account manager after external accounts file change");
1253
+ };
1254
+ const onAccountsFileChanged = async () => {
1255
+ if (accountsWatcherDisposed)
1256
+ return;
1257
+ if (watchedAccountsPath !== getStoragePath()) {
1258
+ await ensureAccountsWatcher();
1259
+ return;
1260
+ }
1261
+ const path = watchedAccountsPath;
1262
+ if (!path)
1263
+ return;
1264
+ const generation = accountsWatchGeneration;
1265
+ const digest = await readAccountsDigest(path);
1266
+ if (generation !== accountsWatchGeneration || !digest || digest === observedAccountsDigest)
1267
+ return;
1268
+ observedAccountsDigest = digest;
1269
+ clearTimeout(accountsReloadTimer);
1270
+ accountsReloadTimer = undefined;
1271
+ if (digest === consumeLastWrittenAccountsDigest(path))
1272
+ return;
1273
+ accountsReloadTimer = setTimeout(() => {
1274
+ accountsReloadTimer = undefined;
1275
+ void reloadForExternalAccountsChange(path, generation);
1276
+ }, 500);
1277
+ accountsReloadTimer.unref();
1278
+ };
1279
+ const onAccountsStatChanged = () => {
1280
+ void onAccountsFileChanged();
1281
+ };
1282
+ const ensureAccountsWatcher = async () => {
1283
+ if (accountsWatcherDisposed)
1284
+ return;
1285
+ const path = getStoragePath();
1286
+ if (path === watchedAccountsPath)
1287
+ return;
1288
+ stopAccountsWatcher();
1289
+ if (isKeychainOptInEnabled())
1290
+ return;
1291
+ unsubscribeAccountsPath ??= subscribeToStoragePathChanges(() => {
1292
+ void ensureAccountsWatcher();
1293
+ });
1294
+ watchedAccountsPath = path;
1295
+ const generation = accountsWatchGeneration;
1296
+ const initialDigest = await readAccountsDigest(path);
1297
+ if (generation !== accountsWatchGeneration)
1298
+ return;
1299
+ observedAccountsDigest = initialDigest;
1300
+ // Stat polling follows the path across the storage writer's temp-file rename.
1301
+ watchFile(path, { interval: 1500, persistent: false }, onAccountsStatChanged);
1302
+ unregisterCleanup(disposeAccountsWatcher);
1303
+ registerCleanup(disposeAccountsWatcher);
1304
+ };
1146
1305
  const persistAuthenticatedSelections = async (results, replaceAll) => {
1147
1306
  try {
1148
1307
  await persistAccountPool(results, replaceAll);
@@ -1175,6 +1334,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1175
1334
  const { event } = input;
1176
1335
  if (event.type === "server.instance.disposed") {
1177
1336
  quotaMonitor.dispose();
1337
+ disposeAccountsWatcher();
1178
1338
  return;
1179
1339
  }
1180
1340
  // Handle TUI account selection events
@@ -1265,6 +1425,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1265
1425
  resolveActiveIndex,
1266
1426
  getRateLimitResetTimeForFamily,
1267
1427
  formatRateLimitEntry,
1428
+ formatQuotaExhaustionEntry,
1268
1429
  buildJsonAccountIdentity,
1269
1430
  buildRoutingVisibilitySnapshot,
1270
1431
  appendRoutingVisibilityText,
@@ -1328,7 +1489,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1328
1489
  });
1329
1490
  }
1330
1491
  applyUiRuntimeFromConfig(pluginConfig);
1331
- const perProjectAccounts = getPerProjectAccounts(pluginConfig);
1492
+ let perProjectAccounts = getPerProjectAccounts(pluginConfig);
1493
+ let storageTransition;
1494
+ const activeFetches = new Set();
1332
1495
  setStoragePath(perProjectAccounts ? process.cwd() : null);
1333
1496
  const authFallback = auth.type === "oauth" ? auth : undefined;
1334
1497
  // Prefer multi-account auth metadata when available, but still handle
@@ -1368,8 +1531,19 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1368
1531
  }
1369
1532
  });
1370
1533
  }
1371
- let accountManager = await accountManagerPromise;
1372
- cachedAccountManager = accountManager;
1534
+ const loadedManager = await accountManagerPromise;
1535
+ if (cachedAccountManager && cachedAccountManager !== loadedManager) {
1536
+ // An external reload replaced the cache while this load was
1537
+ // in flight. The loaded snapshot is stale; retiring it keeps
1538
+ // its debounced save from writing full membership over the
1539
+ // successor's state.
1540
+ loadedManager.disposeShutdownHandler();
1541
+ }
1542
+ else {
1543
+ cachedAccountManager = loadedManager;
1544
+ }
1545
+ const accountManager = cachedAccountManager ?? loadedManager;
1546
+ await ensureAccountsWatcher();
1373
1547
  const refreshToken = authFallback?.refresh ?? "";
1374
1548
  const needsPersist = refreshToken &&
1375
1549
  !accountManager.hasRefreshToken(refreshToken);
@@ -1394,77 +1568,37 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1394
1568
  global: providerConfig?.options || {},
1395
1569
  models: providerConfig?.models || {},
1396
1570
  };
1397
- // Load plugin configuration and determine CODEX_MODE
1398
- // Priority: CODEX_MODE env var > config file > default (true)
1399
- const codexMode = getCodexMode(pluginConfig);
1400
- const requestTransformMode = getRequestTransformMode(pluginConfig);
1401
- const useLegacyRequestTransform = requestTransformMode === "legacy";
1402
- const fastSessionEnabled = getFastSession(pluginConfig);
1403
- const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
1404
- const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1405
- const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1406
- beginnerSafeModeEnabled = beginnerSafeMode;
1407
- const maskEmailEnabled = getCodexTuiMaskEmail(pluginConfig);
1408
- const retryProfile = beginnerSafeMode
1409
- ? "conservative"
1410
- : getRetryProfile(pluginConfig);
1411
- const retryBudgetOverrides = beginnerSafeMode
1412
- ? {}
1413
- : getRetryBudgetOverrides(pluginConfig);
1414
- const retryBudgetLimits = resolveRetryBudgetLimits(retryProfile, retryBudgetOverrides);
1415
- runtimeMetrics.retryProfile = retryProfile;
1416
- runtimeMetrics.retryBudgetLimits = { ...retryBudgetLimits };
1417
- const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
1418
- const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
1419
- const retryAllAccountsRateLimited = beginnerSafeMode
1420
- ? false
1421
- : getRetryAllAccountsRateLimited(pluginConfig);
1422
- const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
1423
- const retryAllAccountsMaxRetries = beginnerSafeMode
1424
- ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1425
- : getRetryAllAccountsMaxRetries(pluginConfig);
1426
- const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
1427
- const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
1428
- const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
1429
- const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig);
1430
- const toastDurationMs = getToastDurationMs(pluginConfig);
1431
- const accountToastsEnabled = getAccountToastsEnabled(pluginConfig);
1432
- const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
1433
- const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
1434
1571
  const sessionRecoveryEnabled = getSessionRecovery(pluginConfig);
1572
+ beginnerSafeModeEnabled = getBeginnerSafeMode(pluginConfig);
1573
+ const initialRetryProfile = beginnerSafeModeEnabled ? "conservative" : getRetryProfile(pluginConfig);
1574
+ runtimeMetrics.retryProfile = initialRetryProfile;
1575
+ runtimeMetrics.retryBudgetLimits = resolveRetryBudgetLimits(initialRetryProfile, beginnerSafeModeEnabled ? {} : getRetryBudgetOverrides(pluginConfig));
1435
1576
  const autoResumeEnabled = getAutoResume(pluginConfig);
1436
1577
  const autoUpdateEnabled = getAutoUpdate(pluginConfig);
1437
- const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
1438
- const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
1439
- const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1440
- const rotationStrategy = getRotationStrategy(pluginConfig);
1441
- const effectiveUserConfig = fastSessionEnabled
1442
- ? applyFastSessionDefaults(userConfig)
1443
- : userConfig;
1444
- if (fastSessionEnabled) {
1578
+ if (getFastSession(pluginConfig)) {
1445
1579
  logDebug("Fast session mode enabled", {
1446
1580
  reasoningEffort: "none/low",
1447
1581
  reasoningSummary: "auto",
1448
1582
  textVerbosity: "low",
1449
- fastSessionStrategy,
1450
- fastSessionMaxInputItems,
1583
+ fastSessionStrategy: getFastSessionStrategy(pluginConfig),
1584
+ fastSessionMaxInputItems: getFastSessionMaxInputItems(pluginConfig),
1451
1585
  });
1452
1586
  }
1453
- if (beginnerSafeMode) {
1587
+ if (getBeginnerSafeMode(pluginConfig)) {
1454
1588
  logInfo("Beginner safe mode enabled", {
1455
- retryProfile,
1456
- retryAllAccountsRateLimited,
1457
- retryAllAccountsMaxRetries,
1589
+ retryProfile: "conservative",
1590
+ retryAllAccountsRateLimited: false,
1591
+ retryAllAccountsMaxRetries: Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig)),
1458
1592
  });
1459
1593
  }
1460
1594
  const prewarmEnabled = process.env.CODEX_AUTH_PREWARM !== "0" &&
1461
1595
  process.env.VITEST !== "true" &&
1462
1596
  process.env.NODE_ENV !== "test";
1463
- if (!startupPrewarmTriggered && prewarmEnabled && useLegacyRequestTransform) {
1597
+ if (!startupPrewarmTriggered && prewarmEnabled && getRequestTransformMode(pluginConfig) === "legacy") {
1464
1598
  startupPrewarmTriggered = true;
1465
1599
  const configuredModels = Object.keys(userConfig.models ?? {});
1466
1600
  prewarmCodexInstructions(configuredModels);
1467
- if (codexMode) {
1601
+ if (getCodexMode(pluginConfig)) {
1468
1602
  prewarmOpenCodeCodexPrompt();
1469
1603
  }
1470
1604
  }
@@ -1497,10 +1631,79 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1497
1631
  * @returns Response from Codex API
1498
1632
  */
1499
1633
  async fetch(input, init) {
1634
+ let finishFetch;
1635
+ let pendingFetch;
1500
1636
  try {
1501
- if (cachedAccountManager && cachedAccountManager !== accountManager) {
1502
- accountManager = cachedAccountManager;
1637
+ const pluginConfig = loadPluginConfig();
1638
+ const currentPerProjectAccounts = getPerProjectAccounts(pluginConfig);
1639
+ while (storageTransition)
1640
+ await storageTransition;
1641
+ if (currentPerProjectAccounts !== perProjectAccounts) {
1642
+ storageTransition = (async () => {
1643
+ await Promise.all(activeFetches);
1644
+ // Drain the old pool's pending write before changing the global path.
1645
+ await cachedAccountManager?.flushPendingSave();
1646
+ setStoragePath(currentPerProjectAccounts ? process.cwd() : null);
1647
+ invalidateAccountManagerCache();
1648
+ perProjectAccounts = currentPerProjectAccounts;
1649
+ })().finally(() => { storageTransition = undefined; });
1650
+ await storageTransition;
1651
+ }
1652
+ pendingFetch = new Promise((resolve) => { finishFetch = resolve; });
1653
+ activeFetches.add(pendingFetch);
1654
+ if (!accountManagerPromise) {
1655
+ const pending = AccountManager.loadFromDisk();
1656
+ accountManagerPromise = pending;
1657
+ void pending.catch(() => {
1658
+ if (accountManagerPromise === pending)
1659
+ accountManagerPromise = null;
1660
+ });
1661
+ }
1662
+ if (!cachedAccountManager) {
1663
+ const loadedManager = await accountManagerPromise;
1664
+ if (cachedAccountManager && cachedAccountManager !== loadedManager) {
1665
+ // External reload won the race while this load was in
1666
+ // flight: adopt the cache and retire the stale load.
1667
+ loadedManager.disposeShutdownHandler();
1668
+ }
1669
+ else if (!cachedAccountManager) {
1670
+ cachedAccountManager = loadedManager;
1671
+ }
1503
1672
  }
1673
+ const codexMode = getCodexMode(pluginConfig);
1674
+ const requestTransformMode = getRequestTransformMode(pluginConfig);
1675
+ const fastSessionEnabled = getFastSession(pluginConfig);
1676
+ const fastSessionStrategy = getFastSessionStrategy(pluginConfig);
1677
+ const fastSessionMaxInputItems = getFastSessionMaxInputItems(pluginConfig);
1678
+ const beginnerSafeMode = getBeginnerSafeMode(pluginConfig);
1679
+ beginnerSafeModeEnabled = beginnerSafeMode;
1680
+ const maskEmailEnabled = getCodexTuiMaskEmail(pluginConfig);
1681
+ const retryProfile = beginnerSafeMode ? "conservative" : getRetryProfile(pluginConfig);
1682
+ const retryBudgetOverrides = beginnerSafeMode ? {} : getRetryBudgetOverrides(pluginConfig);
1683
+ const retryBudgetLimits = resolveRetryBudgetLimits(retryProfile, retryBudgetOverrides);
1684
+ runtimeMetrics.retryProfile = retryProfile;
1685
+ runtimeMetrics.retryBudgetLimits = { ...retryBudgetLimits };
1686
+ const tokenRefreshSkewMs = getTokenRefreshSkewMs(pluginConfig);
1687
+ const rateLimitToastDebounceMs = getRateLimitToastDebounceMs(pluginConfig);
1688
+ const retryAllAccountsRateLimited = beginnerSafeMode ? false : getRetryAllAccountsRateLimited(pluginConfig);
1689
+ const retryAllAccountsMaxWaitMs = getRetryAllAccountsMaxWaitMs(pluginConfig);
1690
+ const retryAllAccountsMaxRetries = beginnerSafeMode
1691
+ ? Math.min(1, getRetryAllAccountsMaxRetries(pluginConfig))
1692
+ : getRetryAllAccountsMaxRetries(pluginConfig);
1693
+ const unsupportedCodexPolicy = getUnsupportedCodexPolicy(pluginConfig);
1694
+ const fallbackOnUnsupportedCodexModel = unsupportedCodexPolicy === "fallback";
1695
+ const fallbackToGpt52OnUnsupportedGpt53 = getFallbackToGpt52OnUnsupportedGpt53(pluginConfig);
1696
+ const unsupportedCodexFallbackChain = getUnsupportedCodexFallbackChain(pluginConfig);
1697
+ const toastDurationMs = getToastDurationMs(pluginConfig);
1698
+ const accountToastsEnabled = getAccountToastsEnabled(pluginConfig);
1699
+ const fetchTimeoutMs = getFetchTimeoutMs(pluginConfig);
1700
+ const streamStallTimeoutMs = getStreamStallTimeoutMs(pluginConfig);
1701
+ const emptyResponseMaxRetries = getEmptyResponseMaxRetries(pluginConfig);
1702
+ const emptyResponseRetryDelayMs = getEmptyResponseRetryDelayMs(pluginConfig);
1703
+ const pidOffsetEnabled = getPidOffsetEnabled(pluginConfig);
1704
+ const rotationStrategy = getRotationStrategy(pluginConfig);
1705
+ const effectiveUserConfig = fastSessionEnabled ? applyFastSessionDefaults(userConfig) : userConfig;
1706
+ let accountManager = cachedAccountManager;
1504
1707
  // Step 1: Extract and rewrite URL for Codex backend
1505
1708
  const originalUrl = extractRequestUrl(input);
1506
1709
  const url = openAIBaseURL ? originalUrl : rewriteUrlForCodex(originalUrl);
@@ -1638,6 +1841,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1638
1841
  const startTime = Date.now();
1639
1842
  const endTime = startTime + totalMs;
1640
1843
  while (Date.now() < endTime) {
1844
+ if (cachedAccountManager !== accountManager)
1845
+ return;
1641
1846
  if (abortSignal?.aborted) {
1642
1847
  throw abortError();
1643
1848
  }
@@ -1659,7 +1864,124 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1659
1864
  if (model) {
1660
1865
  attemptedUnsupportedFallbackModels.add(model);
1661
1866
  }
1867
+ // Degrading the model mid-request touches several coupled pieces:
1868
+ // the attempted-model set, the routing snapshot, the model's own
1869
+ // instructions, the reasoning clamp (only the 5.6 tiers accept
1870
+ // `max`, so an un-clamped sol -> gpt-5.5 hop turns a graceful
1871
+ // fallback into a hard 400) and the per-model body shape. Both the
1872
+ // entitlement fallback and the quota fallback go through here so
1873
+ // the two can never drift apart on any of them.
1874
+ const applyModelFallback = async (previousModel, target, reason) => {
1875
+ attemptedUnsupportedFallbackModels.add(previousModel);
1876
+ attemptedUnsupportedFallbackModels.add(target);
1877
+ model = target;
1878
+ modelFamily = getModelFamily(model);
1879
+ quotaKey = `${modelFamily}:${model}`;
1880
+ fallbackApplied = true;
1881
+ fallbackFrom = previousModel;
1882
+ fallbackTo = model;
1883
+ fallbackReason = reason;
1884
+ const fallbackInstructions = await getCodexInstructions(model);
1885
+ if (transformedBody && typeof transformedBody === "object") {
1886
+ transformedBody = {
1887
+ ...transformedBody,
1888
+ model,
1889
+ instructions: fallbackInstructions,
1890
+ input: upsertBackendModelIdentityMessage(transformedBody.input, model),
1891
+ };
1892
+ }
1893
+ else {
1894
+ let fallbackBody = {
1895
+ model,
1896
+ instructions: fallbackInstructions,
1897
+ };
1898
+ if (requestInit?.body && typeof requestInit.body === "string") {
1899
+ try {
1900
+ const parsed = JSON.parse(requestInit.body);
1901
+ fallbackBody = {
1902
+ ...parsed,
1903
+ model,
1904
+ instructions: fallbackInstructions,
1905
+ };
1906
+ if (Array.isArray(fallbackBody.input)) {
1907
+ fallbackBody.input = upsertBackendModelIdentityMessage(fallbackBody.input, model);
1908
+ }
1909
+ }
1910
+ catch {
1911
+ // Keep minimal fallback body if parsing fails.
1912
+ }
1913
+ }
1914
+ transformedBody = fallbackBody;
1915
+ }
1916
+ const clampedReasoning = clampReasoningForModel(transformedBody.reasoning, model);
1917
+ if (clampedReasoning !== transformedBody.reasoning) {
1918
+ transformedBody = {
1919
+ ...transformedBody,
1920
+ reasoning: clampedReasoning,
1921
+ };
1922
+ }
1923
+ requestInit = {
1924
+ ...(requestInit ?? {}),
1925
+ body: JSON.stringify(shapeBodyForModel(transformedBody)),
1926
+ };
1927
+ if (runtimeMetrics.lastSelectionSnapshot) {
1928
+ runtimeMetrics.lastSelectionSnapshot = {
1929
+ ...runtimeMetrics.lastSelectionSnapshot,
1930
+ family: modelFamily,
1931
+ model: model ?? null,
1932
+ requestedModel,
1933
+ effectiveModel: model ?? null,
1934
+ quotaKey,
1935
+ fallbackApplied,
1936
+ fallbackFrom,
1937
+ fallbackTo,
1938
+ fallbackReason,
1939
+ };
1940
+ }
1941
+ };
1942
+ // A degraded model must not degrade again without bound, even if a
1943
+ // custom chain is cyclic. The attempted set already prevents
1944
+ // revisiting a model; this caps the total hops per request.
1945
+ const MAX_QUOTA_FALLBACK_SWITCHES = 3;
1946
+ let quotaFallbackSwitches = 0;
1662
1947
  while (true) {
1948
+ if (cachedAccountManager && cachedAccountManager !== accountManager) {
1949
+ accountManager = cachedAccountManager;
1950
+ }
1951
+ else if (!cachedAccountManager) {
1952
+ // Reload through the shared accountManagerPromise so
1953
+ // concurrent requests (and the startup path above)
1954
+ // share one load. Racing independent loadFromDisk()
1955
+ // calls here can orphan the slower manager while an
1956
+ // in-flight request still holds it, and an orphaned
1957
+ // manager is never retired, so its next save would
1958
+ // write full membership and drop externally imported
1959
+ // accounts.
1960
+ if (accountManagerPromise === null) {
1961
+ const pending = AccountManager.loadFromDisk();
1962
+ accountManagerPromise = pending;
1963
+ void pending.catch(() => {
1964
+ if (accountManagerPromise === pending)
1965
+ accountManagerPromise = null;
1966
+ });
1967
+ }
1968
+ const reloaded = await accountManagerPromise;
1969
+ if (cachedAccountManager) {
1970
+ if (cachedAccountManager !== reloaded) {
1971
+ // The cache was repopulated while this load was in
1972
+ // flight (external reload after an invalidation).
1973
+ // The loaded manager is stale; retire it so its
1974
+ // debounced save cannot write full membership over
1975
+ // accounts imported after its snapshot was taken.
1976
+ reloaded.disposeShutdownHandler();
1977
+ }
1978
+ accountManager = cachedAccountManager;
1979
+ }
1980
+ else {
1981
+ cachedAccountManager = reloaded;
1982
+ accountManager = reloaded;
1983
+ }
1984
+ }
1663
1985
  let accountCount = accountManager.getAccountCount();
1664
1986
  const attempted = new Set();
1665
1987
  // Diagnostics for the terminal error message below. The composite key keeps
@@ -1697,6 +2019,11 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1697
2019
  break;
1698
2020
  }
1699
2021
  attempted.add(account.index);
2022
+ // Hybrid's last-resort result is not necessarily eligible. Requests
2023
+ // must honor active blocks rather than sending it upstream anyway.
2024
+ if (selectionExplainability.some((entry) => entry.index === account.index && !entry.eligible)) {
2025
+ continue;
2026
+ }
1700
2027
  runtimeMetrics.lastSelectedAccountIndex = account.index;
1701
2028
  runtimeMetrics.lastQuotaKey = quotaKey;
1702
2029
  if (runtimeMetrics.lastSelectionSnapshot) {
@@ -2109,81 +2436,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2109
2436
  if (fallbackModel) {
2110
2437
  const previousModel = model ?? "gpt-5-codex";
2111
2438
  const previousModelFamily = modelFamily;
2112
- attemptedUnsupportedFallbackModels.add(previousModel);
2113
- attemptedUnsupportedFallbackModels.add(fallbackModel);
2114
2439
  accountManager.refundToken(account, previousModelFamily, previousModel);
2115
- model = fallbackModel;
2116
- modelFamily = getModelFamily(model);
2117
- quotaKey = `${modelFamily}:${model}`;
2118
- fallbackApplied = true;
2119
- fallbackFrom = previousModel;
2120
- fallbackTo = model;
2121
- fallbackReason = "fallback-unsupported-model-entitlement";
2122
- const fallbackInstructions = await getCodexInstructions(model);
2123
- if (transformedBody && typeof transformedBody === "object") {
2124
- transformedBody = {
2125
- ...transformedBody,
2126
- model,
2127
- instructions: fallbackInstructions,
2128
- input: upsertBackendModelIdentityMessage(transformedBody.input, model),
2129
- };
2130
- }
2131
- else {
2132
- let fallbackBody = {
2133
- model,
2134
- instructions: fallbackInstructions,
2135
- };
2136
- if (requestInit?.body && typeof requestInit.body === "string") {
2137
- try {
2138
- const parsed = JSON.parse(requestInit.body);
2139
- fallbackBody = {
2140
- ...parsed,
2141
- model,
2142
- instructions: fallbackInstructions,
2143
- };
2144
- if (Array.isArray(fallbackBody.input)) {
2145
- fallbackBody.input = upsertBackendModelIdentityMessage(fallbackBody.input, model);
2146
- }
2147
- }
2148
- catch {
2149
- // Keep minimal fallback body if parsing fails.
2150
- }
2151
- }
2152
- transformedBody = fallbackBody;
2153
- }
2154
- // The carried-over reasoning effort was clamped for the ORIGINAL
2155
- // model; the fallback target may not accept it (`max` exists only
2156
- // on the 5.6 tiers, so a sol -> gpt-5.5 hop must degrade it or the
2157
- // graceful fallback turns into a hard 400).
2158
- const clampedReasoning = clampReasoningForModel(transformedBody.reasoning, model);
2159
- if (clampedReasoning !== transformedBody.reasoning) {
2160
- transformedBody = {
2161
- ...transformedBody,
2162
- reasoning: clampedReasoning,
2163
- };
2164
- }
2165
- // Shape for whichever model this attempt targets. A 5.6 -> 5.5 fallback
2166
- // must go out in the classic shape, and a 5.6 -> 5.6 hop must re-fold
2167
- // the new model's instructions into `input` rather than leaving them
2168
- // at the top level.
2169
- requestInit = {
2170
- ...(requestInit ?? {}),
2171
- body: JSON.stringify(shapeBodyForModel(transformedBody)),
2172
- };
2173
- if (runtimeMetrics.lastSelectionSnapshot) {
2174
- runtimeMetrics.lastSelectionSnapshot = {
2175
- ...runtimeMetrics.lastSelectionSnapshot,
2176
- family: modelFamily,
2177
- model: model ?? null,
2178
- requestedModel,
2179
- effectiveModel: model ?? null,
2180
- quotaKey,
2181
- fallbackApplied,
2182
- fallbackFrom,
2183
- fallbackTo,
2184
- fallbackReason,
2185
- };
2186
- }
2440
+ await applyModelFallback(previousModel, fallbackModel, "fallback-unsupported-model-entitlement");
2187
2441
  runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`;
2188
2442
  runtimeMetrics.lastErrorCategory = "model-fallback";
2189
2443
  logWarn(`Model ${previousModel} is unsupported for this ChatGPT account. Falling back to ${model}.`, {
@@ -2279,7 +2533,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2279
2533
  await sleep(addJitter(Math.max(MIN_BACKOFF_MS, delayMs), 0.2));
2280
2534
  continue;
2281
2535
  }
2282
- accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
2536
+ // Authoritative subscription exhaustion already has its own block.
2537
+ // Do not duplicate its reset as a model/family transient 429; retain
2538
+ // any genuine transient state written by other in-flight requests.
2539
+ if (!quotaExhausted) {
2540
+ accountManager.markRateLimitedWithReason(account, delayMs, modelFamily, parseRateLimitReason(rateLimit.code), model);
2541
+ }
2283
2542
  accountManager.recordRateLimit(account, modelFamily, model);
2284
2543
  account.lastSwitchReason = "rate-limit";
2285
2544
  runtimeMetrics.accountRotations++;
@@ -2507,6 +2766,72 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2507
2766
  const unavailableCount = accountManager
2508
2767
  .getAccountsSnapshot()
2509
2768
  .filter((account) => !fetchedAccountKeys.has(getAccountDiagnosticsKey(account))).length;
2769
+ const enabledSelection = count > 0 ? accountManager
2770
+ .getSelectionExplainability(modelFamily, model)
2771
+ .filter((entry) => entry.enabled) : [];
2772
+ const upstreamBlocked = enabledSelection.length > 0 && enabledSelection.every((entry) => entry.rateLimitedUntil !== undefined || entry.quotaExhaustedUntil !== undefined);
2773
+ // Every enabled account has an active upstream block. Before waiting out a
2774
+ // block that can run for days (`retryAllAccountsMaxRetries`
2775
+ // defaults to Infinity), degrade to the next chain model that is
2776
+ // actually usable right now. Gated exactly like the entitlement
2777
+ // auto-fallback -- same default-selector entry models, same
2778
+ // opt-out env vars -- even when an entry ID was selected directly.
2779
+ // Local token depletion and auth cooldown alone never trigger it.
2780
+ // An account-wide quota block fails the eligibility test
2781
+ // on every candidate, so it correctly falls through to the wait.
2782
+ if (upstreamBlocked &&
2783
+ waitMs > 0 &&
2784
+ count > 0 &&
2785
+ model &&
2786
+ quotaFallbackSwitches < MAX_QUOTA_FALLBACK_SWITCHES &&
2787
+ isDefaultAutoFallbackModel(model, attemptedUnsupportedFallbackModels)) {
2788
+ const rejected = new Set();
2789
+ let usableFallback;
2790
+ while (true) {
2791
+ const candidate = pickFallbackChainTarget({
2792
+ currentModel: model,
2793
+ attemptedModels: new Set([
2794
+ ...attemptedUnsupportedFallbackModels,
2795
+ ...rejected,
2796
+ ]),
2797
+ customChain: unsupportedCodexFallbackChain,
2798
+ fallbackToGpt52OnUnsupportedGpt53,
2799
+ });
2800
+ if (!candidate || rejected.has(candidate))
2801
+ break;
2802
+ // Only degrade to a model some account can serve NOW,
2803
+ // otherwise the hop just moves the same block sideways.
2804
+ const candidatePool = getModelAccountPool(pluginConfig, candidate);
2805
+ const strictCandidatePool = candidatePool.length > 0 &&
2806
+ getModelAccountPoolMode(pluginConfig, candidate) === "strict";
2807
+ const candidateAccounts = accountManager.getAccountsSnapshot();
2808
+ const candidateEligible = accountManager.getSelectionExplainability(getModelFamily(candidate), candidate).some((entry) => entry.eligible && (!strictCandidatePool ||
2809
+ candidateAccounts.some((account) => account.index === entry.index &&
2810
+ candidatePool.some((key) => matchesModelPoolAccountKey(account, key)))));
2811
+ // A preferred pool may spill into general accounts; a strict
2812
+ // pool must contain an eligible member. This does not select
2813
+ // an account or advance any rotation cursor.
2814
+ if (candidateEligible) {
2815
+ usableFallback = candidate;
2816
+ break;
2817
+ }
2818
+ rejected.add(candidate);
2819
+ }
2820
+ if (usableFallback) {
2821
+ const previousModel = model;
2822
+ quotaFallbackSwitches++;
2823
+ await applyModelFallback(previousModel, usableFallback, "fallback-quota-exhausted");
2824
+ runtimeMetrics.lastError = `Model fallback: ${previousModel} -> ${model}`;
2825
+ runtimeMetrics.lastErrorCategory = "model-fallback";
2826
+ logWarn(`All ${count} account(s) are rate-limited or out of quota for ${previousModel}. Falling back to ${model}.`, {
2827
+ requestedModel: previousModel,
2828
+ effectiveModel: model,
2829
+ fallbackApplied: true,
2830
+ fallbackReason: "fallback-quota-exhausted",
2831
+ });
2832
+ continue;
2833
+ }
2834
+ }
2510
2835
  if (retryAllAccountsRateLimited &&
2511
2836
  count > 0 &&
2512
2837
  waitMs > 0 &&
@@ -2556,6 +2881,9 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
2556
2881
  }
2557
2882
  }
2558
2883
  finally {
2884
+ if (pendingFetch)
2885
+ activeFetches.delete(pendingFetch);
2886
+ finishFetch?.();
2559
2887
  clearCorrelationId();
2560
2888
  }
2561
2889
  },