@juspay/neurolink 12.7.3 → 12.7.5

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.
@@ -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
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  import { Agent } from "undici";
16
16
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
17
17
  import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
18
- import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
18
+ import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
19
19
  import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, mergeQuotaSnapshot, modelFamilyToken, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
20
20
  import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
21
21
  import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
@@ -72,6 +72,30 @@ const BLOCKED_UPSTREAM_HEADERS = new Set([
72
72
  ]);
73
73
  const PROXY_INTERNAL_ACCOUNT_LABEL = "proxy/internal";
74
74
  const PROXY_INTERNAL_ACCOUNT_TYPE = "internal";
75
+ function resolveRequestLogAccountIdentity(accountLabel, accountType) {
76
+ if (!accountLabel) {
77
+ return {};
78
+ }
79
+ if (accountType === "codex-oauth") {
80
+ return {
81
+ accountKey: accountLabel.startsWith("codex:")
82
+ ? accountLabel
83
+ : `codex:${accountLabel}`,
84
+ provider: "openai",
85
+ };
86
+ }
87
+ if (accountType === "oauth" ||
88
+ accountType === "api_key" ||
89
+ accountType === "passthrough") {
90
+ return {
91
+ accountKey: accountType === "passthrough"
92
+ ? accountLabel
93
+ : normalizeAnthropicAccountKey(accountLabel),
94
+ provider: "anthropic",
95
+ };
96
+ }
97
+ return {};
98
+ }
75
99
  // ---------------------------------------------------------------------------
76
100
  // Module-level state
77
101
  // ---------------------------------------------------------------------------
@@ -634,7 +658,8 @@ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
634
658
  }
635
659
  /**
636
660
  * Seed each account's runtime quota from the persisted snapshots in
637
- * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
661
+ * ~/.neurolink/account-quotas.json (keyed by provider-qualified account key).
662
+ * Runtime state is
638
663
  * in-memory only, so without this the quota-aware ordering is blind after a
639
664
  * proxy restart: all accounts tie, selection falls back to token-store
640
665
  * enumeration order, and the first account served becomes self-reinforcing
@@ -652,8 +677,13 @@ async function seedRuntimeQuotasFromDisk(accounts) {
652
677
  const now = Date.now();
653
678
  for (const account of accounts) {
654
679
  const state = getOrCreateRuntimeState(account.key);
655
- if (!state.quota && persistedQuotas[account.label]) {
656
- state.quota = persistedQuotas[account.label];
680
+ if (!state.quota) {
681
+ // Before provider identity was introduced, Anthropic snapshots were
682
+ // written under the bare display label. Keep that reading as an
683
+ // upgrade bridge, but never write new data back under the ambiguous
684
+ // key: a Codex login can legitimately use the same email.
685
+ state.quota =
686
+ persistedQuotas[account.key] ?? persistedQuotas[account.label];
657
687
  }
658
688
  const persistedCooldown = persistedCooldowns[account.key];
659
689
  if (persistedCooldown?.coolingUntil > now &&
@@ -708,7 +738,7 @@ async function applyAccountUsageResult(account, fetchResult, observedAt, prior)
708
738
  // Non-fatal: the next successful response will reconcile again.
709
739
  });
710
740
  }
711
- await saveAccountQuota(account.label, quota).catch(() => {
741
+ await saveAccountQuota(account.key, quota).catch(() => {
712
742
  // Non-fatal: quota persistence is best-effort.
713
743
  });
714
744
  return quota;
@@ -759,7 +789,11 @@ async function refreshAccountLimits(options = {}) {
759
789
  key: account.key,
760
790
  type: account.type,
761
791
  status,
762
- quota: quota ?? state?.quota ?? persisted[account.label] ?? null,
792
+ quota: quota ??
793
+ state?.quota ??
794
+ persisted[account.key] ??
795
+ persisted[account.label] ??
796
+ null,
763
797
  };
764
798
  if (error !== undefined) {
765
799
  result.error = error;
@@ -815,7 +849,7 @@ async function refreshAccountLimits(options = {}) {
815
849
  results[index] = buildResult(account, "error", null, fetchResult.error);
816
850
  continue;
817
851
  }
818
- const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.label] ?? null);
852
+ const quota = await applyAccountUsageResult(account, fetchResult, refresh.startedAt, persisted[account.key] ?? persisted[account.label] ?? null);
819
853
  if (!quota) {
820
854
  results[index] = buildResult(account, "error", null, "usage payload had no recognizable limit windows");
821
855
  continue;
@@ -2832,7 +2866,7 @@ async function auditCompleteShareHeartbeat(grant, reportedCoins) {
2832
2866
  return { paused: false, detail: "no provisioned account recorded" };
2833
2867
  }
2834
2868
  const state = accountRuntimeState.get(`anthropic:${accountLabel}`);
2835
- const stats = getAccountStats(accountLabel);
2869
+ const stats = getAccountStats(accountLabel, "oauth");
2836
2870
  const { verdict, shouldPause } = await recordAuditObservation({
2837
2871
  grantId: grant.id,
2838
2872
  accountLabel,
@@ -3453,6 +3487,7 @@ async function executeClaudeCodexFallback(args) {
3453
3487
  query: {},
3454
3488
  params: {},
3455
3489
  body: convertClaudeRequestToCodex(body, model),
3490
+ metadata: { ...ctx.metadata, "neurolink.codexFallback": true },
3456
3491
  // Keep the child attribution isolated until its stream has passed
3457
3492
  // validation. A failed Codex attempt must not look like a served request.
3458
3493
  responseHeaders: {},
@@ -4059,7 +4094,7 @@ async function handleAnthropicSuccessfulResponse(args) {
4059
4094
  // Non-fatal: the next successful response will reconcile again.
4060
4095
  });
4061
4096
  }
4062
- saveAccountQuota(account.label, quota).catch(() => {
4097
+ saveAccountQuota(account.key, quota).catch(() => {
4063
4098
  // Non-fatal: quota persistence is best-effort
4064
4099
  });
4065
4100
  }
@@ -4725,7 +4760,7 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
4725
4760
  // Non-fatal: the next successful response will reconcile again.
4726
4761
  });
4727
4762
  }
4728
- saveAccountQuota(account.label, retryQuota).catch((error) => {
4763
+ saveAccountQuota(account.key, retryQuota).catch((error) => {
4729
4764
  logger.debug("[proxy] Failed to persist account quota after auth retry", {
4730
4765
  account: account.label,
4731
4766
  error: error instanceof Error ? error.message : String(error),
@@ -5016,7 +5051,7 @@ async function handleAnthropicAuthRetry(args) {
5016
5051
  accountState.coolingReason = retryPlan.reason;
5017
5052
  }
5018
5053
  if (retryQuota429) {
5019
- saveAccountQuota(account.label, retryQuota429).catch(() => {
5054
+ saveAccountQuota(account.key, retryQuota429).catch(() => {
5020
5055
  // Non-fatal: routing already has the in-memory snapshot.
5021
5056
  });
5022
5057
  }
@@ -5580,9 +5615,13 @@ function createClaudeRequestRuntimeContext(args) {
5580
5615
  : status >= 400
5581
5616
  ? PROXY_INTERNAL_ACCOUNT_TYPE
5582
5617
  : undefined;
5618
+ const finalAccountIdentity = resolveRequestLogAccountIdentity(finalAccountLabel, finalAccountType);
5583
5619
  if (status >= 400) {
5584
5620
  recordFinalError(status, finalAccountLabel, finalAccountType, {
5585
5621
  requestId: ctx.requestId,
5622
+ ...(finalAccountIdentity.accountKey
5623
+ ? { accountKey: finalAccountIdentity.accountKey }
5624
+ : {}),
5586
5625
  errorType,
5587
5626
  terminalOutcome: errorType === "client_cancelled"
5588
5627
  ? "client_cancelled"
@@ -5606,7 +5645,13 @@ function createClaudeRequestRuntimeContext(args) {
5606
5645
  stream: !!body.stream,
5607
5646
  toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
5608
5647
  account: finalAccountLabel ?? "",
5648
+ ...(finalAccountIdentity.accountKey
5649
+ ? { accountKey: finalAccountIdentity.accountKey }
5650
+ : {}),
5609
5651
  accountType: finalAccountType ?? "",
5652
+ ...(finalAccountIdentity.provider
5653
+ ? { provider: finalAccountIdentity.provider }
5654
+ : {}),
5610
5655
  ...buildClientAttribution(ctx.headers),
5611
5656
  responseStatus: status,
5612
5657
  responseTimeMs: Date.now() - requestStartTime,
@@ -5669,6 +5714,7 @@ function createClaudeRequestRuntimeContext(args) {
5669
5714
  function createAnthropicAttemptLogger(args) {
5670
5715
  const { ctx, body, toolCount, requestStart, tracer, account, attemptNumber } = args;
5671
5716
  const attemptStartedAt = Date.now();
5717
+ const accountIdentity = resolveRequestLogAccountIdentity(account.label, account.type);
5672
5718
  return (status, errorType, errorMessage, extra) => {
5673
5719
  const attemptCompletedAt = Date.now();
5674
5720
  const traceCtx = tracer?.getTraceContext();
@@ -5682,7 +5728,11 @@ function createAnthropicAttemptLogger(args) {
5682
5728
  stream: !!body.stream,
5683
5729
  toolCount,
5684
5730
  account: account.label,
5731
+ accountKey: account.key,
5685
5732
  accountType: account.type,
5733
+ ...(accountIdentity.provider
5734
+ ? { provider: accountIdentity.provider }
5735
+ : {}),
5686
5736
  responseStatus: status,
5687
5737
  responseTimeMs: attemptCompletedAt - requestStart,
5688
5738
  attemptDurationMs: extra?.attemptDurationMs ?? attemptCompletedAt - attemptStartedAt,
@@ -6335,7 +6385,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6335
6385
  // Refresh the account's quota snapshot for proactive selection.
6336
6386
  if (fetchResult.quota) {
6337
6387
  accountState.quota = mergeQuotaSnapshot(accountState.quota, fetchResult.quota);
6338
- saveAccountQuota(account.label, fetchResult.quota).catch(() => {
6388
+ saveAccountQuota(account.key, fetchResult.quota).catch(() => {
6339
6389
  // Non-fatal: routing already has the in-memory snapshot.
6340
6390
  });
6341
6391
  }
@@ -17,7 +17,12 @@
17
17
  * never collides with anthropic entries) and does pre-commit rotation only, not
18
18
  * the full transient-budget / admission machinery.
19
19
  */
20
- import type { AccountCoolingReason, AccountQuota, CodexRuntimeAccount, RouteGroup, ServerContext } from "../../types/index.js";
20
+ import type { AccountQuota, CodexRefreshTokenStore, CodexRuntimeAccount, CodexTokenRefresher, RateLimitCoolingReason, RouteGroup, ServerContext } from "../../types/index.js";
21
+ declare function refreshCodexTokenOnceWithDependencies(key: string, refreshToken: string, store: CodexRefreshTokenStore, refresh: CodexTokenRefresher): Promise<{
22
+ accessToken: string;
23
+ refreshToken: string;
24
+ expiresAt?: number;
25
+ }>;
21
26
  /** Refresh an account's token at most once at a time. */
22
27
  declare function refreshCodexTokenOnce(key: string, refreshToken: string): Promise<{
23
28
  accessToken: string;
@@ -44,7 +49,7 @@ declare function buildCodexUpstreamHeaders(clientHeaders: Record<string, string>
44
49
  */
45
50
  declare function planCodexCooldown(quota: AccountQuota | null, retryAfterMs: number, now: number): {
46
51
  coolingUntil: number;
47
- reason: AccountCoolingReason;
52
+ reason: RateLimitCoolingReason;
48
53
  };
49
54
  /** Core pooled handler for POST /backend-api/codex/responses. */
50
55
  export declare function handleCodexResponsesRequest(ctx: ServerContext): Promise<Response>;
@@ -61,6 +66,7 @@ export declare const __testHooks: {
61
66
  buildCodexUpstreamHeaders: typeof buildCodexUpstreamHeaders;
62
67
  planCodexCooldown: typeof planCodexCooldown;
63
68
  refreshCodexTokenOnce: typeof refreshCodexTokenOnce;
69
+ refreshCodexTokenOnceWithDependencies: typeof refreshCodexTokenOnceWithDependencies;
64
70
  codexRefreshInFlightSize: () => number;
65
71
  };
66
72
  export {};