codex-multi-auth 2.22.0 → 2.24.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 (117) hide show
  1. package/.codex-plugin/plugin.json +9 -9
  2. package/LICENSE +21 -21
  3. package/README.md +159 -150
  4. package/assets/codex-multi-auth-icon.svg +39 -39
  5. package/assets/readme-hero.svg +31 -31
  6. package/config/README.md +31 -31
  7. package/config/codex-legacy.json +1021 -1021
  8. package/config/codex-modern.json +347 -347
  9. package/config/minimal-codex.json +15 -15
  10. package/config/schema/config.schema.json +8 -0
  11. package/dist/index.js +33 -10
  12. package/dist/lib/account-policy.d.ts +2 -0
  13. package/dist/lib/account-policy.js +3 -1
  14. package/dist/lib/accounts/rate-limits.d.ts +6 -0
  15. package/dist/lib/accounts/rate-limits.js +14 -1
  16. package/dist/lib/accounts.d.ts +6 -1
  17. package/dist/lib/accounts.js +33 -8
  18. package/dist/lib/api-route-store.d.ts +1 -0
  19. package/dist/lib/api-route-store.js +2 -1
  20. package/dist/lib/auth/account-access.js +2 -1
  21. package/dist/lib/auth/auth.js +3 -2
  22. package/dist/lib/auth/device-auth.js +2 -1
  23. package/dist/lib/auth/http.d.ts +1 -0
  24. package/dist/lib/auth/http.js +1 -0
  25. package/dist/lib/auth/token-utils.d.ts +5 -0
  26. package/dist/lib/auth/token-utils.js +10 -0
  27. package/dist/lib/codex-manager/account-manager-commands.js +1 -0
  28. package/dist/lib/codex-manager/commands/account.js +15 -0
  29. package/dist/lib/codex-manager/commands/locale.d.ts +12 -0
  30. package/dist/lib/codex-manager/commands/locale.js +25 -0
  31. package/dist/lib/codex-manager/commands/resets.js +18 -5
  32. package/dist/lib/codex-manager/commands/status.d.ts +4 -0
  33. package/dist/lib/codex-manager/commands/status.js +476 -5
  34. package/dist/lib/codex-manager/commands/workspace.d.ts +4 -2
  35. package/dist/lib/codex-manager/commands/workspace.js +33 -2
  36. package/dist/lib/codex-manager/help.js +5 -3
  37. package/dist/lib/codex-manager.js +112 -30
  38. package/dist/lib/config.js +3 -0
  39. package/dist/lib/constants.d.ts +2 -0
  40. package/dist/lib/constants.js +2 -0
  41. package/dist/lib/context-budget-response.js +8 -8
  42. package/dist/lib/context-overflow.js +10 -10
  43. package/dist/lib/locale.d.ts +7 -0
  44. package/dist/lib/locale.js +35 -0
  45. package/dist/lib/oauth-success.html +125 -125
  46. package/dist/lib/policy/runtime-policy-cache.js +1 -1
  47. package/dist/lib/policy/runtime-policy.d.ts +1 -0
  48. package/dist/lib/policy/runtime-policy.js +4 -0
  49. package/dist/lib/preemptive-quota-scheduler.js +12 -3
  50. package/dist/lib/prompts/codex-host-bridge.js +138 -138
  51. package/dist/lib/prompts/codex.js +79 -79
  52. package/dist/lib/quota-cache.d.ts +4 -0
  53. package/dist/lib/quota-probe.js +2 -1
  54. package/dist/lib/quota-readiness.d.ts +1 -1
  55. package/dist/lib/quota-readiness.js +9 -2
  56. package/dist/lib/request/proxy-fetch.d.ts +5 -0
  57. package/dist/lib/request/proxy-fetch.js +37 -0
  58. package/dist/lib/request/rate-limit-decision.js +1 -1
  59. package/dist/lib/request/url-rewriting.js +17 -0
  60. package/dist/lib/runtime/account-model-catalog.d.ts +2 -0
  61. package/dist/lib/runtime/account-model-catalog.js +7 -2
  62. package/dist/lib/runtime/account-reset-credits.d.ts +11 -2
  63. package/dist/lib/runtime/account-reset-credits.js +35 -12
  64. package/dist/lib/runtime/app-bind.d.ts +4 -1
  65. package/dist/lib/runtime/app-bind.js +63 -3
  66. package/dist/lib/runtime/automatic-account-checks.js +6 -5
  67. package/dist/lib/runtime/automatic-subscription-checks.js +4 -3
  68. package/dist/lib/runtime/model-discovery-status.js +2 -2
  69. package/dist/lib/runtime/reset-credit-routing.d.ts +11 -3
  70. package/dist/lib/runtime/reset-credit-routing.js +66 -21
  71. package/dist/lib/runtime/reset-credits.d.ts +31 -7
  72. package/dist/lib/runtime/reset-credits.js +135 -28
  73. package/dist/lib/runtime/responses-websocket.d.ts +30 -0
  74. package/dist/lib/runtime/responses-websocket.js +243 -29
  75. package/dist/lib/runtime/rotation-account-selection.d.ts +5 -4
  76. package/dist/lib/runtime/rotation-proxy-state.d.ts +3 -0
  77. package/dist/lib/runtime/rotation-server-types.d.ts +4 -1
  78. package/dist/lib/runtime/subscription-quota-order.d.ts +1 -0
  79. package/dist/lib/runtime/subscription-quota-order.js +7 -3
  80. package/dist/lib/runtime/workspace-model-scopes.d.ts +2 -0
  81. package/dist/lib/runtime/workspace-model-scopes.js +14 -4
  82. package/dist/lib/runtime-rotation-proxy.js +138 -38
  83. package/dist/lib/schemas.d.ts +5 -0
  84. package/dist/lib/schemas.js +1 -0
  85. package/dist/lib/storage/snapshot-merge.js +15 -4
  86. package/dist/lib/web-dashboard-html.d.ts +1 -0
  87. package/dist/lib/web-dashboard-html.js +66 -0
  88. package/dist/lib/web-dashboard.d.ts +36 -0
  89. package/dist/lib/web-dashboard.js +322 -0
  90. package/node_modules/@codex-ai/plugin/dist/index.d.ts +2 -2
  91. package/node_modules/@codex-ai/plugin/dist/index.js +2 -2
  92. package/node_modules/@codex-ai/plugin/dist/tool.d.ts +42 -42
  93. package/node_modules/@codex-ai/plugin/dist/tool.js +29 -29
  94. package/node_modules/@codex-ai/plugin/package.json +9 -9
  95. package/node_modules/@codex-ai/sdk/dist/index.d.ts +4 -4
  96. package/node_modules/@codex-ai/sdk/dist/index.js +2 -2
  97. package/node_modules/@codex-ai/sdk/package.json +8 -8
  98. package/package.json +209 -207
  99. package/scripts/codex-app-launcher.js +653 -653
  100. package/scripts/codex-app-router.js +446 -402
  101. package/scripts/codex-bin-resolver.js +332 -332
  102. package/scripts/codex-multi-auth.js +65 -65
  103. package/scripts/codex-routing.js +63 -62
  104. package/scripts/codex.js +7143 -7006
  105. package/scripts/install-codex-auth-utils.js +213 -213
  106. package/scripts/mcodex.js +367 -367
  107. package/scripts/postinstall.js +114 -114
  108. package/scripts/preuninstall.js +265 -265
  109. package/skills/codex-auth-setup/SKILL.md +73 -73
  110. package/vendor/codex-ai-plugin/dist/index.d.ts +2 -2
  111. package/vendor/codex-ai-plugin/dist/index.js +2 -2
  112. package/vendor/codex-ai-plugin/dist/tool.d.ts +42 -42
  113. package/vendor/codex-ai-plugin/dist/tool.js +29 -29
  114. package/vendor/codex-ai-plugin/package.json +9 -9
  115. package/vendor/codex-ai-sdk/dist/index.d.ts +4 -4
  116. package/vendor/codex-ai-sdk/dist/index.js +2 -2
  117. package/vendor/codex-ai-sdk/package.json +8 -8
@@ -2,14 +2,15 @@ import { createAutomaticApiCheck, startAutomaticApiChecks } from "./runtime/auto
2
2
  import { startAutomaticAccountChecks } from "./runtime/automatic-account-checks.js";
3
3
  import { createAutomaticSubscriptionCheck } from "./runtime/automatic-subscription-checks.js";
4
4
  import { ClientCancellationError } from "./request/client-cancellation.js";
5
+ import { fetchWithProxy } from "./request/proxy-fetch.js";
5
6
  import { createNativeAccountStorageReader } from "./runtime/native-account-storage.js";
6
7
  import { resetSnapshotQuota } from "./runtime/reset-credits.js";
7
8
  import { createResetCreditService, loadResetCreditState } from "./runtime/account-reset-credits.js";
8
- import { recoverResetQuota, applyConfirmedReset } from "./runtime/reset-credit-routing.js";
9
+ import { recoverResetQuota, applyConfirmedReset, primaryPoolFullyExhausted } from "./runtime/reset-credit-routing.js";
9
10
  import { readSubscriptionQuotaEvent } from "./runtime/subscription-quota-event.js";
10
11
  import { loadQuotaCache } from "./quota-cache.js";
11
12
  import { findQuotaCacheEntryForAccount } from "./quota-readiness.js";
12
- import { subscriptionQuotaPreference, compareSubscriptionQuota } from "./runtime/subscription-quota-order.js";
13
+ import { hasFreshOrdinaryUsagePermission, subscriptionQuotaPreference, compareSubscriptionQuota } from "./runtime/subscription-quota-order.js";
13
14
  import { inferenceAccountKey } from "./runtime/inference-activity.js";
14
15
  import { modelScopeId, workspaceModelScopes } from "./runtime/workspace-model-scopes.js";
15
16
  import { RuntimeCapabilityFailures, classifyCapabilityFailure } from "./runtime/runtime-capability-failures.js";
@@ -32,7 +33,7 @@ import { AccountManager, AUTH_INVALIDATION_MARKER, extractAccountId, } from "./a
32
33
  import { withRoutingMutex } from "./routing-mutex.js";
33
34
  import { getQuotaKey } from "./accounts/rate-limits.js";
34
35
  import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getPreemptiveQuotaEnabled, getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getContextBudgetGuardEnabled, getContextBudgetGuardSoftPercent, getContextBudgetGuardHardPercent, getContextBudgetGuardModelWindowOverrides, getRoutingMutexMode, getSchedulingStrategy, loadPluginConfig, } from "./config.js";
35
- import { CODEX_BASE_URL, HTTP_STATUS, LUNA_RESERVE_MODEL, MAX_RATE_LIMIT_DELAY_MS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
36
+ import { CODEX_BASE_URL, HTTP_STATUS, LUNA_RESERVE_MODEL, MAX_RATE_LIMIT_DELAY_MS, RATE_LIMIT_CACHE_TTL_MS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
36
37
  import { getModelFamily } from "./prompts/codex.js";
37
38
  import { CURRENT_CODEX_MODEL } from "./request/helpers/model-map.js";
38
39
  import { recordRuntimeInferenceRequest, flushRuntimeInferenceActivity, mutateRuntimeObservabilitySnapshot, recordRuntimeAccountRecovery, recordRuntimePoolExhaustion, } from "./runtime/runtime-observability.js";
@@ -829,7 +830,8 @@ function writeUnauthorized(res) {
829
830
  }
830
831
  function writePoolExhausted(params) {
831
832
  const { res, accountManager, family, model, reason } = params;
832
- const waitMs = params.retryAfterMs ?? accountManager.getMinWaitTimeForFamily(family, model);
833
+ const waitMs = params.retryAfterMs ??
834
+ accountManager.getMinWaitTimeForFamily(family, model, params.ineligibleAccountIndexes);
833
835
  const accountCount = accountManager.getAccountCount();
834
836
  const accountSkipReasons = params.accountSkipReasons ?? {};
835
837
  recordRuntimePoolExhaustion({
@@ -879,7 +881,7 @@ export async function startRuntimeRotationProxy(options) {
879
881
  const routingMutexMode = getRoutingMutexMode(pluginConfig);
880
882
  activeAccountManager.setRoutingMutexMode(routingMutexMode);
881
883
  const schedulingStrategy = getSchedulingStrategy(pluginConfig);
882
- const fetchImpl = options.fetchImpl ?? fetch;
884
+ const fetchImpl = options.fetchImpl ?? fetchWithProxy;
883
885
  const host = options.host ?? DEFAULT_HOST;
884
886
  // Defense in depth (runtime-proxy-01): the proxy presents managed OAuth tokens
885
887
  // and must never be reachable off-box. It is loopback-only with NO opt-out —
@@ -1045,6 +1047,7 @@ export async function startRuntimeRotationProxy(options) {
1045
1047
  await Promise.all([automaticChecks?.stop(), automaticApiChecks?.stop()]);
1046
1048
  websocketGateway.close();
1047
1049
  await closeServer(server, sockets);
1050
+ state.subscriptionQuotaRejections?.clear();
1048
1051
  await flushRuntimeInferenceActivity();
1049
1052
  await state.activeAccountManager.flushPendingSave();
1050
1053
  },
@@ -1057,6 +1060,9 @@ export async function startRuntimeRotationProxy(options) {
1057
1060
  schedulingStrategy: state.schedulingStrategy,
1058
1061
  websocketConnections: websocketGateway.stats.connections,
1059
1062
  websocketUpstreamRequests: websocketGateway.stats.upstreamRequests,
1063
+ websocketActiveConnections: websocketGateway.stats.activeConnections,
1064
+ websocketCapacityReclaims: websocketGateway.stats.capacityReclaims,
1065
+ lastWebsocketUpgradeRejection: websocketGateway.stats.lastUpgradeRejection,
1060
1066
  lastWebsocketDisconnect: websocketGateway.stats.lastDisconnect,
1061
1067
  // Redact any email/token material that leaked into a raw upstream or
1062
1068
  // refresh error string before exposing it to status/report consumers
@@ -1603,7 +1609,7 @@ async function handleRequestInner(state, req, res, traceId) {
1603
1609
  }
1604
1610
  }
1605
1611
  };
1606
- const resetCreditState = state.nativeOpenai && isResponsesRequest ? await cachedRead(state, "reset-credits", loadResetCreditState).catch(() => null) : null;
1612
+ let resetCreditState = state.nativeOpenai && isResponsesRequest ? await cachedRead(state, "reset-credits", loadResetCreditState).catch(() => null) : null;
1607
1613
  const quotaForScope = (account, scope) => {
1608
1614
  // Scoped observations belong to this credential and workspace; legacy entries only describe the binding.
1609
1615
  const cached = findQuotaCacheEntryForAccount(subscriptionQuotaCache, account, accountManager.getAccountsSnapshot(), undefined, scope.accountId);
@@ -1723,7 +1729,7 @@ async function handleRequestInner(state, req, res, traceId) {
1723
1729
  if (retryAt > state.now())
1724
1730
  throw new CatalogRetryError(retryAt - state.now());
1725
1731
  backoff.delete(key);
1726
- const scope = state.activeAccountManager.getAccountsSnapshot().flatMap(workspaceModelScopes).find(s => s.id === key && s.enabled);
1732
+ const scope = state.activeAccountManager.getAccountsSnapshot().flatMap(workspaceModelScopes).find(s => s.id === key && s.routable);
1727
1733
  const account = scope ? state.activeAccountManager.getAccountByIndex(scope.accountIndex) : null;
1728
1734
  if (!account || account.enabled === false || account.authInvalidatedAt ||
1729
1735
  (account.cooldownReason === "auth-failure" && (account.coolingDownUntil ?? 0) > state.now()))
@@ -1796,7 +1802,7 @@ async function handleRequestInner(state, req, res, traceId) {
1796
1802
  a.email === reference.email &&
1797
1803
  a.accountId === reference.accountId)));
1798
1804
  const scopes = discovery.flatMap(workspaceModelScopes);
1799
- const enabledKeys = scopes.filter(scope => scope.enabled).map(scope => scope.id);
1805
+ const enabledKeys = scopes.filter(scope => scope.routable).map(scope => scope.id);
1800
1806
  const routableKeys = scopes.filter(scope => scope.routable).map(scope => scope.id);
1801
1807
  const oauthRefresh = modelCatalog.list(enabledKeys);
1802
1808
  // Explicit check waits for all workspaces; picker discovery runs API and OAuth in parallel.
@@ -1875,13 +1881,23 @@ async function handleRequestInner(state, req, res, traceId) {
1875
1881
  models = await refresh;
1876
1882
  else {
1877
1883
  const cached = pickerSnapshot();
1878
- if (cached.length)
1884
+ // An API cache can be warm while this client-version's OAuth
1885
+ // catalog is still cold. Give subscription discovery the same
1886
+ // bounded opportunity as an entirely empty picker.
1887
+ const oauthReady = modelCatalog.cachedList(routableKeys).length > 0 ||
1888
+ routableKeys.every(key => modelCatalog.hasUsablePickerSnapshot(key));
1889
+ if (cached.length && oauthReady)
1879
1890
  models = cached;
1880
1891
  else {
1881
1892
  let timer;
1882
1893
  try {
1883
1894
  models = await Promise.race([refresh, new Promise(resolve => {
1884
- timer = setTimeout(() => resolve(pickerSnapshot()), 2000);
1895
+ timer = setTimeout(() => {
1896
+ // Do not let an incomplete API-only success replace the
1897
+ // native client's complete cached catalog for minutes.
1898
+ const missingSubscription = routableKeys.length > 0 && !modelCatalog.cachedList(routableKeys).length;
1899
+ resolve(missingSubscription && routableKeys.some(key => !modelCatalog.hasUsablePickerSnapshot(key)) ? [] : pickerSnapshot());
1900
+ }, 2000);
1885
1901
  })]);
1886
1902
  }
1887
1903
  finally {
@@ -1890,6 +1906,12 @@ async function handleRequestInner(state, req, res, traceId) {
1890
1906
  }
1891
1907
  }
1892
1908
  }
1909
+ // A completed failed fetch is still unknown, not a successful
1910
+ // empty catalog. Never replace native subscription entries with
1911
+ // an API-only success while a routable catalog is unknown.
1912
+ if (!forceCatalogRefresh && !models.some(model => !/^(api|zdr)\//.test(model.slug)) &&
1913
+ routableKeys.some(key => !modelCatalog.hasUsablePickerSnapshot(key)))
1914
+ models = [];
1893
1915
  res.setHeader("etag", setCatalogEtag(state, models));
1894
1916
  writeJson(res, models.length ? 200 : 503, models.length
1895
1917
  ? { models }
@@ -1948,20 +1970,45 @@ async function handleRequestInner(state, req, res, traceId) {
1948
1970
  observations: state.subscriptionQuotaObservations ??= new Map(), now: state.now(), clearQuotaScheduler: a => state.preemptiveQuotaScheduler.clear(buildQuotaScheduleKey(a, context.family, context.model)) });
1949
1971
  }
1950
1972
  }
1951
- if (state.nativeOpenai && isResponsesRequest && context.model && context.model !== LUNA_RESERVE_MODEL && !isPinned) {
1973
+ const primaryWorkspaceCandidates = () => new Map([...workspaceCandidates].filter(([index]) => {
1974
+ const account = accountManager.getAccountByIndex(index);
1975
+ // Catalog candidates can outlive credential invalidation while preparing a request.
1976
+ return account && account.enabled !== false && !account.authInvalidatedAt &&
1977
+ !policyDecision?.emergencyAccountIndexes?.has(index) && !policyDecision?.blockedAccountIndexes.has(index);
1978
+ }));
1979
+ const emergencyBackupsAllowed = () => state.nativeOpenai && isResponsesRequest &&
1980
+ context.model !== LUNA_RESERVE_MODEL && primaryPoolFullyExhausted(primaryWorkspaceCandidates(), resetCreditState, state.now(), (index, scope) => {
1981
+ const account = accountManager.getAccountByIndex(index);
1982
+ return account ? quotaForScope(account, scope) : undefined;
1983
+ });
1984
+ const recoverPrimaryQuota = async () => {
1985
+ if (!state.nativeOpenai || !isResponsesRequest || !context.model || context.model === LUNA_RESERVE_MODEL || isPinned)
1986
+ return false;
1952
1987
  try {
1953
- await recoverResetQuota({ model: context.model, family: context.family, native: true, pinned: false, manager: accountManager,
1954
- scopes: workspaceCandidates, quotaForScope, priorityByAccount: policyDecision?.priorityByAccount, preferredIndex: storageMeta.pinnedAccountIndex, service: createResetCreditService(accountManager),
1955
- observations: state.subscriptionQuotaObservations ??= new Map(), now: state.now,
1988
+ const observations = state.subscriptionQuotaObservations ??= new Map();
1989
+ const recovered = await recoverResetQuota({ model: context.model, family: context.family, native: true, pinned: false, manager: accountManager,
1990
+ scopes: primaryWorkspaceCandidates(), quotaForScope, priorityByAccount: policyDecision?.priorityByAccount, preferredIndex: storageMeta.pinnedAccountIndex, service: createResetCreditService(accountManager),
1991
+ observations, quotaRejections: state.subscriptionQuotaRejections, now: state.now,
1956
1992
  clearQuotaScheduler: account => state.preemptiveQuotaScheduler.clear(buildQuotaScheduleKey(account, context.family, context.model)),
1957
1993
  });
1994
+ try {
1995
+ resetCreditState = await loadResetCreditState();
1996
+ }
1997
+ catch {
1998
+ resetCreditState = null;
1999
+ }
2000
+ return recovered;
1958
2001
  }
1959
2002
  catch {
2003
+ resetCreditState = null;
1960
2004
  state.status.lastError = "Subscription reset recovery could not be confirmed; no further automatic redemption attempted.";
2005
+ return false;
1961
2006
  }
1962
- // Recovery may have redeemed and written new reset state; the next request re-reads it.
1963
- state.readCache?.delete("reset-credits");
1964
- }
2007
+ finally {
2008
+ state.readCache?.delete("reset-credits");
2009
+ }
2010
+ };
2011
+ await recoverPrimaryQuota();
1965
2012
  // A pinned Luna request can arrive after an earlier turn already proved
1966
2013
  // ordinary subscription quota exhausted. That persisted family marker
1967
2014
  // would make chooseAccount reject the hard pin before any upstream call,
@@ -2012,8 +2059,10 @@ async function handleRequestInner(state, req, res, traceId) {
2012
2059
  // alternative account, so exhausting that local heuristic can only reject a
2013
2060
  // request that the pinned account could serve. Transcription is not inference
2014
2061
  // and must not debit the Codex model pool or claim its inference circuit probe.
2062
+ // Native subscriptions use upstream quota for admission; local scoring must
2063
+ // not synthesize a 429 while actual subscription capacity remains.
2015
2064
  // Transcription uses credential-only admission below.
2016
- const bypassPoolTokenBucket = isPinned || isTranscribeRequest;
2065
+ const bypassPoolTokenBucket = isPinned || isTranscribeRequest || (state.nativeOpenai && isResponsesRequest);
2017
2066
  const refundConsumedPoolToken = (account) => {
2018
2067
  if (!bypassPoolTokenBucket) {
2019
2068
  accountManager.refundToken(account, context.family, context.model);
@@ -2105,8 +2154,10 @@ async function handleRequestInner(state, req, res, traceId) {
2105
2154
  return "retry";
2106
2155
  };
2107
2156
  let runtimeSelectionIterations = 0;
2157
+ let recoveryRetriedAfterExhaustion = false;
2108
2158
  while ((isPinned ||
2109
2159
  attemptedIndexes.size < accountCount ||
2160
+ (!isPinned && !recoveryRetriedAfterExhaustion) ||
2110
2161
  (isTranscribeRequest && !state.nativeOpenai && accountCount === 0 && !reloadedAfterNoAccount)) &&
2111
2162
  transientAttempts < transientAttemptLimit &&
2112
2163
  (!isPinned ||
@@ -2157,6 +2208,14 @@ async function handleRequestInner(state, req, res, traceId) {
2157
2208
  }
2158
2209
  }
2159
2210
  const attemptedForSelection = new Set([...attemptedIndexes, ...catalogExcludedIndexes()]);
2211
+ if (!isPinned && policyDecision?.emergencyAccountIndexes?.size) {
2212
+ const backupsAllowed = emergencyBackupsAllowed();
2213
+ if (!backupsAllowed)
2214
+ for (const index of policyDecision.emergencyAccountIndexes) {
2215
+ attemptedForSelection.add(index);
2216
+ accountSkipReasons.set(index, "policy: emergency backup reserved");
2217
+ }
2218
+ }
2160
2219
  if (isTranscribeRequest) {
2161
2220
  const candidate = chooseTranscriptionAccount({
2162
2221
  accountManager,
@@ -2194,12 +2253,13 @@ async function handleRequestInner(state, req, res, traceId) {
2194
2253
  stickyBoostByAccount: rotationStickyBoost,
2195
2254
  pidOffsetEnabled: state.pidOffsetEnabled,
2196
2255
  schedulingStrategy: state.schedulingStrategy,
2197
- // Only on a RETRY pass. The first pass still honors a cooldown
2198
- // another request left on the pin, so an already-cooling pinned
2199
- // account 503s immediately as before; from the second pass on,
2200
- // this request's own retry budget outranks the cooldown it just
2201
- // created for itself.
2202
- allowPinnedCooldown: isPinned && transientAttempts > 0,
2256
+ // A shared network cooldown is not evidence that the pinned
2257
+ // credentials cannot serve independent concurrent work. Let each
2258
+ // new request try; its own retries remain bounded and backed off.
2259
+ // Other initial cooldowns still block, as do quota/auth/policy
2260
+ // checks and the circuit admission immediately after selection.
2261
+ allowPinnedCooldown: isPinned && (transientAttempts > 0 ||
2262
+ (typeof pinnedIndex === "number" && accountManager.getAccountByIndex(pinnedIndex)?.cooldownReason === "network-error")),
2203
2263
  });
2204
2264
  return result;
2205
2265
  };
@@ -2227,6 +2287,20 @@ async function handleRequestInner(state, req, res, traceId) {
2227
2287
  for (const index of catalogExcludedIndexes())
2228
2288
  accountSkipReasons.set(index, "model-not-supported");
2229
2289
  if (!selected) {
2290
+ // A 429 can be the first evidence that cached ordinary capacity is gone.
2291
+ // Recheck once before failing; only an explicit pre-inference rejection
2292
+ // reaches this path, never an ambiguous or already-streaming generation.
2293
+ if (!isPinned && !recoveryRetriedAfterExhaustion) {
2294
+ recoveryRetriedAfterExhaustion = true;
2295
+ const backupsWereAllowed = emergencyBackupsAllowed();
2296
+ if (await recoverPrimaryQuota()) {
2297
+ attemptedIndexes.clear();
2298
+ accountSkipReasons.clear();
2299
+ continue;
2300
+ }
2301
+ if (!backupsWereAllowed && emergencyBackupsAllowed())
2302
+ continue;
2303
+ }
2230
2304
  if (isTranscribeRequest &&
2231
2305
  !state.nativeOpenai &&
2232
2306
  !reloadedAfterNoAccount) {
@@ -2326,10 +2400,12 @@ async function handleRequestInner(state, req, res, traceId) {
2326
2400
  }
2327
2401
  attemptedIndexes.add(selected.index);
2328
2402
  const quotaScheduleKey = buildQuotaScheduleKey(selected, context.family, context.model);
2403
+ const selectedScope = workspaceCandidates.get(selected.index)?.[0];
2404
+ const nativePermission = selectedScope && hasFreshOrdinaryUsagePermission(quotaForScope(selected, selectedScope), state.now());
2329
2405
  const preemptiveDeferral = state.preemptiveQuotaScheduler.getDeferral(quotaScheduleKey, state.now());
2330
2406
  if (!isTranscribeRequest &&
2331
2407
  context.model !== LUNA_RESERVE_MODEL &&
2332
- preemptiveDeferral.defer &&
2408
+ !nativePermission && preemptiveDeferral.defer &&
2333
2409
  preemptiveDeferral.waitMs > 0) {
2334
2410
  accountSkipReasons.set(selected.index, preemptiveDeferral.reason ?? "quota-near-exhaustion");
2335
2411
  exhaustionReason = "rate-limit";
@@ -2474,7 +2550,8 @@ async function handleRequestInner(state, req, res, traceId) {
2474
2550
  continue;
2475
2551
  }
2476
2552
  let pendingScopes = workspaceCandidates.get(refreshed.account.index);
2477
- if (isTranscribeRequest) {
2553
+ // Routes without model-catalog preparation still use the selected enabled workspace.
2554
+ if (isImageRequest || isTranscribeRequest || isThreadGoalRequest) {
2478
2555
  pendingScopes = workspaceModelScopes(refreshed.account)
2479
2556
  .filter((scope) => scope.routable)
2480
2557
  .sort((a, b) => Number(b.selected) - Number(a.selected) || Number(b.bound) - Number(a.bound));
@@ -2531,14 +2608,10 @@ async function handleRequestInner(state, req, res, traceId) {
2531
2608
  : undefined;
2532
2609
  const disk = latest?.accounts.find(item => item.recordId && item.recordId === refreshed.account.recordId)
2533
2610
  ?? latest?.accounts.find(item => item.accountId === refreshed.account.accountId && sanitizeEmail(item.email) === sanitizeEmail(refreshed.account.email)) ?? retained;
2534
- const workspace = disk?.workspaces?.find(item => item.id.trim() === accountId);
2535
- // Derive the stored binding exactly as workspaceModelScopes does: trimmed,
2536
- // falling back to the token's workspace claim when no id was stored.
2537
- // A stored record carries accessToken; the transient-grace fallback is a live account (access).
2538
2611
  const diskToken = disk?.accessToken ?? disk?.access;
2539
- const diskBoundId = disk ? disk.accountId?.trim() || extractAccountId(diskToken)?.trim() : undefined;
2540
2612
  const stillEligible = disk && disk.enabled !== false && !disk.authInvalidatedAt &&
2541
- (workspace ? workspace.enabled !== false : accountId === diskBoundId);
2613
+ workspaceModelScopes({ ...disk, index: refreshed.account.index, access: diskToken })
2614
+ .some(scope => scope.routable && scope.accountId === accountId);
2542
2615
  if (!stillEligible) {
2543
2616
  writeJson(res, 503, { error: { code: "routing_configuration_changed", message: "Routing eligibility changed while preparing the request. Retry with the current configuration." } });
2544
2617
  return;
@@ -2711,7 +2784,9 @@ async function handleRequestInner(state, req, res, traceId) {
2711
2784
  const key = JSON.stringify([requestScope.id, context.model]);
2712
2785
  if (observations.size >= 1000 && !observations.has(key))
2713
2786
  observations.delete(observations.keys().next().value ?? "");
2714
- observations.set(key, { ...merged, model: context.model ?? "unknown", planType: planType ?? previous?.planType });
2787
+ observations.set(key, { ...merged, model: context.model ?? "unknown", planType: planType ?? previous?.planType,
2788
+ ...(snapshot.status === 200 && hasFreshOrdinaryUsagePermission(previous, state.now()) ? { ordinaryUsageAllowed: true, ordinaryUsageCheckedAt: previous?.ordinaryUsageCheckedAt ?? previous?.updatedAt } : {}),
2789
+ });
2715
2790
  }
2716
2791
  };
2717
2792
  const quotaSnapshot = readQuotaSchedulerSnapshot(upstream.headers, upstream.status, state.now());
@@ -2812,6 +2887,17 @@ async function handleRequestInner(state, req, res, traceId) {
2812
2887
  if (outcome === "retry")
2813
2888
  continue;
2814
2889
  }
2890
+ if (state.nativeOpenai && isResponsesRequest && requestScope && context.model !== LUNA_RESERVE_MODEL && subscriptionQuotaExceeded && !modelAtCapacity) {
2891
+ // A body-only quota rejection is newer evidence than an earlier
2892
+ // native permission. Record no invented quota percentage or permission.
2893
+ // An older in-flight success cannot clear this hint; newer native
2894
+ // permission checks or its bounded lifetime invalidate it during recovery.
2895
+ const rejections = state.subscriptionQuotaRejections ??= new Map();
2896
+ const key = JSON.stringify([requestScope.id, context.model]);
2897
+ if (rejections.size >= 1000 && !rejections.has(key))
2898
+ rejections.delete(rejections.keys().next().value ?? "");
2899
+ rejections.set(key, { key: requestScope.id, accountId: requestScope.accountId, observedAt: state.now() });
2900
+ }
2815
2901
  if (context.model !== LUNA_RESERVE_MODEL && subscriptionQuotaExceeded) {
2816
2902
  state.preemptiveQuotaScheduler.markRateLimited(quotaScheduleKey, retryAfterMs, state.now());
2817
2903
  }
@@ -2824,7 +2910,7 @@ async function handleRequestInner(state, req, res, traceId) {
2824
2910
  (context.model === "gpt-6-luna" || context.model === "gpt-5.6-luna")) {
2825
2911
  const confirmedKey = confirmedQuotaExhaustionKey(context.family, context.model);
2826
2912
  const confirmedResetAt = state.now() +
2827
- Math.min(Math.max(0, Math.floor(retryAfterMs)), MAX_RATE_LIMIT_DELAY_MS);
2913
+ Math.min(Math.max(0, Math.floor(retryAfterMs)), RATE_LIMIT_CACHE_TTL_MS);
2828
2914
  rateLimitState.account.rateLimitResetTimes[confirmedKey] = Math.max(rateLimitState.account.rateLimitResetTimes[confirmedKey] ?? 0, confirmedResetAt);
2829
2915
  }
2830
2916
  rateLimitState.accountManager.saveToDiskDebounced();
@@ -2864,7 +2950,11 @@ async function handleRequestInner(state, req, res, traceId) {
2864
2950
  }
2865
2951
  exhaustionReason = "rate-limit";
2866
2952
  capabilityRejectionIsLatest = false;
2867
- transientAttempts += 1;
2953
+ // Account-local throttles are bounded by distinct accounts/workspaces.
2954
+ // They must not spend the network/server budget before a usable sibling.
2955
+ // Pins and model-wide capacity failures retain the transient bound.
2956
+ if (isPinned || modelAtCapacity)
2957
+ transientAttempts += 1;
2868
2958
  transientExhaustionReason = "rate-limit";
2869
2959
  state.status.retries += 1;
2870
2960
  noteRotation();
@@ -2874,9 +2964,9 @@ async function handleRequestInner(state, req, res, traceId) {
2874
2964
  const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
2875
2965
  const errorCode = extractErrorCodeFromBody(bodyText);
2876
2966
  if (isWorkspaceDisabledError(upstream.status, errorCode, bodyText)) {
2877
- if (requestScope && refreshed.account.workspaces?.length) {
2967
+ if (requestScope?.storedWorkspaceId !== undefined) {
2878
2968
  const workspaceState = responseStateTarget();
2879
- if (workspaceState.accountManager.disableWorkspace(workspaceState.account, requestScope.accountId))
2969
+ if (workspaceState.accountManager.disableWorkspace(workspaceState.account, requestScope.storedWorkspaceId))
2880
2970
  workspaceState.accountManager.saveToDiskDebounced();
2881
2971
  pendingScopes?.shift();
2882
2972
  refundConsumedPoolToken(refreshed.account);
@@ -3111,7 +3201,7 @@ async function handleRequestInner(state, req, res, traceId) {
3111
3201
  // No-op when no reason is recorded, so the hot path stays write-free.
3112
3202
  if (!isTranscribeRequest) {
3113
3203
  const quotaDeferral = state.preemptiveQuotaScheduler.getDeferral(quotaScheduleKey, state.now());
3114
- const nearExhaustionWaitMs = context.model === LUNA_RESERVE_MODEL
3204
+ const nearExhaustionWaitMs = (context.model === LUNA_RESERVE_MODEL || (requestScope && hasFreshOrdinaryUsagePermission(quotaForScope(refreshed.account, requestScope), state.now())))
3115
3205
  ? 0
3116
3206
  : quotaDeferral.defer
3117
3207
  ? quotaDeferral.waitMs
@@ -3382,9 +3472,13 @@ async function handleRequestInner(state, req, res, traceId) {
3382
3472
  // Read live account state, not only selection skip reasons. The final
3383
3473
  // refresh attempt can install an auth cooldown after the last selection
3384
3474
  // pass, so that blocker may never have been written to accountSkipReasons.
3475
+ const backupsReserved = !isPinned && !emergencyBackupsAllowed();
3385
3476
  for (const account of accountManager.getAccountsSnapshot()) {
3386
3477
  if (policyDecision?.blockedAccountIndexes.has(account.index))
3387
3478
  continue;
3479
+ // Selection never offers a reserved backup, so it is not ready capacity.
3480
+ if (backupsReserved && policyDecision?.emergencyAccountIndexes?.has(account.index))
3481
+ continue;
3388
3482
  if (account.enabled === false)
3389
3483
  continue;
3390
3484
  if (!accountManager.hasEnabledWorkspaces(account))
@@ -3409,6 +3503,11 @@ async function handleRequestInner(state, req, res, traceId) {
3409
3503
  return minWaitMs ?? 0;
3410
3504
  })()
3411
3505
  : undefined;
3506
+ const ineligibleAccountIndexes = new Set(policyDecision?.blockedAccountIndexes ?? []);
3507
+ if (!isPinned && !emergencyBackupsAllowed()) {
3508
+ for (const index of policyDecision?.emergencyAccountIndexes ?? [])
3509
+ ineligibleAccountIndexes.add(index);
3510
+ }
3412
3511
  writePoolExhausted({
3413
3512
  res,
3414
3513
  accountManager,
@@ -3416,6 +3515,7 @@ async function handleRequestInner(state, req, res, traceId) {
3416
3515
  model: context.model,
3417
3516
  reason: exhaustionReason,
3418
3517
  retryAfterMs: transcriptionRetryAfterMs,
3518
+ ineligibleAccountIndexes,
3419
3519
  accountSkipReasons: Object.fromEntries([...accountSkipReasons.entries()].map(([index, reason]) => [
3420
3520
  String(index),
3421
3521
  reason,
@@ -5,6 +5,11 @@
5
5
  */
6
6
  import { z } from "zod";
7
7
  export declare const PluginConfigSchema: z.ZodObject<{
8
+ locale: z.ZodOptional<z.ZodEnum<{
9
+ auto: "auto";
10
+ en: "en";
11
+ zh: "zh";
12
+ }>>;
8
13
  codexMode: z.ZodOptional<z.ZodBoolean>;
9
14
  codexRuntimeRotationProxy: z.ZodOptional<z.ZodBoolean>;
10
15
  codexTuiV2: z.ZodOptional<z.ZodBoolean>;
@@ -22,6 +22,7 @@ function schemaLog() {
22
22
  // Plugin Configuration Schema
23
23
  // ============================================================================
24
24
  export const PluginConfigSchema = z.object({
25
+ locale: z.enum(["auto", "en", "zh"]).optional(),
25
26
  codexMode: z.boolean().optional(),
26
27
  codexRuntimeRotationProxy: z.boolean().optional(),
27
28
  codexTuiV2: z.boolean().optional(),
@@ -26,7 +26,9 @@ const byId = (rows) => new Map((rows ?? []).map(row => [row.id, row]));
26
26
  /**
27
27
  * Merge workspaces by id, never by array position. Fields changed on one side
28
28
  * win; enabled/disabledAt move as one unit, and when both sides changed it an
29
- * intentional re-enable wins over a disable. Other two-sided edits conflict.
29
+ * intentional re-enable wins over a disable. Concurrent disables preserve an
30
+ * operator exclusion (no disabledAt) over a recoverable runtime health marker.
31
+ * Other two-sided edits conflict.
30
32
  * Removal follows the account rule: a record removed on either side stays
31
33
  * removed.
32
34
  */
@@ -44,7 +46,13 @@ function mergeWorkspaces(base, disk, local) {
44
46
  continue;
45
47
  }
46
48
  const state = (row) => ({ enabled: row?.enabled, disabledAt: row?.disabledAt });
47
- const stateSource = equal(state(b), state(l)) ? d : equal(state(b), state(d)) ? l : d.enabled !== false ? d : l.enabled !== false ? l : (d.disabledAt ?? 0) >= (l.disabledAt ?? 0) ? d : l;
49
+ const stateSource = equal(state(b), state(l)) ? d
50
+ : equal(state(b), state(d)) ? l
51
+ : d.enabled !== false ? d
52
+ : l.enabled !== false ? l
53
+ : d.disabledAt === undefined ? d
54
+ : l.disabledAt === undefined ? l
55
+ : d.disabledAt >= l.disabledAt ? d : l;
48
56
  const pick = (row) => ({ ...row, enabled: stateSource.enabled, disabledAt: stateSource.disabledAt });
49
57
  const merged = mergeValue(b ? pick(b) : undefined, pick(d), pick(l));
50
58
  if (merged.disabledAt === undefined)
@@ -54,7 +62,7 @@ function mergeWorkspaces(base, disk, local) {
54
62
  const currentId = (row) => row.workspaces?.[row.currentWorkspaceIndex ?? 0]?.id;
55
63
  const chosen = currentId(local) !== currentId(base) ? currentId(local) : currentId(disk);
56
64
  let index = workspaces.findIndex(row => row.id === chosen);
57
- if (index < 0 || workspaces[index]?.enabled === false) {
65
+ if (index < 0) {
58
66
  const enabled = workspaces.findIndex(row => row.enabled !== false);
59
67
  index = enabled >= 0 ? enabled : Math.max(index, 0);
60
68
  }
@@ -86,7 +94,10 @@ function mergeRuntimeAccount(base, disk, local) {
86
94
  const before = base.rateLimitResetTimes?.[key], a = disk.rateLimitResetTimes?.[key], b = local.rateLimitResetTimes?.[key];
87
95
  // An explicit clear is an operation, not a new observation.
88
96
  const clear = before !== undefined && ((a === undefined && b === before) || (b === undefined && a === before) || (a === undefined && b === undefined));
89
- const value = clear ? undefined : Math.max(a ?? 0, b ?? 0);
97
+ // A side that kept the baseline made no observation, so the other side's
98
+ // value wins even when lower (e.g. a load-time revalidation bound).
99
+ // Only two concurrent observations keep the later deadline.
100
+ const value = clear ? undefined : a === before ? b : b === before ? a : Math.max(a ?? 0, b ?? 0);
90
101
  if (value !== undefined)
91
102
  limits[key] = value;
92
103
  }
@@ -0,0 +1 @@
1
+ export declare function renderDashboardHtml(token: string | null, locale?: "en" | "zh"): string;
@@ -0,0 +1,66 @@
1
+ const DASHBOARD_HTML = `
2
+ <!doctype html>
3
+ <html lang="zh-CN">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width,initial-scale=1">
7
+ <title>Codex Multi-Auth · 号池监控</title>
8
+ <style>
9
+ :root{color-scheme:dark;--bg:#09111f;--panel:#101c2f;--panel2:#14243b;--line:#223653;--text:#edf5ff;--muted:#8ea5c2;--green:#36d399;--amber:#f7c95c;--red:#fb7185;--blue:#65a8ff;--shadow:0 14px 40px rgba(0,0,0,.25)}
10
+ *{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 20% 0,#17345a 0,transparent 38%),var(--bg);color:var(--text);font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
11
+ .shell{max-width:1440px;margin:0 auto;padding:28px 26px 48px}.top{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:26px}.brand{display:flex;gap:14px;align-items:center}.mark{width:44px;height:44px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(135deg,#4b8dff,#55d6ae);color:#07111f;font-weight:900;font-size:18px;box-shadow:0 8px 24px #3b82f655}.eyebrow{color:var(--blue);font-size:12px;letter-spacing:.12em;text-transform:uppercase}.title{font-size:28px;font-weight:800;letter-spacing:-.03em}.subtitle{color:var(--muted);margin-top:2px}.actions{display:flex;gap:10px;align-items:center}.button{border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:9px;padding:9px 13px;cursor:pointer}.button:hover{border-color:var(--blue);background:var(--panel2)}.live{display:flex;gap:7px;align-items:center;color:var(--muted);font-size:12px}.dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 10px var(--green)}.dot.off{background:var(--red);box-shadow:0 0 10px var(--red)}
12
+ .summary{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:12px;margin-bottom:22px}.card,.panel{background:linear-gradient(145deg,#122139dd,#0e192add);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow)}.card{padding:17px}.label{color:var(--muted);font-size:12px}.value{font-size:26px;font-weight:800;margin-top:7px}.value.green{color:var(--green)}.value.amber{color:var(--amber)}.value.red{color:var(--red)}.value.blue{color:var(--blue)}
13
+ .grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(280px,.7fr);gap:18px;align-items:start}.panel{padding:18px}.panel-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.panel-title{font-size:16px;font-weight:750}.panel-meta{color:var(--muted);font-size:12px}.accounts{display:grid;gap:10px}.account{padding:15px;border:1px solid var(--line);border-radius:11px;background:#0e1a2caa}.account-head{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.account-name{font-weight:700;overflow-wrap:anywhere}.account-meta{color:var(--muted);font-size:12px;margin-top:3px}.badge{border-radius:99px;padding:3px 9px;font-size:11px;white-space:nowrap;border:1px solid #ffffff18}.badge.ready{color:var(--green);background:#36d39918}.badge.login{color:var(--red);background:#fb718518}.badge.disabled{color:var(--muted);background:#8ea5c218}.badge.current{color:var(--blue);background:#65a8ff18}.account-stats{display:grid;grid-template-columns:1fr 1fr 120px;gap:15px;margin-top:14px}.quota{min-width:0}.quota-row{display:flex;justify-content:space-between;gap:7px;color:var(--muted);font-size:11px;margin-top:8px}.bar{height:8px;border-radius:99px;background:#243752;overflow:hidden;margin-top:5px}.fill{height:100%;border-radius:99px;background:linear-gradient(90deg,var(--green),#7de2ab)}.fill.warn{background:linear-gradient(90deg,var(--amber),#ff9e5c)}.fill.danger{background:linear-gradient(90deg,var(--red),#ffadad)}.risk{text-align:right}.risk-num{font-size:23px;font-weight:800}.risk-low{color:var(--green)}.risk-medium{color:var(--amber)}.risk-high{color:var(--red)}.reason{color:var(--muted);font-size:12px;margin-top:8px;overflow-wrap:anywhere}.empty{padding:35px;text-align:center;color:var(--muted)}.subhead{margin:14px 0 4px;color:var(--muted);font-size:11px;letter-spacing:.1em;text-transform:uppercase}.side{display:grid;gap:18px}.kv{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #ffffff0d}.kv:last-child{border-bottom:0}.kv b{font-weight:650;text-align:right;overflow-wrap:anywhere}.notice{padding:12px;border-radius:10px;background:#f7c95c13;border:1px solid #f7c95c3d;color:#fbe6a2;font-size:12px}.error{padding:12px;border-radius:10px;background:#fb718513;border:1px solid #fb71853d;color:#ffc2cb;font-size:12px}.foot{color:var(--muted);font-size:12px;margin-top:20px;text-align:center}
14
+ @media(max-width:1050px){.summary{grid-template-columns:repeat(3,minmax(0,1fr))}.grid{grid-template-columns:1fr}.side{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:680px){.shell{padding:18px 13px 36px}.top{display:block}.actions{margin-top:15px;justify-content:space-between}.summary{grid-template-columns:repeat(2,minmax(0,1fr))}.account-stats{grid-template-columns:1fr 1fr}.risk{text-align:left}.side{grid-template-columns:1fr}.title{font-size:23px}}
15
+ </style>
16
+ </head>
17
+ <body>
18
+ <main class="shell">
19
+ <header class="top"><div class="brand"><div class="mark">MA</div><div><div class="eyebrow">Codex Multi-Auth</div><div class="title" data-i18n="title">号池实时监控</div><div class="subtitle" data-i18n="subtitle">账号、配额与轮换状态</div></div></div><div class="actions"><div class="live"><span id="dot" class="dot"></span><span id="connection" data-i18n="connecting">连接中…</span></div><button id="lang" class="button">EN</button><button id="refresh" class="button" data-i18n="refresh">刷新</button></div></header>
20
+ <section id="summary" class="summary"></section>
21
+ <div id="error" class="error" hidden></div>
22
+ <section class="grid"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="accounts">账号池</div><div id="updated" class="panel-meta">—</div></div><div id="accounts" class="accounts"></div></section><aside class="side"><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="routing">路由</div></div><div id="routing"></div></section><section class="panel"><div class="panel-head"><div class="panel-title" data-i18n="runtime">运行时</div></div><div id="runtime"></div></section></aside></section>
23
+ <div class="foot" data-i18n="foot">数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。</div>
24
+ </main>
25
+ <script>
26
+ (function(){
27
+ var token="__CMA_TOKEN__";
28
+ var lang="__CMA_LANG__";
29
+ var text={zh:{title:"号池实时监控",subtitle:"账号、配额与轮换状态",connecting:"连接中…",connected:"实时连接",offline:"连接中断",refresh:"刷新",accounts:"账号池",apiAccounts:"API/ZDR 账号",routing:"路由",runtime:"运行时",total:"总账号",ready:"可用",login:"需重新登录",disabled:"已禁用",enabled:"已启用",recommend:"推荐账号",active:"当前账号",requests:"请求",success:"成功",failed:"失败",last:"最近请求",quota:"配额",risk:"风险",unknown:"未知",reason:"原因",primary:"主窗口",secondary:"次窗口",priority:"优先级",models:"可见模型",foot:"数据来自本机号池;页面默认仅监听回环地址,按 Ctrl-C 停止服务。"},en:{title:"Pool live monitor",subtitle:"Accounts, quota and routing",connecting:"Connecting…",connected:"Live",offline:"Disconnected",refresh:"Refresh",accounts:"Account pool",apiAccounts:"API/ZDR accounts",routing:"Routing",runtime:"Runtime",total:"Accounts",ready:"Ready",login:"Re-login needed",disabled:"Disabled",enabled:"Enabled",recommend:"Recommended",active:"Current",requests:"Requests",success:"Success",failed:"Failed",last:"Last request",quota:"Quota",risk:"Risk",unknown:"Unknown",reason:"Reason",primary:"primary",secondary:"secondary",priority:"priority",models:"visible models",foot:"Data comes from the local pool; the server listens on loopback by default. Ctrl-C stops it."}};
30
+ function t(k){return text[lang][k]||k}
31
+ function label(v){var s=String(v==null?"":v);return lang==="zh"?s.replace(/^Account /,"账号 ").replace(/Token account/g,"令牌账号").replace(/, id:/g,",ID:").replace(/, /g,","):s}
32
+ function reasonText(v){var s=String(v==null?"—":v);if(lang!=="zh")return s;return s.replace("token-invalid — re-login needed","令牌无效——需要重新登录").replace("primary quota ","主窗口配额 ").replace(" secondary quota "," 次窗口配额 ").replace(" used","已使用").replace("account is disabled","账号已禁用").replace("quota cache exhausted","配额缓存已用尽")}
33
+ function esc(v){return String(v==null?"":v).replace(/[&<>"']/g,function(c){return({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c]})}
34
+ function api(path){return path+(path.indexOf("?")>=0?"&":"?")+"token="+encodeURIComponent(token)}
35
+ function ago(ms){if(typeof ms!=="number"||!isFinite(ms))return "—";var s=Math.max(0,Math.round(ms/1000));if(s<60)return s+"s";if(s<3600)return Math.floor(s/60)+"m";if(s<86400)return Math.floor(s/3600)+"h";return Math.floor(s/86400)+"d"}
36
+ function statusClass(a){var m=(a.markers||[]).join(" ");if(a.enabled===false)return "disabled";if(/invalid|sign-in|re-login/i.test(m))return "login";return "ready"}
37
+ function winLabel(w,key){var m=w&&w.windowMinutes;if(typeof m==="number"&&isFinite(m)&&m>0){var zh=lang==="zh";if(m%1440===0)return (m/1440)+(zh?"天":"d");if(m%60===0)return (m/60)+(zh?"小时":"h");return m+(zh?"分钟":"m")}return t(key)}
38
+ function bar(window,key){if(!window||typeof window.usedPercent!=="number")return '<div class="label">'+t("unknown")+"</div>";var p=Math.max(0,Math.min(100,Math.round(window.usedPercent)));var cls=p>=90?" danger":p>=75?" warn":"";return '<div class="quota-row"><span>'+esc(winLabel(window,key))+" "+t("quota")+'</span><span>'+p+"% / "+(100-p)+"%</span></div><div class="bar"><div class="fill'+cls+'" style="width:'+p+'%"></div></div>'}
39
+ function draw(data){window.__data=data;var all=data.accounts||[];var ready=all.filter(function(a){return a.enabled!==false&&statusClass(a)==="ready"}).length;var login=all.filter(function(a){return statusClass(a)==="login"}).length;var disabled=all.filter(function(a){return a.enabled===false}).length;var rec=data.recommendedIndex==null?"—":"#"+(data.recommendedIndex+1);var rt=data.runtime||{};document.getElementById("summary").innerHTML='<div class="card"><div class="label">'+t("total")+'</div><div class="value blue">'+(data.totalAccountCount||all.length)+'</div></div><div class="card"><div class="label">'+t("ready")+'</div><div class="value green">'+ready+'</div></div><div class="card"><div class="label">'+t("login")+'</div><div class="value red">'+login+'</div></div><div class="card"><div class="label">'+t("disabled")+'</div><div class="value">'+disabled+'</div></div><div class="card"><div class="label">'+t("recommend")+'</div><div class="value amber">'+rec+'</div></div>';
40
+ document.getElementById("accounts").innerHTML=all.length?all.map(function(a){var sub=a.subscription||{};var q=a.quota||{};var risk=a.forecastRiskScore==null?"—":a.forecastRiskScore+"/100";var rl=a.forecastRiskLevel||"low";var badges=(a.markers||[]).map(function(m){return '<span class="badge '+(/current/.test(m)?"current":statusClass(a))+'">'+esc(label(m))+'</span>'}).join(" ");return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||("Account "+(a.index+1))))+'</div><div class="account-meta">'+esc(sub.planLabel||t("unknown"))+" · "+esc(label(sub.selectedWorkspace||""))+'</div></div><div>'+badges+' <span class="badge '+statusClass(a)+'">'+(statusClass(a)==="ready"?t("ready"):statusClass(a)==="login"?t("login"):t("disabled"))+'</span></div></div><div class="account-stats"><div class="quota">'+bar(q.primary,"primary")+bar(q.secondary,"secondary")+'</div><div><div class="label">'+t("last")+'</div><div class="value" style="font-size:18px">'+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div><div class="reason">'+t("reason")+": "+esc(reasonText(a.reason||"—"))+'</div></div><div class="risk"><div class="label">'+t("risk")+'</div><div class="risk-num risk-'+esc(rl)+'">'+esc(risk)+"</div></div></div></article>"}).join(""):'<div class="empty">'+t("unknown")+"</div>";
41
+ var apis=(data.apiAccounts||[]).map(function(a){return '<article class="account"><div class="account-head"><div><div class="account-name">'+esc(label(a.label||"API"))+'</div><div class="account-meta">'+esc(a.kind||"api")+" · "+t("priority")+" "+esc(a.priority==null?"—":a.priority)+'</div></div><div><span class="badge '+(a.enabled===false?"disabled":"ready")+'">'+(a.enabled===false?t("disabled"):t("enabled"))+'</span></div></div><div class="account-meta">'+t("models")+": "+esc((a.visibleModels||[]).length||"—")+" · "+t("last")+": "+ago(a.lastInferenceRequestAt?Date.now()-a.lastInferenceRequestAt:null)+'</div></article>'}).join("");
42
+ if(apis)document.getElementById("accounts").innerHTML+='<div class="subhead">'+t("apiAccounts")+"</div>"+apis;
43
+ var recReason=data.recommendationReason||"—";document.getElementById("routing").innerHTML='<div class="kv"><span>'+t("active")+'</span><b>'+esc(data.activeIndex==null?"—":"#"+(data.activeIndex+1))+'</b></div><div class="kv"><span>'+t("recommend")+'</span><b>'+esc(rec)+"</b></div><div class="kv"><span>'+t("reason")+'</span><b>'+esc(recReason)+'</b></div>';
44
+ document.getElementById("runtime").innerHTML='<div class="kv"><span>'+t("requests")+'</span><b>'+esc(rt.responsesRequests==null?"—":rt.responsesRequests)+'</b></div><div class="kv"><span>'+t("success")+'</span><b>'+esc(rt.successfulRequests==null?"—":rt.successfulRequests)+'</b></div><div class="kv"><span>'+t("failed")+'</span><b>'+esc(rt.failedRequests==null?"—":rt.failedRequests)+'</b></div><div class="kv"><span>'+t("last")+'</span><b>'+esc(data.runtimeInUseIndex==null?"—":"#"+(data.runtimeInUseIndex+1))+'</b></div>';
45
+ document.getElementById("updated").textContent=new Date().toLocaleTimeString();document.getElementById("error").hidden=true;document.getElementById("dot").className="dot";document.getElementById("connection").textContent=t("connected")
46
+ }
47
+ function setLang(){document.documentElement.lang=lang==="zh"?"zh-CN":"en";document.querySelectorAll("[data-i18n]").forEach(function(e){e.textContent=t(e.getAttribute("data-i18n"))});document.getElementById("lang").textContent=lang==="zh"?"EN":"中文";if(window.__data)draw(window.__data)}
48
+ function failed(e){document.getElementById("error").hidden=false;document.getElementById("error").textContent=e&&e.message?e.message:"Request failed";document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}
49
+ function load(){fetch(api("/api/status"),{cache:"no-store"}).then(function(r){if(!r.ok)throw Error("HTTP "+r.status);return r.json()}).then(draw).catch(failed)}
50
+ document.getElementById("refresh").onclick=load;document.getElementById("lang").onclick=function(){lang=lang==="zh"?"en":"zh";setLang()};setLang();load();try{var es=new EventSource(api("/events"));es.onmessage=function(e){try{draw(JSON.parse(e.data))}catch(_){}};es.onerror=function(){document.getElementById("dot").className="dot off";document.getElementById("connection").textContent=t("offline")}}catch(_){setInterval(load,5000)}
51
+ })();
52
+ </script>
53
+ </body>
54
+ </html>`;
55
+ export function renderDashboardHtml(token, locale = "zh") {
56
+ const safeToken = JSON.stringify(token ?? "")
57
+ .slice(1, -1)
58
+ .replace(/</g, "\\u003c")
59
+ .replace(/>/g, "\\u003e")
60
+ .replace(/&/g, "\\u0026")
61
+ .replace(/\u2028/g, "\\u2028")
62
+ .replace(/\u2029/g, "\\u2029");
63
+ return DASHBOARD_HTML
64
+ .replace("__CMA_TOKEN__", safeToken)
65
+ .replace("__CMA_LANG__", locale === "zh" ? "zh" : "en");
66
+ }
@@ -0,0 +1,36 @@
1
+ import { type Server, type ServerResponse } from "node:http";
2
+ import { type OutputLocale } from "./locale.js";
3
+ export interface WebStatusSnapshot {
4
+ [key: string]: unknown;
5
+ }
6
+ export interface WebServerOptions {
7
+ host?: string;
8
+ port?: number;
9
+ token?: string | null;
10
+ refreshMs?: number;
11
+ getStatus: () => Promise<WebStatusSnapshot>;
12
+ openBrowser?: (url: string) => void;
13
+ locale?: OutputLocale;
14
+ /** Called when an SSE client is dropped for falling behind on broadcasts. */
15
+ onSseClientDrop?: (client: ServerResponse) => void;
16
+ }
17
+ export interface WebServerHandle {
18
+ server: Server;
19
+ url: string;
20
+ token: string | null;
21
+ close: () => Promise<void>;
22
+ }
23
+ export declare function startWebServer(options: WebServerOptions): Promise<WebServerHandle>;
24
+ export declare function runWebCommand(args: string[], deps: {
25
+ getStatus: () => Promise<WebStatusSnapshot>;
26
+ logInfo?: (message: string) => void;
27
+ openBrowser?: (url: string) => void;
28
+ locale?: OutputLocale;
29
+ }): Promise<number>;
30
+ export declare function parseWebCommandArgs(args: string[]): {
31
+ host: string;
32
+ port: number;
33
+ token: string | null;
34
+ refreshMs: number;
35
+ openBrowser: boolean;
36
+ };