@juspay/neurolink 12.7.3 → 12.7.4

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.
@@ -24,7 +24,7 @@ import { formatUptime, isProcessRunning, StateFileManager, } from "../utils/serv
24
24
  import { configureProxyKeepAliveDispatcher } from "../../proxy/proxyDispatcher.js";
25
25
  import { ProxyRuntimeConfigStore } from "../../proxy/runtimeConfig.js";
26
26
  import { startProxyLogCleanupScheduler } from "../../proxy/logCleanupScheduler.js";
27
- import { anthropicAccountKeysEqual, createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
27
+ import { anthropicAccountKeysEqual, createAccountAllowlist, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
28
28
  import { resolveProxyStatusAccountIdentity } from "../../proxy/codexAccountUsage.js";
29
29
  import { beginProxyRequest, getProxyActivitySnapshot, trackProxyResponse, } from "../../proxy/proxyActivity.js";
30
30
  import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
@@ -66,11 +66,9 @@ const gatedShareRequests = new WeakSet();
66
66
  const PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS = 5_000;
67
67
  /** How long shutdown waits on the share listener before moving on. */
68
68
  const SHARE_LISTENER_CLOSE_TIMEOUT_MS = 10_000;
69
- const LEGACY_STATUS_ACCOUNT_CACHE_TTL_MS = 5_000;
70
69
  const PROXY_STATUS_TOKEN_READ_TIMEOUT_MS = 2_000;
71
70
  const PROXY_STATUS_RECONCILE_TIMEOUT_MS = 750;
72
71
  const PROXY_STATUS_ACCOUNT_INVENTORY_TIMEOUT_MS = 750;
73
- let legacyStatusAccountCache;
74
72
  // Allowed drift between a pid's OS-reported start time and the persisted
75
73
  // ProxySupervisorState.startTime before processLooksLikeProxySupervisor
76
74
  // treats it as a confident mismatch (recycled pid). Generous on purpose:
@@ -386,34 +384,6 @@ async function resolveStatusPrimaryAccount(proxyConfig) {
386
384
  source: "fallback",
387
385
  };
388
386
  }
389
- async function resolveLegacyStatusAccountLabel(storedAnthropicAccountCount) {
390
- if (storedAnthropicAccountCount !== 0) {
391
- return null;
392
- }
393
- const credentialsPath = join(homedir(), ".neurolink", "anthropic-credentials.json");
394
- const now = Date.now();
395
- if (legacyStatusAccountCache?.credentialsPath === credentialsPath &&
396
- legacyStatusAccountCache.expiresAt > now) {
397
- return legacyStatusAccountCache.label;
398
- }
399
- let label = null;
400
- try {
401
- const { readFile } = await import("node:fs/promises");
402
- const parsed = JSON.parse(await readFile(credentialsPath, "utf8"));
403
- if (parsed.oauth?.accessToken) {
404
- label = parsed.email?.trim() || "legacy-default";
405
- }
406
- }
407
- catch {
408
- label = null;
409
- }
410
- legacyStatusAccountCache = {
411
- credentialsPath,
412
- expiresAt: now + LEGACY_STATUS_ACCOUNT_CACHE_TTL_MS,
413
- label,
414
- };
415
- return label;
416
- }
417
387
  function deriveAccountAllowance(accountKey, now, allowlist, expirations, cooldowns) {
418
388
  const allowed = isAccountAllowed(accountKey, allowlist);
419
389
  const expiresAt = expirations.get(accountKey);
@@ -1067,6 +1037,7 @@ function redactStatusAccounts(rows, allowed) {
1067
1037
  return rows.map((row, index) => ({
1068
1038
  ...row,
1069
1039
  label: `account-${index + 1}`,
1040
+ ...(row.key !== undefined ? { key: null } : {}),
1070
1041
  ...(row.email !== undefined ? { email: null } : {}),
1071
1042
  }));
1072
1043
  }
@@ -1686,21 +1657,28 @@ export async function createProxyStartApp(params) {
1686
1657
  logger.debug(`[proxy] /status using empty cooldown snapshot: ${error instanceof Error ? error.message : String(error)}`);
1687
1658
  return {};
1688
1659
  });
1689
- const storedAccountKeys = new Set();
1660
+ const storedAnthropicAccountKeys = new Set();
1661
+ const storedCodexAccountKeys = new Set();
1690
1662
  const storedAccountExpirations = new Map();
1691
- const disabledAccountKeys = new Set();
1692
1663
  const disabledProviderAccountKeys = new Set();
1693
1664
  let accountInventoryLoaded = false;
1694
1665
  try {
1695
1666
  const { tokenStore } = await import("../../auth/tokenStore.js");
1696
- const storedKeys = await withTimeout(tokenStore.listByPrefix("anthropic:"), PROXY_STATUS_ACCOUNT_INVENTORY_TIMEOUT_MS, "[proxy] /status account enumeration timed out");
1697
- for (const key of storedKeys) {
1698
- storedAccountKeys.add(normalizeAnthropicAccountKey(key));
1667
+ const [anthropicKeys, codexKeys] = await withTimeout(Promise.all([
1668
+ tokenStore.listByPrefix("anthropic:"),
1669
+ tokenStore.listByPrefix("codex:"),
1670
+ ]), PROXY_STATUS_ACCOUNT_INVENTORY_TIMEOUT_MS, "[proxy] /status account enumeration timed out");
1671
+ for (const key of anthropicKeys) {
1672
+ storedAnthropicAccountKeys.add(normalizeAnthropicAccountKey(key));
1673
+ }
1674
+ for (const key of codexKeys) {
1675
+ storedCodexAccountKeys.add(key);
1699
1676
  }
1700
1677
  // Once account names are known, preserve them even when optional token
1701
1678
  // metadata is slow. That keeps the status table useful and avoids
1702
1679
  // incorrectly presenting known accounts as removed.
1703
1680
  accountInventoryLoaded = true;
1681
+ const storedKeys = [...anthropicKeys, ...codexKeys];
1704
1682
  const inventory = await withTimeout((async () => {
1705
1683
  const tokenExpirations = await Promise.all(storedKeys.map(async (key) => {
1706
1684
  try {
@@ -1717,20 +1695,21 @@ export async function createProxyStartApp(params) {
1717
1695
  })(), PROXY_STATUS_ACCOUNT_INVENTORY_TIMEOUT_MS, "[proxy] /status account metadata timed out");
1718
1696
  for (const expiration of inventory.tokenExpirations) {
1719
1697
  if (expiration) {
1720
- storedAccountExpirations.set(normalizeAnthropicAccountKey(expiration[0]), expiration[1]);
1698
+ const key = expiration[0].startsWith("anthropic:")
1699
+ ? normalizeAnthropicAccountKey(expiration[0])
1700
+ : expiration[0];
1701
+ storedAccountExpirations.set(key, expiration[1]);
1721
1702
  }
1722
1703
  }
1723
1704
  for (const key of inventory.disabledKeys) {
1724
- disabledAccountKeys.add(normalizeAnthropicAccountKey(key));
1725
- disabledProviderAccountKeys.add(key);
1705
+ disabledProviderAccountKeys.add(key.startsWith("anthropic:")
1706
+ ? normalizeAnthropicAccountKey(key)
1707
+ : key);
1726
1708
  }
1727
1709
  }
1728
1710
  catch (err) {
1729
1711
  logger.debug(`[proxy] /status: failed to resolve account cooldown labels: ${err instanceof Error ? err.message : String(err)}`);
1730
1712
  }
1731
- const legacyAccountLabel = accountInventoryLoaded
1732
- ? await withTimeout(resolveLegacyStatusAccountLabel(storedAccountKeys.size), PROXY_STATUS_ACCOUNT_INVENTORY_TIMEOUT_MS, "[proxy] /status legacy account inspection timed out").catch(() => null)
1733
- : null;
1734
1713
  const now = Date.now();
1735
1714
  const health = buildProxyHealthResponse(readiness, {
1736
1715
  strategy: activeStrategy,
@@ -1744,52 +1723,54 @@ export async function createProxyStartApp(params) {
1744
1723
  source: "fallback",
1745
1724
  }));
1746
1725
  const activeUpdaterPid = supervisorState?.updaterPid ?? runtimeState?.updaterPid;
1747
- const accountRows = Object.values(stats.accounts).map((account) => {
1748
- const identity = resolveProxyStatusAccountIdentity(account.label, account.type);
1726
+ const accountRows = Object.entries(stats.accounts).map(([persistedMapKey, account]) => {
1727
+ // Legacy snapshots used their bare map key as an inferred identity. Do
1728
+ // not map those old rows to a provider based on an email: the same email
1729
+ // can be present in both pools. New records carry account.key.
1730
+ const persistedKey = account.key ?? null;
1731
+ const identity = persistedKey
1732
+ ? resolveProxyStatusAccountIdentity(account.label, account.type, persistedKey)
1733
+ : { provider: "other", key: null };
1734
+ const isLegacyUnattributed = persistedKey === null &&
1735
+ (account.type === "oauth" ||
1736
+ account.type === "api_key" ||
1737
+ account.type === "codex-oauth");
1749
1738
  const isAnthropicAccount = identity.provider === "anthropic";
1750
1739
  const isCodexAccount = identity.provider === "codex";
1751
- const normalizedKey = identity.provider === "anthropic" ? identity.key : null;
1752
- const isLegacyAccount = isAnthropicAccount && account.label === legacyAccountLabel;
1753
- const accountKey = isAnthropicAccount
1754
- ? storedAccountKeys.has(normalizedKey ?? "")
1755
- ? normalizedKey
1756
- : isLegacyAccount
1757
- ? LEGACY_ANTHROPIC_ACCOUNT_KEY
1758
- : account.label === "env"
1759
- ? ENV_ANTHROPIC_ACCOUNT_KEY
1760
- : normalizedKey
1761
- : identity.key;
1762
- const isStored = isAnthropicAccount &&
1763
- accountKey !== null &&
1764
- (storedAccountKeys.has(accountKey) || isLegacyAccount);
1765
- const { allowed, expired, cooling } = isAnthropicAccount && accountKey !== null
1740
+ const accountKey = identity.key ?? (persistedKey || persistedMapKey);
1741
+ const isStored = (isAnthropicAccount && storedAnthropicAccountKeys.has(accountKey)) ||
1742
+ (isCodexAccount && storedCodexAccountKeys.has(accountKey));
1743
+ const providerState = isAnthropicAccount
1766
1744
  ? deriveAccountAllowance(accountKey, now, activeAccountAllowlist, storedAccountExpirations, cooldowns)
1767
- : {
1768
- allowed: true,
1769
- expired: false,
1770
- cooling: accountKey !== null &&
1771
- (cooldowns[accountKey]?.coolingUntil ?? 0) > now,
1772
- };
1773
- const isDisabled = (isAnthropicAccount &&
1774
- accountKey !== null &&
1775
- disabledAccountKeys.has(accountKey)) ||
1776
- (isCodexAccount &&
1777
- accountKey !== null &&
1778
- disabledProviderAccountKeys.has(accountKey));
1745
+ : isCodexAccount
1746
+ ? {
1747
+ allowed: true,
1748
+ expired: (storedAccountExpirations.get(accountKey) ?? 0) > 0 &&
1749
+ (storedAccountExpirations.get(accountKey) ?? 0) <= now,
1750
+ cooling: (cooldowns[accountKey]?.coolingUntil ?? 0) > now,
1751
+ }
1752
+ : { allowed: true, expired: false, cooling: false };
1753
+ const isDisabled = !isLegacyUnattributed && disabledProviderAccountKeys.has(accountKey);
1779
1754
  const accountStatus = account.type === "internal"
1780
1755
  ? "internal"
1781
- : isDisabled
1782
- ? "disabled"
1783
- : isAnthropicAccount && expired
1784
- ? "expired"
1785
- : isAnthropicAccount && !allowed
1786
- ? "excluded"
1787
- : accountInventoryLoaded && isAnthropicAccount && !isStored
1788
- ? "removed"
1789
- : cooling
1790
- ? "cooling"
1791
- : "active";
1756
+ : isLegacyUnattributed
1757
+ ? "unattributed"
1758
+ : isDisabled
1759
+ ? "disabled"
1760
+ : providerState.expired
1761
+ ? "expired"
1762
+ : isAnthropicAccount && !providerState.allowed
1763
+ ? "excluded"
1764
+ : accountInventoryLoaded &&
1765
+ (isAnthropicAccount || isCodexAccount) &&
1766
+ !isStored
1767
+ ? "removed"
1768
+ : providerState.cooling
1769
+ ? "cooling"
1770
+ : "active";
1792
1771
  return {
1772
+ key: isLegacyUnattributed ? null : accountKey,
1773
+ provider: isLegacyUnattributed ? "unknown" : identity.provider,
1793
1774
  label: account.label,
1794
1775
  type: account.type,
1795
1776
  attempts: account.attemptCount,
@@ -1800,21 +1781,26 @@ export async function createProxyStartApp(params) {
1800
1781
  rateLimits: account.rateLimitCount,
1801
1782
  transientRateLimits: account.transientRateLimitCount,
1802
1783
  quotaRateLimits: account.quotaRateLimitCount,
1803
- cooling,
1784
+ cooling: providerState.cooling,
1804
1785
  status: accountStatus,
1805
- allowed: account.type === "internal" ? undefined : allowed,
1806
- expired: isAnthropicAccount ? expired : undefined,
1786
+ allowed: account.type === "internal" ? undefined : providerState.allowed,
1787
+ expired: isAnthropicAccount || isCodexAccount
1788
+ ? providerState.expired
1789
+ : undefined,
1807
1790
  };
1808
1791
  });
1809
- const representedAccountKeys = new Set(accountRows
1810
- .filter((account) => account.type === "oauth" || account.type === "api_key")
1811
- .map((account) => normalizeAnthropicAccountKey(account.label)));
1812
- for (const accountKey of storedAccountKeys) {
1792
+ const representedAccountKeys = new Set(accountRows.flatMap((account) => account.key &&
1793
+ (account.provider === "anthropic" || account.provider === "codex")
1794
+ ? [account.key]
1795
+ : []));
1796
+ for (const accountKey of storedAnthropicAccountKeys) {
1813
1797
  if (representedAccountKeys.has(accountKey)) {
1814
1798
  continue;
1815
1799
  }
1816
1800
  const { allowed, expired, cooling } = deriveAccountAllowance(accountKey, now, activeAccountAllowlist, storedAccountExpirations, cooldowns);
1817
1801
  accountRows.push({
1802
+ key: accountKey,
1803
+ provider: "anthropic",
1818
1804
  label: accountKey.slice("anthropic:".length),
1819
1805
  type: "oauth",
1820
1806
  attempts: 0,
@@ -1826,7 +1812,7 @@ export async function createProxyStartApp(params) {
1826
1812
  transientRateLimits: 0,
1827
1813
  quotaRateLimits: 0,
1828
1814
  cooling,
1829
- status: disabledAccountKeys.has(accountKey)
1815
+ status: disabledProviderAccountKeys.has(accountKey)
1830
1816
  ? "disabled"
1831
1817
  : expired
1832
1818
  ? "expired"
@@ -1839,6 +1825,38 @@ export async function createProxyStartApp(params) {
1839
1825
  expired,
1840
1826
  });
1841
1827
  }
1828
+ for (const accountKey of storedCodexAccountKeys) {
1829
+ if (representedAccountKeys.has(accountKey)) {
1830
+ continue;
1831
+ }
1832
+ const expiresAt = storedAccountExpirations.get(accountKey);
1833
+ const expired = expiresAt !== undefined && expiresAt > 0 && expiresAt <= now;
1834
+ const cooling = (cooldowns[accountKey]?.coolingUntil ?? 0) > now;
1835
+ accountRows.push({
1836
+ key: accountKey,
1837
+ provider: "codex",
1838
+ label: accountKey.slice("codex:".length),
1839
+ type: "codex-oauth",
1840
+ attempts: 0,
1841
+ requests: 0,
1842
+ success: 0,
1843
+ errors: 0,
1844
+ attemptErrors: 0,
1845
+ rateLimits: 0,
1846
+ transientRateLimits: 0,
1847
+ quotaRateLimits: 0,
1848
+ cooling,
1849
+ status: disabledProviderAccountKeys.has(accountKey)
1850
+ ? "disabled"
1851
+ : expired
1852
+ ? "expired"
1853
+ : cooling
1854
+ ? "cooling"
1855
+ : "active",
1856
+ allowed: true,
1857
+ expired,
1858
+ });
1859
+ }
1842
1860
  const attributed = accountRows.reduce((total, account) => ({
1843
1861
  attempts: total.attempts + (account.attempts ?? 0),
1844
1862
  requests: total.requests + (account.requests ?? 0),
@@ -3031,7 +3049,8 @@ function printStatusStats(stats) {
3031
3049
  if (lastError) {
3032
3050
  const cause = lastError.errorType ?? lastError.category;
3033
3051
  const code = lastError.errorCode ? `/${lastError.errorCode}` : "";
3034
- const account = lastError.account ? ` account=${lastError.account}` : "";
3052
+ const accountIdentity = lastError.accountKey ?? lastError.account;
3053
+ const account = accountIdentity ? ` account=${accountIdentity}` : "";
3035
3054
  console.info(` Last error: ${new Date(lastError.at).toISOString()} ${cause}${code} status=${lastError.status}${account}`);
3036
3055
  if (lastError.message) {
3037
3056
  console.info(` Last cause: ${sanitizeProxyStatusTerminalErrorMessage(lastError.message)}`);
@@ -6,10 +6,8 @@
6
6
  * - login, logout, status, refresh: Anthropic OAuth (API key + OAuth)
7
7
  * - providers, validate, health: Multi-provider auth management
8
8
  */
9
- /**
10
- * Supported providers for authentication
11
- */
12
- const SUPPORTED_PROVIDERS = ["anthropic", "codex"];
9
+ /** Providers with first-class credential flows in `neurolink auth`. */
10
+ const AUTH_LOGIN_PROVIDERS = ["anthropic", "codex"];
13
11
  /**
14
12
  * Auth Command Factory
15
13
  *
@@ -167,7 +165,7 @@ export class AuthCommandFactory {
167
165
  .positional("provider", {
168
166
  type: "string",
169
167
  description: "AI provider to authenticate with",
170
- choices: SUPPORTED_PROVIDERS,
168
+ choices: AUTH_LOGIN_PROVIDERS,
171
169
  demandOption: true,
172
170
  })
173
171
  .option("method", {
@@ -204,7 +202,7 @@ export class AuthCommandFactory {
204
202
  .positional("provider", {
205
203
  type: "string",
206
204
  description: "AI provider to log out from",
207
- choices: SUPPORTED_PROVIDERS,
205
+ choices: AUTH_LOGIN_PROVIDERS,
208
206
  demandOption: true,
209
207
  })
210
208
  .example("$0 auth logout anthropic", "Clear all Anthropic credentials");
@@ -217,7 +215,7 @@ export class AuthCommandFactory {
217
215
  .positional("provider", {
218
216
  type: "string",
219
217
  description: "AI provider to check (optional, shows all if not specified)",
220
- choices: SUPPORTED_PROVIDERS,
218
+ choices: AUTH_LOGIN_PROVIDERS,
221
219
  })
222
220
  .example("$0 auth status", "Show status for all configured providers")
223
221
  .example("$0 auth status anthropic", "Show Anthropic authentication status")
@@ -231,7 +229,7 @@ export class AuthCommandFactory {
231
229
  .positional("provider", {
232
230
  type: "string",
233
231
  description: "AI provider to refresh tokens for",
234
- choices: SUPPORTED_PROVIDERS,
232
+ choices: AUTH_LOGIN_PROVIDERS,
235
233
  demandOption: true,
236
234
  })
237
235
  .example("$0 auth refresh anthropic", "Refresh Anthropic OAuth tokens");
@@ -244,7 +242,7 @@ export class AuthCommandFactory {
244
242
  .option("refresh", {
245
243
  type: "boolean",
246
244
  default: false,
247
- description: "Fetch fresh limits from Anthropic for all OAuth accounts before listing (via the running proxy when available)",
245
+ description: "Refresh available provider limits for authenticated accounts; show unsupported providers explicitly",
248
246
  })
249
247
  .example("$0 auth list", "List all authenticated accounts")
250
248
  .example("$0 auth list --format json", "List accounts in JSON format")
@@ -257,8 +255,7 @@ export class AuthCommandFactory {
257
255
  return yargs
258
256
  .positional("provider", {
259
257
  type: "string",
260
- description: "AI provider to remove account from",
261
- choices: SUPPORTED_PROVIDERS,
258
+ description: "Provider namespace to remove an account from",
262
259
  demandOption: true,
263
260
  })
264
261
  .option("label", {
@@ -12,7 +12,7 @@ export declare const CODEX_ACCOUNT_PREFIX = "codex:";
12
12
  /** Keep an account label in the Codex namespace used by its token store. */
13
13
  export declare function normalizeCodexAccountKey(value: string): string;
14
14
  /** Resolve the provider-qualified key used to render a proxy status row. */
15
- export declare function resolveProxyStatusAccountIdentity(label: string, type: string): CodexProxyStatusAccountIdentity;
15
+ export declare function resolveProxyStatusAccountIdentity(label: string, type: string, persistedKey?: string): CodexProxyStatusAccountIdentity;
16
16
  /** Enumerate Codex OAuth accounts from the token store for usage refresh. */
17
17
  export declare function listCodexAccountsForUsage(): Promise<ProxyPassthroughAccount[]>;
18
18
  /** Normalise a Codex rate-limit block into the shared AccountQuota shape. */
@@ -19,7 +19,19 @@ export function normalizeCodexAccountKey(value) {
19
19
  : `${CODEX_ACCOUNT_PREFIX}${trimmed}`;
20
20
  }
21
21
  /** Resolve the provider-qualified key used to render a proxy status row. */
22
- export function resolveProxyStatusAccountIdentity(label, type) {
22
+ export function resolveProxyStatusAccountIdentity(label, type, persistedKey) {
23
+ // Current counter snapshots persist the provider-qualified key. Prefer it
24
+ // over the display type so a future type rename cannot move an account into
25
+ // the wrong cooldown/disabled namespace.
26
+ if (persistedKey?.toLowerCase().startsWith("anthropic:")) {
27
+ return {
28
+ provider: "anthropic",
29
+ key: normalizeAnthropicAccountKey(persistedKey),
30
+ };
31
+ }
32
+ if (persistedKey?.startsWith(CODEX_ACCOUNT_PREFIX)) {
33
+ return { provider: "codex", key: persistedKey };
34
+ }
23
35
  if (type === "oauth" || type === "api_key") {
24
36
  return { provider: "anthropic", key: normalizeAnthropicAccountKey(label) };
25
37
  }
@@ -63,6 +63,13 @@ export function trackProxyResponse(response, finishRequest, observer) {
63
63
  let observedBodyBytes = 0;
64
64
  let responseChunks = 0;
65
65
  let settled = false;
66
+ let sourceClosed = false;
67
+ // A framework can cancel its adapter after the upstream stream has already
68
+ // closed. Snapshot reader.closed before cancel() so that normal cleanup is
69
+ // not recorded as a client-aborted request.
70
+ void reader.closed.then(() => {
71
+ sourceClosed = true;
72
+ }, () => undefined);
66
73
  const settle = (outcome) => {
67
74
  if (settled) {
68
75
  return;
@@ -100,7 +107,11 @@ export function trackProxyResponse(response, finishRequest, observer) {
100
107
  }
101
108
  },
102
109
  async cancel(reason) {
103
- settle("client_cancelled");
110
+ // Read the state before cancelling: reader.cancel() itself rejects the
111
+ // closed promise for a genuinely active source, which is too late to
112
+ // distinguish it from a source that had already ended normally.
113
+ await Promise.resolve();
114
+ settle(sourceClosed ? "completed" : "client_cancelled");
104
115
  await withTimeout(reader.cancel(reason), PROXY_RESPONSE_CANCEL_TIMEOUT_MS, "Timed out cancelling the upstream proxy response");
105
116
  },
106
117
  });
@@ -40,7 +40,7 @@ export declare class ProxyUsageStatsStore {
40
40
  recordAttemptError(accountLabel: string, accountType: string, status: number, rateLimitKind?: "transient" | "quota"): void;
41
41
  recordFinalError(status: number, accountLabel?: string, accountType?: string, details?: ProxyTerminalErrorDetails): void;
42
42
  getStats(): ProxyStats;
43
- getAccountStats(label: string): AccountStats | undefined;
43
+ getAccountStats(label: string, type?: string): AccountStats | undefined;
44
44
  getTerminalErrors(): ProxyTerminalErrorJournal;
45
45
  getUsageSnapshot(): ProxyUsageStatsSnapshot;
46
46
  getPersistenceStatus(): ProxyStatsPersistenceStatus;
@@ -81,7 +81,7 @@ export declare function getStats(): ProxyStats;
81
81
  export declare function getUsageSnapshot(): ProxyUsageStatsSnapshot;
82
82
  export declare function getReconciledStats(): Promise<ProxyStats>;
83
83
  export declare function getReconciledUsageSnapshot(): Promise<ProxyUsageStatsSnapshot>;
84
- export declare function getAccountStats(label: string): AccountStats | undefined;
84
+ export declare function getAccountStats(label: string, type?: string): AccountStats | undefined;
85
85
  export declare function getTerminalErrors(): ProxyTerminalErrorJournal;
86
86
  export declare function getUsageStatsPersistenceStatus(): ProxyStatsPersistenceStatus;
87
87
  export declare function flushUsageStats(): Promise<void>;
@@ -8,6 +8,7 @@
8
8
  import { randomUUID } from "node:crypto";
9
9
  import { mkdir, open, readFile, readdir, rename, rm, stat, } from "node:fs/promises";
10
10
  import { basename, dirname, join } from "node:path";
11
+ import { normalizeAnthropicAccountKey } from "./accountSelection.js";
11
12
  import { AsyncMutex } from "../utils/asyncMutex.js";
12
13
  import { redactUrlsInText, sanitizeForLog } from "../utils/logSanitize.js";
13
14
  import { writeJsonSnapshotAtomically } from "./snapshotPersistence.js";
@@ -63,6 +64,34 @@ function emptyTerminalErrorJournal(startedAt) {
63
64
  recent: [],
64
65
  };
65
66
  }
67
+ /**
68
+ * Statistics must never use an email/label as their primary map key. One
69
+ * person can authenticate both provider pools with the same email, and a
70
+ * bare key silently merged their attempts, limits, and failures into one row.
71
+ *
72
+ * Keep non-account pseudo rows (passthrough, peer, internal) untouched. The
73
+ * display label remains separate so the CLI stays readable while persisted
74
+ * counters retain a collision-proof identity.
75
+ */
76
+ function resolveStatsAccountIdentity(label, type) {
77
+ if (type === "codex-oauth") {
78
+ const key = label.startsWith("codex:") ? label : `codex:${label}`;
79
+ return {
80
+ key,
81
+ label: label.startsWith("codex:") ? label.slice("codex:".length) : label,
82
+ };
83
+ }
84
+ if (type === "oauth" || type === "api_key") {
85
+ const key = normalizeAnthropicAccountKey(label);
86
+ return {
87
+ key,
88
+ label: label.startsWith("anthropic:")
89
+ ? label.slice("anthropic:".length)
90
+ : label,
91
+ };
92
+ }
93
+ return { key: label, label };
94
+ }
66
95
  function cloneTerminalErrorSummary(summary) {
67
96
  return { ...summary };
68
97
  }
@@ -152,6 +181,9 @@ function terminalErrorCategory(status, errorType) {
152
181
  }
153
182
  function createTerminalErrorSummary(args) {
154
183
  const { now, status, accountLabel, accountType, details } = args;
184
+ const accountIdentity = accountLabel && accountType
185
+ ? resolveStatsAccountIdentity(accountLabel, accountType)
186
+ : undefined;
155
187
  const errorType = clipTerminalErrorField(details?.errorType);
156
188
  const message = details?.message
157
189
  ? stripTerminalErrorControlCharacters(sanitizeForLog(redactUrlsInText(details.message), MAX_TERMINAL_ERROR_MESSAGE_LENGTH + MAX_TERMINAL_ERROR_FIELD_LENGTH))
@@ -170,6 +202,11 @@ function createTerminalErrorSummary(args) {
170
202
  ...(clipTerminalErrorField(accountLabel)
171
203
  ? { account: clipTerminalErrorField(accountLabel) }
172
204
  : {}),
205
+ ...(clipTerminalErrorField(details?.accountKey ?? accountIdentity?.key)
206
+ ? {
207
+ accountKey: clipTerminalErrorField(details?.accountKey ?? accountIdentity?.key),
208
+ }
209
+ : {}),
173
210
  ...(clipTerminalErrorField(accountType)
174
211
  ? { accountType: clipTerminalErrorField(accountType) }
175
212
  : {}),
@@ -201,7 +238,9 @@ function mergeAccountStats(left, right) {
201
238
  if (!left) {
202
239
  return cloneAccount(right);
203
240
  }
241
+ const key = right.key ?? left.key;
204
242
  return {
243
+ ...(key ? { key } : {}),
205
244
  label: right.label || left.label,
206
245
  type: right.type || left.type,
207
246
  attemptCount: left.attemptCount + right.attemptCount,
@@ -248,7 +287,9 @@ function validAccountStats(value) {
248
287
  return false;
249
288
  }
250
289
  const candidate = value;
251
- return (typeof candidate.label === "string" &&
290
+ return ((candidate.key === undefined ||
291
+ (typeof candidate.key === "string" && candidate.key.length > 0)) &&
292
+ typeof candidate.label === "string" &&
252
293
  typeof candidate.type === "string" &&
253
294
  finiteNonNegativeInteger(candidate.attemptCount) &&
254
295
  finiteNonNegativeInteger(candidate.attemptErrorCount) &&
@@ -282,7 +323,7 @@ function validStats(value) {
282
323
  typeof candidate.accounts !== "object") {
283
324
  return false;
284
325
  }
285
- return Object.entries(candidate.accounts).every(([label, account]) => validAccountStats(account) && label === account.label);
326
+ return Object.entries(candidate.accounts).every(([key, account]) => validAccountStats(account) && key === (account.key ?? account.label));
286
327
  }
287
328
  function validOptionalString(value, maxLength) {
288
329
  return (value === undefined ||
@@ -302,6 +343,7 @@ function validTerminalErrorSummary(value) {
302
343
  TERMINAL_ERROR_CATEGORIES.includes(candidate.category) &&
303
344
  validOptionalString(candidate.requestId, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
304
345
  validOptionalString(candidate.account, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
346
+ validOptionalString(candidate.accountKey, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
305
347
  validOptionalString(candidate.accountType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
306
348
  validOptionalString(candidate.errorType, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
307
349
  validOptionalString(candidate.errorCode, MAX_TERMINAL_ERROR_FIELD_LENGTH) &&
@@ -615,8 +657,20 @@ export class ProxyUsageStatsStore {
615
657
  getStats() {
616
658
  return cloneStats(this.stats);
617
659
  }
618
- getAccountStats(label) {
619
- const account = this.stats.accounts[label];
660
+ getAccountStats(label, type) {
661
+ const direct = this.stats.accounts[label];
662
+ if (direct) {
663
+ return cloneAccount(direct);
664
+ }
665
+ const identity = type
666
+ ? resolveStatsAccountIdentity(label, type)
667
+ : undefined;
668
+ const account = identity
669
+ ? this.stats.accounts[identity.key]
670
+ : (() => {
671
+ const matches = Object.values(this.stats.accounts).filter((candidate) => candidate.label === label);
672
+ return matches.length === 1 ? matches[0] : undefined;
673
+ })();
620
674
  return account ? cloneAccount(account) : undefined;
621
675
  }
622
676
  getTerminalErrors() {
@@ -920,9 +974,11 @@ export class ProxyUsageStatsStore {
920
974
  }
921
975
  }
922
976
  ensureAccount(target, label, type) {
923
- if (!target.accounts[label]) {
924
- target.accounts[label] = {
925
- label,
977
+ const identity = resolveStatsAccountIdentity(label, type);
978
+ if (!target.accounts[identity.key]) {
979
+ target.accounts[identity.key] = {
980
+ key: identity.key,
981
+ label: identity.label,
926
982
  type,
927
983
  attemptCount: 0,
928
984
  attemptErrorCount: 0,
@@ -934,7 +990,7 @@ export class ProxyUsageStatsStore {
934
990
  lastAttemptAt: 0,
935
991
  };
936
992
  }
937
- return target.accounts[label];
993
+ return target.accounts[identity.key];
938
994
  }
939
995
  scheduleFlush() {
940
996
  if (!this.filePath || this.flushTimer) {
@@ -1131,8 +1187,8 @@ export async function getReconciledStats() {
1131
1187
  export async function getReconciledUsageSnapshot() {
1132
1188
  return defaultStore.reconcileUsageSnapshot();
1133
1189
  }
1134
- export function getAccountStats(label) {
1135
- return defaultStore.getAccountStats(label);
1190
+ export function getAccountStats(label, type) {
1191
+ return defaultStore.getAccountStats(label, type);
1136
1192
  }
1137
1193
  export function getTerminalErrors() {
1138
1194
  return defaultStore.getTerminalErrors();
@@ -65,7 +65,8 @@ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: nu
65
65
  declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: AccountQuota, now: number, policy?: ProxyOveragePolicy): ProxyQuotaCooldownUpdate;
66
66
  /**
67
67
  * Seed each account's runtime quota from the persisted snapshots in
68
- * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
68
+ * ~/.neurolink/account-quotas.json (keyed by provider-qualified account key).
69
+ * Runtime state is
69
70
  * in-memory only, so without this the quota-aware ordering is blind after a
70
71
  * proxy restart: all accounts tie, selection falls back to token-store
71
72
  * enumeration order, and the first account served becomes self-reinforcing