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
@@ -1,15 +1,15 @@
1
- {
2
- "$schema": "./schema/config.schema.json",
3
- "plugin": ["codex-multi-auth"],
4
-
5
- "provider": {
6
- "openai": {
7
- "options": {
8
- "store": false
9
- }
10
- }
11
- },
12
- "model": "openai/gpt-5.6-sol"
13
- }
14
-
15
-
1
+ {
2
+ "$schema": "./schema/config.schema.json",
3
+ "plugin": ["codex-multi-auth"],
4
+
5
+ "provider": {
6
+ "openai": {
7
+ "options": {
8
+ "store": false
9
+ }
10
+ }
11
+ },
12
+ "model": "openai/gpt-5.6-sol"
13
+ }
14
+
15
+
@@ -31,6 +31,14 @@
31
31
  "pluginConfig": {
32
32
  "type": "object",
33
33
  "properties": {
34
+ "locale": {
35
+ "type": "string",
36
+ "enum": [
37
+ "auto",
38
+ "en",
39
+ "zh"
40
+ ]
41
+ },
34
42
  "codexMode": {
35
43
  "type": "boolean"
36
44
  },
package/dist/index.js CHANGED
@@ -672,12 +672,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
672
672
  capabilityPolicy: capabilityPolicyStore,
673
673
  });
674
674
  }
675
- catch (policyError) {
676
- logWarn("Runtime policy evaluation failed open.", {
677
- error: policyError instanceof Error
678
- ? policyError.message
679
- : String(policyError),
680
- });
675
+ catch {
676
+ logWarn("Runtime policy evaluation failed; request blocked.");
681
677
  }
682
678
  usageRecorder = createRuntimeUsageRecorder({
683
679
  source: "plugin-host",
@@ -692,6 +688,25 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
692
688
  statusCode: null,
693
689
  errorCode: "plugin_host_request_failed",
694
690
  };
691
+ if (!runtimePolicyDecision) {
692
+ runtimeMetrics.failedRequests += 1;
693
+ runtimeMetrics.lastError = "Runtime policy unavailable";
694
+ syncRuntimeObservability(requestTraceId);
695
+ usageCompletion = {
696
+ outcome: "blocked",
697
+ statusCode: 503,
698
+ errorCode: "runtime_policy_unavailable",
699
+ };
700
+ return new Response(JSON.stringify({
701
+ error: {
702
+ message: "Runtime policy could not be loaded for this local request.",
703
+ code: "runtime_policy_unavailable",
704
+ },
705
+ }), {
706
+ status: 503,
707
+ headers: { "content-type": "application/json; charset=utf-8" },
708
+ });
709
+ }
695
710
  if (runtimePolicyDecision && !runtimePolicyDecision.allowed) {
696
711
  runtimeMetrics.failedRequests += 1;
697
712
  runtimeMetrics.lastError = "Runtime policy blocked request";
@@ -715,6 +730,12 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
715
730
  },
716
731
  });
717
732
  }
733
+ // This host cannot prove ordinary capacity and permitted reset
734
+ // recovery are exhausted, so emergency accounts stay reserved.
735
+ const blockedAccountIndexes = new Set([
736
+ ...(runtimePolicyDecision?.blockedAccountIndexes ?? []),
737
+ ...(runtimePolicyDecision?.emergencyAccountIndexes ?? []),
738
+ ]);
718
739
  const abortSignal = requestInit?.signal ?? init?.signal ?? null;
719
740
  const sleep = createAbortableSleep(abortSignal);
720
741
  const poolCooldownRemainingMs = getPoolExhaustionCooldownRemaining();
@@ -843,7 +864,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
843
864
  typeof preferredSessionAccountIndex === "number") {
844
865
  usedPreferredSessionAccount = true;
845
866
  if (accountManager.isAccountAvailableForFamily(preferredSessionAccountIndex, modelFamily, model) &&
846
- !runtimePolicyDecision?.blockedAccountIndexes.has(preferredSessionAccountIndex)) {
867
+ !blockedAccountIndexes.has(preferredSessionAccountIndex)) {
847
868
  account = accountManager.getAccountByIndex(preferredSessionAccountIndex);
848
869
  if (account) {
849
870
  account.lastUsed = Date.now();
@@ -858,13 +879,14 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
858
879
  account = accountManager.getCurrentOrNextForFamilyHybrid(modelFamily, model, {
859
880
  pidOffsetEnabled,
860
881
  scoreBoostByAccount: capabilityBoostByAccount,
882
+ blockedAccountIndexes,
861
883
  });
862
884
  }
863
885
  if (!account || attempted.has(account.index)) {
864
886
  break;
865
887
  }
866
888
  attempted.add(account.index);
867
- if (runtimePolicyDecision?.blockedAccountIndexes.has(account.index)) {
889
+ if (blockedAccountIndexes.has(account.index)) {
868
890
  continue;
869
891
  }
870
892
  // Log account selection for debugging rotation
@@ -1451,7 +1473,7 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1451
1473
  runtimeMetrics.cumulativeLatencyMs += fetchLatencyMs;
1452
1474
  let responseForSuccess = response;
1453
1475
  if (isStreaming) {
1454
- const streamFallbackCandidateOrder = buildAdaptiveStreamFailoverCandidateOrder(account.index, accountSnapshotList);
1476
+ const streamFallbackCandidateOrder = buildAdaptiveStreamFailoverCandidateOrder(account.index, accountSnapshotList.filter((candidate) => !blockedAccountIndexes.has(candidate.index)));
1455
1477
  runtimeMetrics.lastStreamFailoverCandidateCount =
1456
1478
  streamFallbackCandidateOrder.length;
1457
1479
  runtimeMetrics.streamFailoverCandidatesConsidered +=
@@ -1472,7 +1494,8 @@ export const OpenAIOAuthPlugin = async ({ client }) => {
1472
1494
  if (abortSignal?.aborted) {
1473
1495
  return null;
1474
1496
  }
1475
- if (!accountManager.isAccountAvailableForFamily(candidateIndex, modelFamily, model)) {
1497
+ if (blockedAccountIndexes.has(candidateIndex) ||
1498
+ !accountManager.isAccountAvailableForFamily(candidateIndex, modelFamily, model)) {
1476
1499
  continue;
1477
1500
  }
1478
1501
  const fallbackAccount = accountManager.getAccountByIndex(candidateIndex);
@@ -7,6 +7,8 @@ export interface AccountPolicy {
7
7
  priority?: number;
8
8
  /** Opt in to periodic first-use completion for unused personal subscriptions. */
9
9
  autoPrime?: boolean;
10
+ /** Pool backup only, after primary subscriptions and reset credits are exhausted. */
11
+ emergencyOnly?: boolean;
10
12
  paused: boolean;
11
13
  drained: boolean;
12
14
  note: string | null;
@@ -37,6 +37,7 @@ function normalizePolicy(key, value) {
37
37
  weight: normalizeWeight(record.weight),
38
38
  priority: normalizePriority(record.priority),
39
39
  autoPrime: record.autoPrime === true,
40
+ emergencyOnly: record.emergencyOnly === true,
40
41
  paused: record.paused === true,
41
42
  drained: record.drained === true,
42
43
  note: note.length > 0 ? note.slice(0, 500) : null,
@@ -112,7 +113,7 @@ export async function loadAccountPolicyStore(options = {}) {
112
113
  if (!key.startsWith("sha256:"))
113
114
  continue;
114
115
  // Legacy rows may omit flags; explicit malformed values are not OFF.
115
- if (!isRecord(row) || ["autoPrime", "paused", "drained"].some(flag => flag in row && typeof row[flag] !== "boolean")) {
116
+ if (!isRecord(row) || ["autoPrime", "emergencyOnly", "paused", "drained"].some(flag => flag in row && typeof row[flag] !== "boolean")) {
116
117
  throw new Error("Invalid account policy row");
117
118
  }
118
119
  }
@@ -241,6 +242,7 @@ export function upsertAccountPolicy(store, accountKey, mutate, now = Date.now())
241
242
  next.weight = normalizeWeight(next.weight);
242
243
  next.priority = normalizePriority(next.priority);
243
244
  next.autoPrime = next.autoPrime === true;
245
+ next.emergencyOnly = next.emergencyOnly === true;
244
246
  next.updatedAt = stampUpdatedAt(now, store.accounts[accountKey]?.updatedAt);
245
247
  store.accounts[accountKey] = next;
246
248
  return next;
@@ -15,6 +15,12 @@ export interface RateLimitState {
15
15
  export interface RateLimitedEntity {
16
16
  rateLimitResetTimes: RateLimitState;
17
17
  }
18
+ /**
19
+ * Bound legacy persisted routing deadlines once, when loading the account.
20
+ * These deadlines schedule revalidation; quota snapshots retain upstream resets.
21
+ * Never call this on every selection: that would slide the deadline indefinitely.
22
+ */
23
+ export declare function boundRateLimitCache(times: RateLimitState, now?: number): RateLimitState;
18
24
  export declare function clearExpiredRateLimits(entity: RateLimitedEntity): void;
19
25
  /**
20
26
  * Remove every rate-limit reset entry from `entity`, regardless of whether the
@@ -2,7 +2,7 @@
2
2
  * Rate limiting utilities for account management.
3
3
  * Extracted from accounts.ts to reduce module size and improve cohesion.
4
4
  */
5
- import { LUNA_RESERVE_MODEL } from "../constants.js";
5
+ import { LUNA_RESERVE_MODEL, RATE_LIMIT_CACHE_TTL_MS } from "../constants.js";
6
6
  import { nowMs } from "../utils.js";
7
7
  export function parseRateLimitReason(code) {
8
8
  if (!code)
@@ -28,6 +28,19 @@ export function clampNonNegativeInt(value, fallback) {
28
28
  }
29
29
  return value < 0 ? 0 : Math.floor(value);
30
30
  }
31
+ /**
32
+ * Bound legacy persisted routing deadlines once, when loading the account.
33
+ * These deadlines schedule revalidation; quota snapshots retain upstream resets.
34
+ * Never call this on every selection: that would slide the deadline indefinitely.
35
+ */
36
+ export function boundRateLimitCache(times, now = nowMs()) {
37
+ return Object.fromEntries(Object.entries(times).map(([key, until]) => [
38
+ key,
39
+ typeof until === "number"
40
+ ? Math.min(until, now + RATE_LIMIT_CACHE_TTL_MS)
41
+ : until,
42
+ ]));
43
+ }
31
44
  export function clearExpiredRateLimits(entity) {
32
45
  const now = nowMs();
33
46
  const keys = Object.keys(entity.rateLimitResetTimes);
@@ -70,6 +70,7 @@ export declare class AccountManager {
70
70
  private readonly persistedWorkspaceSelections;
71
71
  private readonly persistedWorkspaces;
72
72
  private hadPersistedStorage;
73
+ private hasUnpersistedRateLimitBounds;
73
74
  private readonly loadedAccountStorageSnapshot;
74
75
  private cursorByFamily;
75
76
  private currentAccountIndexByFamily;
@@ -334,7 +335,11 @@ export declare class AccountManager {
334
335
  commitRefreshedAuth(source: Pick<ManagedAccount, "index" | "accountId" | "email" | "refreshToken">, auth: OAuthAuthDetails): Promise<ManagedAccount | null>;
335
336
  toAuthDetails(account: ManagedAccount): Auth;
336
337
  getMinWaitTime(): number;
337
- getMinWaitTimeForFamily(family: ModelFamily, model?: string | null): number;
338
+ /**
339
+ * Accounts in `excludedIndexes` (policy-blocked or reserved for this request)
340
+ * are not capacity the caller can retry into, so they never shorten the wait.
341
+ */
342
+ getMinWaitTimeForFamily(family: ModelFamily, model?: string | null, excludedIndexes?: ReadonlySet<number>): number;
338
343
  /**
339
344
  * Walks forward from `start` (inclusive) looking for the next enabled
340
345
  * account, wrapping around at most once. Returns -1 when every account in
@@ -9,7 +9,7 @@ import { MODEL_FAMILIES } from "./request/helpers/model-map.js";
9
9
  import { getHealthTracker, getTokenTracker, resetTrackers, selectHybridAccount, } from "./rotation.js";
10
10
  import { withRoutingMutex, } from "./routing-mutex.js";
11
11
  import { nowMs } from "./utils.js";
12
- import { ERROR_MESSAGES, HTTP_STATUS, LUNA_RESERVE_MODEL, MAX_RATE_LIMIT_DELAY_MS } from "./constants.js";
12
+ import { ERROR_MESSAGES, HTTP_STATUS, LUNA_RESERVE_MODEL, RATE_LIMIT_CACHE_TTL_MS } from "./constants.js";
13
13
  import { CodexAuthError } from "./errors.js";
14
14
  import { loadCodexCliState, } from "./codex-cli/state.js";
15
15
  import { syncAccountStorageFromCodexCli } from "./codex-cli/sync.js";
@@ -21,7 +21,7 @@ export { extractAccountId, extractAccountEmail, getAccountIdCandidates, selectBe
21
21
  export { parseRateLimitReason, getQuotaKey, clampNonNegativeInt, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForQuotaKey, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
22
22
  export { lookupCodexCliTokensByEmail, isCodexCliSyncEnabled, } from "./codex-cli/state.js";
23
23
  import { codexCliAccountIdFor, extractAccountId, extractAccountEmail, shouldUpdateAccountIdFromToken, sanitizeEmail, } from "./auth/token-utils.js";
24
- import { clampNonNegativeInt, getQuotaKey, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
24
+ import { boundRateLimitCache, clampNonNegativeInt, getQuotaKey, clearExpiredRateLimits, clearAllRateLimits, isRateLimitedForFamily, formatWaitTime, } from "./accounts/rate-limits.js";
25
25
  const log = createLogger("accounts");
26
26
  let nextRuntimeCircuitKeyId = 0;
27
27
  function getAccountCircuitKey(account) {
@@ -142,6 +142,7 @@ export class AccountManager {
142
142
  persistedWorkspaceSelections = new WeakMap();
143
143
  persistedWorkspaces = new WeakMap();
144
144
  hadPersistedStorage = false;
145
+ hasUnpersistedRateLimitBounds = false;
145
146
  loadedAccountStorageSnapshot;
146
147
  cursorByFamily = initFamilyState(0);
147
148
  currentAccountIndexByFamily = initFamilyState(-1);
@@ -201,6 +202,19 @@ export class AccountManager {
201
202
  }
202
203
  const manager = new AccountManager(authFallback, sourceOfTruthStorage);
203
204
  await manager.hydrateFromCodexCli();
205
+ if (manager.hasUnpersistedRateLimitBounds) {
206
+ // Short-lived wrappers may exit without another save; persisting the
207
+ // bound now keeps each restart from re-bounding from its own load time.
208
+ try {
209
+ await manager.saveToDisk(true);
210
+ manager.hasUnpersistedRateLimitBounds = false;
211
+ }
212
+ catch (error) {
213
+ log.debug("Failed to persist bounded rate-limit deadlines", {
214
+ error: String(error),
215
+ });
216
+ }
217
+ }
204
218
  return manager;
205
219
  }
206
220
  /** @internal True when construction received a parsed/recovered storage snapshot. */
@@ -337,6 +351,11 @@ export class AccountManager {
337
351
  return null;
338
352
  }
339
353
  const matchesFallback = !!authFallback && fallbackMatchedRowIndex === index;
354
+ const storedRateLimits = account.rateLimitResetTimes ?? {};
355
+ const rateLimitResetTimes = boundRateLimitCache(storedRateLimits, baseNow);
356
+ if (Object.entries(rateLimitResetTimes).some(([key, until]) => until !== storedRateLimits[key])) {
357
+ this.hasUnpersistedRateLimitBounds = true;
358
+ }
340
359
  const refreshToken = matchesFallback && authFallback
341
360
  ? authFallback.refresh
342
361
  : account.refreshToken;
@@ -362,7 +381,7 @@ export class AccountManager {
362
381
  addedAt: clampNonNegativeInt(account.addedAt, baseNow),
363
382
  lastUsed: clampNonNegativeInt(account.lastUsed, 0),
364
383
  lastSwitchReason: account.lastSwitchReason,
365
- rateLimitResetTimes: account.rateLimitResetTimes ?? {},
384
+ rateLimitResetTimes,
366
385
  coolingDownUntil: account.coolingDownUntil,
367
386
  cooldownReason: account.cooldownReason,
368
387
  authInvalidatedAt: account.authInvalidatedAt,
@@ -1062,9 +1081,9 @@ export class AccountManager {
1062
1081
  // Fail closed: retain this new limit if the selection cannot be read.
1063
1082
  log.warn("Rate limit recorded while account selection metadata is unavailable");
1064
1083
  }
1065
- // Clamp to MAX_RATE_LIMIT_DELAY_MS so a bogus upstream retry-after value
1066
- // cannot wedge this account unavailable for years (see stress audit H1).
1067
- const retryMs = Math.min(Math.max(0, Math.floor(retryAfterMs)), MAX_RATE_LIMIT_DELAY_MS);
1084
+ // A local rejection is authoritative for at most an hour. The next request
1085
+ // then revalidates upstream, including after an out-of-band quota reset.
1086
+ const retryMs = Math.min(Math.max(0, Math.floor(retryAfterMs)), RATE_LIMIT_CACHE_TTL_MS);
1068
1087
  const observedAtMs = nowMs();
1069
1088
  const resetAt = observedAtMs + retryMs;
1070
1089
  // Only carry forward an unsequenced entry that is still replayable.
@@ -1561,9 +1580,15 @@ export class AccountManager {
1561
1580
  getMinWaitTime() {
1562
1581
  return this.getMinWaitTimeForFamily("codex");
1563
1582
  }
1564
- getMinWaitTimeForFamily(family, model) {
1583
+ /**
1584
+ * Accounts in `excludedIndexes` (policy-blocked or reserved for this request)
1585
+ * are not capacity the caller can retry into, so they never shorten the wait.
1586
+ */
1587
+ getMinWaitTimeForFamily(family, model, excludedIndexes) {
1565
1588
  const now = nowMs();
1566
- const enabledAccounts = this.accounts.filter((account) => account.enabled !== false && !this.isAccountAuthInvalidated(account));
1589
+ const enabledAccounts = this.accounts.filter((account) => account.enabled !== false &&
1590
+ !this.isAccountAuthInvalidated(account) &&
1591
+ !excludedIndexes?.has(account.index));
1567
1592
  const available = enabledAccounts.filter((account) => {
1568
1593
  clearExpiredRateLimits(account);
1569
1594
  return (!isRateLimitedForFamily(account, family, model) &&
@@ -17,6 +17,7 @@ declare const routeSchema: z.ZodObject<{
17
17
  accessPrograms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>;
18
18
  modelAccessPrograms: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
19
19
  }, z.core.$strip>;
20
+ export declare const MAX_API_ROUTES = 100;
20
21
  export type ApiRouteCredential = z.infer<typeof routeSchema>;
21
22
  export declare function getApiRoutesPath(): string;
22
23
  export declare function loadApiRoutes(path?: string): Promise<ApiRouteCredential[]>;
@@ -41,9 +41,10 @@ const routeSchema = z.object({
41
41
  modelAccessPrograms: z.record(modelId, accessPrograms)
42
42
  .refine(value => Object.keys(value).length <= 1000).optional(),
43
43
  });
44
+ export const MAX_API_ROUTES = 100;
44
45
  const schema = z.object({
45
46
  version: z.literal(1),
46
- routes: z.array(routeSchema).max(100),
47
+ routes: z.array(routeSchema).max(MAX_API_ROUTES),
47
48
  });
48
49
  export function getApiRoutesPath() {
49
50
  return join(getCodexMultiAuthDir(), "api-routes.json");
@@ -1,5 +1,6 @@
1
1
  import { CODEX_BASE_URL } from "../constants.js";
2
2
  import { combineSignals } from "../utils.js";
3
+ import { fetchAuth } from "./http.js";
3
4
  const ACCOUNTS_CHECK_PATH = "/wham/accounts/check";
4
5
  const ACCOUNTS_CHECK_TIMEOUT_MS = 10_000;
5
6
  function readString(value) {
@@ -49,7 +50,7 @@ export async function fetchAuthorizedAccounts(accessToken, options = {}) {
49
50
  const token = readString(accessToken);
50
51
  if (!token)
51
52
  return null;
52
- const doFetch = options.fetch ?? globalThis.fetch;
53
+ const doFetch = options.fetch ?? fetchAuth;
53
54
  const timeout = AbortSignal.timeout(ACCOUNTS_CHECK_TIMEOUT_MS);
54
55
  // combineSignals wraps AbortSignal.any (available on every Node the
55
56
  // engines floor, `>=22.19`, permits) with null-tolerance; the native composite also detaches
@@ -2,6 +2,7 @@ import { randomBytes, webcrypto } from "node:crypto";
2
2
  import { logError } from "../logger.js";
3
3
  import { safeParseOAuthTokenResponse } from "../schemas.js";
4
4
  import { isAbortError } from "../utils.js";
5
+ import { fetchAuth } from "./http.js";
5
6
  // OAuth constants (from openai/codex)
6
7
  export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
7
8
  export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
@@ -396,7 +397,7 @@ export function parseAuthorizationInput(input) {
396
397
  * @returns Token result
397
398
  */
398
399
  export async function exchangeAuthorizationCode(code, verifier, redirectUri = REDIRECT_URI) {
399
- const res = await fetch(TOKEN_URL, {
400
+ const res = await fetchAuth(TOKEN_URL, {
400
401
  method: "POST",
401
402
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
402
403
  body: new URLSearchParams({
@@ -468,7 +469,7 @@ export function decodeJWT(token) {
468
469
  }
469
470
  export async function refreshAccessToken(refreshToken, options = {}) {
470
471
  try {
471
- const response = await fetch(TOKEN_URL, {
472
+ const response = await fetchAuth(TOKEN_URL, {
472
473
  method: "POST",
473
474
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
474
475
  signal: options?.signal,
@@ -1,4 +1,5 @@
1
1
  import { CLIENT_ID, exchangeAuthorizationCode, sanitizeOAuthResponseBodyForLog, } from "./auth.js";
2
+ import { fetchAuth } from "./http.js";
2
3
  const DEVICE_AUTH_BASE_URL = "https://auth.openai.com";
3
4
  const DEVICE_AUTH_API_BASE_URL = `${DEVICE_AUTH_BASE_URL}/api/accounts`;
4
5
  export const DEVICE_AUTH_VERIFICATION_URL = `${DEVICE_AUTH_BASE_URL}/codex/device`;
@@ -14,7 +15,7 @@ const DEVICE_AUTH_TRANSIENT_PENDING_STATUSES = new Set([
14
15
  504,
15
16
  ]);
16
17
  function getFetch(options) {
17
- return options.fetchImpl ?? fetch;
18
+ return options.fetchImpl ?? fetchAuth;
18
19
  }
19
20
  function getNow(options) {
20
21
  return options.now ?? Date.now;
@@ -0,0 +1 @@
1
+ export { fetchWithProxy as fetchAuth } from "../request/proxy-fetch.js";
@@ -0,0 +1 @@
1
+ export { fetchWithProxy as fetchAuth } from "../request/proxy-fetch.js";
@@ -45,6 +45,11 @@ export declare function extractAccountId(accessToken?: string): string | undefin
45
45
  */
46
46
  /** Whether an id is an OpenAI platform org id ("org-..."), never a ChatGPT workspace. */
47
47
  export declare function isOpenAiOrgId(accountId: string | undefined): boolean;
48
+ /** Preserve the native client's org-alias mapping; this is not workspace fallback. */
49
+ export declare function nativeWorkspaceIdentity(account: {
50
+ access?: string;
51
+ codexCliMirror?: CodexCliMirror;
52
+ }, storedId: string | undefined): string | undefined;
48
53
  export declare function resolveCodexAuthAccountId(accountId: string | undefined, accessToken?: string, idToken?: string): string | undefined;
49
54
  /**
50
55
  * The account id to hand the ~/.codex/auth.json writer for a saved account,
@@ -261,6 +261,16 @@ export function extractAccountId(accessToken) {
261
261
  export function isOpenAiOrgId(accountId) {
262
262
  return /^org-/i.test(accountId?.trim() ?? "");
263
263
  }
264
+ /** Preserve the native client's org-alias mapping; this is not workspace fallback. */
265
+ export function nativeWorkspaceIdentity(account, storedId) {
266
+ const id = storedId?.trim();
267
+ if (!id)
268
+ return undefined;
269
+ if (!isOpenAiOrgId(id))
270
+ return id;
271
+ const native = codexCliAccountIdFor({ accountId: id, codexCliMirror: account.codexCliMirror }, account.access);
272
+ return native && !isOpenAiOrgId(native) ? native : undefined;
273
+ }
264
274
  export function resolveCodexAuthAccountId(accountId, accessToken, idToken) {
265
275
  const trimmed = accountId?.trim();
266
276
  if (!trimmed)
@@ -14,6 +14,7 @@ export const ACCOUNT_MANAGER_COMMANDS = new Set([
14
14
  "login",
15
15
  "list",
16
16
  "status",
17
+ "web",
17
18
  "limits",
18
19
  "switch",
19
20
  "unpin",
@@ -7,6 +7,7 @@ function printAccountUsage(logInfo) {
7
7
  " codex-multi-auth account weight <index> <0..10>",
8
8
  " codex-multi-auth account priority <index> <0..9>",
9
9
  " codex-multi-auth account auto-prime <index> on|off",
10
+ " codex-multi-auth account emergency-only <index> on|off",
10
11
  " codex-multi-auth account pause|unpause|drain|undrain <index>",
11
12
  " codex-multi-auth account note <index> <text>",
12
13
  " codex-multi-auth account policy list [--json]",
@@ -37,6 +38,7 @@ function policySummary(store, storage) {
37
38
  weight: policy?.weight ?? 1,
38
39
  priority: policy?.priority ?? 1,
39
40
  autoPrime: policy?.autoPrime ?? false,
41
+ emergencyOnly: policy?.emergencyOnly ?? false,
40
42
  paused: policy?.paused ?? false,
41
43
  drained: policy?.drained ?? false,
42
44
  note: policy?.note ?? null,
@@ -84,6 +86,7 @@ export async function runAccountCommand(args, deps) {
84
86
  `weight=${entry.weight}`,
85
87
  `priority=${entry.priority}`,
86
88
  `auto-prime=${entry.autoPrime ? "on" : "off"}`,
89
+ entry.emergencyOnly ? "emergency-only" : null,
87
90
  entry.paused ? "paused" : null,
88
91
  entry.drained ? "drained" : null,
89
92
  entry.tags.length > 0 ? `tags=${entry.tags.join(",")}` : null,
@@ -120,6 +123,18 @@ export async function runAccountCommand(args, deps) {
120
123
  logInfo(`${command === "tag" ? "Tagged" : "Removed tag from"} account ${resolved.index + 1}: ${policy.tags.join(",") || "none"}`);
121
124
  return 0;
122
125
  }
126
+ if (command === "emergency-only") {
127
+ if (rest.length !== 2 || !["on", "off"].includes(rest[1] ?? "")) {
128
+ logError("emergency-only requires on or off.");
129
+ return 1;
130
+ }
131
+ await updateStore(store => ({
132
+ result: upsertAccountPolicy(store, accountKey, next => { next.emergencyOnly = rest[1] === "on"; }, now),
133
+ dirty: true,
134
+ }));
135
+ logInfo(`Emergency-only ${rest[1]} for account ${resolved.index + 1}.`);
136
+ return 0;
137
+ }
123
138
  if (command === "auto-prime") {
124
139
  if (rest.length !== 2 || !["on", "off"].includes(rest[1] ?? "")) {
125
140
  logError("auto-prime requires on or off.");
@@ -0,0 +1,12 @@
1
+ import { type OutputLocaleSetting } from "../../locale.js";
2
+ export interface LocaleCommandDeps {
3
+ loadConfig: () => {
4
+ locale?: OutputLocaleSetting;
5
+ };
6
+ saveConfig: (patch: {
7
+ locale: OutputLocaleSetting;
8
+ }) => Promise<void>;
9
+ logInfo?: (message: string) => void;
10
+ logError?: (message: string) => void;
11
+ }
12
+ export declare function runLocaleCommand(args: string[], deps: LocaleCommandDeps): Promise<number>;
@@ -0,0 +1,25 @@
1
+ import { localeName, normalizeOutputLocaleSetting, resolveOutputLocale, } from "../../locale.js";
2
+ export async function runLocaleCommand(args, deps) {
3
+ const logInfo = deps.logInfo ?? console.log;
4
+ const logError = deps.logError ?? console.error;
5
+ const requested = args[0]?.trim();
6
+ if (args.length > 1 || requested === "--help" || requested === "-h") {
7
+ logInfo("Usage: codex-multi-auth config locale [auto|en|zh]");
8
+ return requested === "--help" || requested === "-h" ? 0 : 1;
9
+ }
10
+ if (!requested) {
11
+ const configured = deps.loadConfig().locale ?? "auto";
12
+ const effective = resolveOutputLocale(configured);
13
+ logInfo(`Locale: ${configured} (effective: ${localeName(effective)})`);
14
+ return 0;
15
+ }
16
+ const locale = normalizeOutputLocaleSetting(requested);
17
+ if (!locale) {
18
+ logError(`Unsupported locale: ${requested}. Choose auto, en, or zh.`);
19
+ return 1;
20
+ }
21
+ await deps.saveConfig({ locale });
22
+ const effective = resolveOutputLocale(locale);
23
+ logInfo(`Locale saved: ${locale} (effective: ${localeName(effective)})`);
24
+ return 0;
25
+ }
@@ -1,8 +1,14 @@
1
1
  import { AccountManager } from '../../accounts.js';
2
2
  import { loadAccounts } from '../../storage.js';
3
3
  import { runWithGlobalStoragePath } from '../../storage/path-state.js';
4
- import { createResetCreditService, resetTargetForStoredAccount, resetAccountLabel } from '../../runtime/account-reset-credits.js';
4
+ import { createResetCreditService, resetTargetForStoredAccount, resetAccountLabel, formatResetCreditLine } from '../../runtime/account-reset-credits.js';
5
5
  import { withCheckProgress } from '../../ui/check-progress.js';
6
+ // A pending result may belong to a workspace that was since disabled or deselected.
7
+ function ownsPendingKey(account, key) {
8
+ const workspaces = account.workspaces?.map(workspace => ({ ...workspace, enabled: true }));
9
+ const selections = [undefined, ...(workspaces ?? []).map((_, index) => index)];
10
+ return selections.some(index => resetTargetForStoredAccount({ ...account, enabled: true, workspaces: index === undefined ? undefined : workspaces, currentWorkspaceIndex: index ?? account.currentWorkspaceIndex })?.key === key);
11
+ }
6
12
  const usage = 'Usage: codex-multi-auth resets list [--refresh] | redeem <account-number> | auto manual|last-resort';
7
13
  export async function runResetsCommand(args) {
8
14
  const [command = 'list', value, ...extra] = args;
@@ -47,7 +53,13 @@ export async function runResetsCommand(args) {
47
53
  const index = (storage?.accounts ?? []).findIndex(a => resetTargetForStoredAccount(a)?.key === state.lastRedemption?.key);
48
54
  console.log(`Last confirmed reset: ${index >= 0 ? `account ${index + 1}` : "removed account"}; ${state.lastRedemption.outcome}; ${state.lastRedemption.automatic ? "automatic" : "explicit"}`);
49
55
  }
50
- (storage?.accounts ?? []).forEach((a, i) => { const target = resetTargetForStoredAccount(a); const ambiguous = target && (keyCounts.get(target.key) ?? 0) > 1; const snapshot = target && !ambiguous ? (refreshed ?? state.snapshots)[target.key] : undefined; console.log(`${resetAccountLabel(a, i)}: ${snapshot?.availableCount ?? 'unknown'} reset credits${snapshot ? ` (checked ${Math.max(0, Math.floor((Date.now() - snapshot.updatedAt) / 1000))}s ago)` : ''}${ambiguous ? ' [ambiguous account identity; repair duplicate records]' : ''}${target?.key === state.pending?.key ? ' [redemption pending; retry this account]' : ''}`); });
56
+ (storage?.accounts ?? []).forEach((a, i) => {
57
+ const target = resetTargetForStoredAccount(a);
58
+ const ambiguous = target && (keyCounts.get(target.key) ?? 0) > 1;
59
+ const snapshot = target && !ambiguous ? (refreshed ?? state.snapshots)[target.key] : undefined;
60
+ console.log(`${resetAccountLabel(a, i)}: ${snapshot?.availableCount ?? 'unknown'} reset credits${snapshot ? ` (checked ${Math.max(0, Math.floor((Date.now() - snapshot.updatedAt) / 1000))}s ago)` : ''}${ambiguous ? ' [ambiguous account identity; repair duplicate records]' : ''}${target?.key === state.pending?.key ? ' [redemption pending; retry this account]' : ''}`);
61
+ snapshot?.credits?.forEach((c, idx) => console.log(formatResetCreditLine(c, idx)));
62
+ });
51
63
  const failed = refreshed ? targets.filter(target => (keyCounts.get(target.key) ?? 0) > 1 || !refreshed[target.key]).length : 0;
52
64
  if (failed) {
53
65
  console.error(`${failed} of ${targets.length} reset-credit reads failed. Check account authentication and the native Codex backend (CODEX_MULTI_AUTH_USAGE_CODEX_BIN selects an executable). No credits were redeemed.`);
@@ -61,10 +73,11 @@ export async function runResetsCommand(args) {
61
73
  const pending = command === 'redeem' ? await service.status().then(s => s.pending ?? null, () => ({ key: undefined })) : null;
62
74
  // The pending record can belong to another account (a redeem is refused while
63
75
  // any result is pending); point at the account that actually needs the retry.
64
- const owner = pending?.key ? (storage?.accounts ?? []).findIndex(a => resetTargetForStoredAccount({ ...a, enabled: true, workspaces: undefined })?.key === pending.key) : -1;
76
+ const pendingKey = pending?.key;
77
+ const owner = pendingKey ? (storage?.accounts ?? []).findIndex(a => ownsPendingKey(a, pendingKey)) : -1;
65
78
  const pendingAccount = owner >= 0 ? storage?.accounts[owner] : undefined;
66
- const disabled = pendingAccount ? resetTargetForStoredAccount(pendingAccount) === null : false;
67
- const retry = disabled ? `account ${owner + 1} or its workspace is disabled; re-enable it before retrying the pending result` : pending?.key && owner < 0 ? 'check it there (the pending account was removed)' : owner >= 0 && owner !== Number(value) - 1 ? `retry account ${owner + 1}, which holds the pending result` : 'retry the same account';
79
+ const disabled = pendingAccount ? resetTargetForStoredAccount(pendingAccount)?.key !== pendingKey : false;
80
+ const retry = disabled ? `account ${owner + 1} or its pending workspace is disabled or no longer selected; re-enable and select it before retrying the pending result` : pending?.key && owner < 0 ? 'check it there (the pending account was removed)' : owner >= 0 && owner !== Number(value) - 1 ? `retry account ${owner + 1}, which holds the pending result` : 'retry the same account';
68
81
  console.error(command === 'list' ? 'Reset-credit availability could not be refreshed. No credits were redeemed.' : command === 'auto' ? 'Reset-credit settings could not be updated.' : pending ? `Reset operation could not be confirmed. No new automatic redemption will be attempted while a result is pending; use resets list and ${retry}.` : 'No reset credit was redeemed. Availability could not be confirmed or a reset was just redeemed; run resets list --refresh and try again.');
69
82
  return 1;
70
83
  }
@@ -7,6 +7,7 @@ import type { QuotaCacheData } from "../../quota-cache.js";
7
7
  import { type AppBindRouterStatus } from "../../runtime/app-bind.js";
8
8
  import { type RuntimeAccountSignal } from "../../runtime/runtime-current-account.js";
9
9
  import type { RuntimeObservabilitySnapshot } from "../../runtime/runtime-observability.js";
10
+ import { type OutputLocale } from "../../locale.js";
10
11
  import type { AccountStorageV3, StorageHealthSummary } from "../../storage.js";
11
12
  type LoadedStorage = AccountStorageV3 | null;
12
13
  export interface StatusCommandDeps {
@@ -31,6 +32,9 @@ export interface StatusCommandDeps {
31
32
  logInfo?: (message: string) => void;
32
33
  /** When true, emit a single machine-readable JSON object instead of text (cli-manager-03). */
33
34
  json?: boolean;
35
+ /** Compact human-facing summary. Undefined keeps the legacy detailed view for API callers. */
36
+ compact?: boolean;
37
+ locale?: OutputLocale;
34
38
  }
35
39
  export declare function runStatusCommand(deps: StatusCommandDeps): Promise<number>;
36
40
  export interface FeaturesCommandDeps {