@juspay/neurolink 9.85.0 → 9.85.1

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.
@@ -58,12 +58,26 @@ const MAX_AUTH_RETRIES = 5;
58
58
  const MAX_CONSECUTIVE_REFRESH_FAILURES = 15;
59
59
  const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
60
60
  const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
61
- /** Maximum upstream 429 attempts per account before rotating to the next account.
62
- * Total attempts per account = this + 1 (the initial call plus this many retries). */
63
- const MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES = 10;
64
- /** Max time to sleep between 429 retries. Caps large upstream retry-after values
65
- * so we don't hold the client connection open for minutes. */
61
+ /** Maximum upstream 429 attempts per account before rotating for a TRANSIENT
62
+ * burst 429 only (window still "allowed", short retry-after). An exhaustion 429
63
+ * (5h/7d window "rejected") rotates immediately with zero same-account retries,
64
+ * because retrying is futile until the window resets. Kept small: retrying a
65
+ * rate-limited account more than a couple of times only burns the client's
66
+ * wall-clock and, under fill-first, delays reaching a healthy account. */
67
+ const MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES = 2;
68
+ /** Max time to sleep between transient 429 retries. Caps large upstream
69
+ * retry-after values so we don't hold the client connection open for minutes. */
66
70
  const MAX_RATE_LIMIT_RETRY_DELAY_MS = 30_000;
71
+ /** Upper bound on any cooldown, as a sanity clamp against a garbage/huge reset
72
+ * epoch. Must exceed the 7-day weekly window so a genuine weekly-exhaustion
73
+ * cooldown is never truncated. */
74
+ const MAX_COOLDOWN_MS = 8 * 24 * 60 * 60 * 1000; // 8 days
75
+ /** Lower bound on any cooldown so we never busy-loop back onto a spent account. */
76
+ const MIN_COOLDOWN_MS = 5_000;
77
+ /** Cap for transient (per-minute burst) cooldowns — these recover quickly, so
78
+ * we should return to the account soon rather than parking it for the full
79
+ * reset window. */
80
+ const TRANSIENT_MAX_COOLDOWN_MS = 15 * 60 * 1000; // 15 minutes
67
81
  /** Timeout for upstream requests to Anthropic. Must be generous enough
68
82
  * to cover the full lifecycle of streaming responses, including extended
69
83
  * thinking from Opus models (which can exceed 5 minutes for large contexts). */
@@ -132,6 +146,161 @@ function isAccountCooling(accountKey) {
132
146
  return !!state?.coolingUntil && Date.now() < state.coolingUntil;
133
147
  }
134
148
  // ---------------------------------------------------------------------------
149
+ // Quota-aware cooldown helpers
150
+ // ---------------------------------------------------------------------------
151
+ /** Convert an Anthropic unified-window reset (Unix epoch SECONDS, per the
152
+ * `anthropic-ratelimit-unified-*-reset` headers) into epoch-ms. Tolerates a
153
+ * value already expressed in ms (some intermediaries normalise it). Returns
154
+ * undefined for absent/zero/past-or-garbage timestamps so callers can fall
155
+ * back to retry-after. */
156
+ function resetEpochToMs(resetEpoch, now) {
157
+ if (!resetEpoch || resetEpoch <= 0) {
158
+ return undefined;
159
+ }
160
+ // Heuristic: a value beyond ~year 2100 in seconds (4102444800) is already ms.
161
+ const ms = resetEpoch > 4_102_444_800 ? resetEpoch : resetEpoch * 1000;
162
+ return ms > now ? ms : undefined;
163
+ }
164
+ /** Clamp a cooldown target epoch-ms into [now+MIN, now+MAX]. */
165
+ function clampCooldownUntil(untilMs, now) {
166
+ return Math.min(Math.max(untilMs, now + MIN_COOLDOWN_MS), now + MAX_COOLDOWN_MS);
167
+ }
168
+ /**
169
+ * Decide how to cool an account after a genuine (non-anti-abuse) 429.
170
+ *
171
+ * The unified subscription limits expose per-window status + reset:
172
+ * - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
173
+ * - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
174
+ * Both mean "retrying this account is futile until its window resets" → rotate
175
+ * immediately (no same-account retries) and park the account until the ACTUAL
176
+ * reset — never the legacy 60s hardcap that let us re-hammer a spent account.
177
+ *
178
+ * Anything else (window still "allowed" but momentarily 429'd — a per-minute
179
+ * burst / acceleration limit) is transient: honor retry-after as a floor,
180
+ * allow a couple of jittered same-account retries, then a short cooldown.
181
+ */
182
+ function planCooldownFor429(quota, retryAfterMs, now) {
183
+ // Weekly exhaustion takes precedence — it's the longest, hardest ceiling.
184
+ if (quota && quota.weeklyStatus === "rejected") {
185
+ const reset = resetEpochToMs(quota.weeklyResetAt, now) ??
186
+ (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
187
+ return {
188
+ reason: "weekly",
189
+ coolingUntil: clampCooldownUntil(reset, now),
190
+ rotateImmediately: true,
191
+ };
192
+ }
193
+ if (quota && quota.sessionStatus === "rejected") {
194
+ const reset = resetEpochToMs(quota.sessionResetAt, now) ??
195
+ (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
196
+ return {
197
+ reason: "session",
198
+ coolingUntil: clampCooldownUntil(reset, now),
199
+ rotateImmediately: true,
200
+ };
201
+ }
202
+ // Transient burst: cool only for retry-after, floored at MIN_COOLDOWN_MS so a
203
+ // tiny retry-after can't make the account eligible almost immediately, and
204
+ // capped so it recovers quickly.
205
+ const base = retryAfterMs > 0 ? retryAfterMs : DEFAULT_COOLING_PERIOD_MS;
206
+ return {
207
+ reason: "transient",
208
+ coolingUntil: now +
209
+ Math.max(MIN_COOLDOWN_MS, Math.min(base, TRANSIENT_MAX_COOLDOWN_MS)),
210
+ rotateImmediately: false,
211
+ };
212
+ }
213
+ /** Human-readable minutes-until for cooldown log lines. */
214
+ function minutesUntil(untilMs, now) {
215
+ return Math.max(0, Math.round((untilMs - now) / 60000));
216
+ }
217
+ /**
218
+ * Proactively cool an account when a SUCCESS response reveals a window has just
219
+ * flipped to "rejected" (the boundary request that spends the last of the quota
220
+ * still returns 200 but reports rejected/next-reset). Parks the account until
221
+ * its reset so the next request skips it instead of discovering the limit via a
222
+ * 429. Never shortens an existing, longer cooldown.
223
+ */
224
+ function maybeCoolFromQuota(state, quota, now) {
225
+ let until;
226
+ let reason;
227
+ if (quota.weeklyStatus === "rejected") {
228
+ until = resetEpochToMs(quota.weeklyResetAt, now);
229
+ reason = "weekly";
230
+ }
231
+ else if (quota.sessionStatus === "rejected") {
232
+ until = resetEpochToMs(quota.sessionResetAt, now);
233
+ reason = "session";
234
+ }
235
+ if (until === undefined) {
236
+ return;
237
+ }
238
+ const clamped = clampCooldownUntil(until, now);
239
+ if (!state.coolingUntil || clamped > state.coolingUntil) {
240
+ state.coolingUntil = clamped;
241
+ state.coolingReason = reason;
242
+ logger.always(`[proxy] proactively cooling account (${reason}) ~${minutesUntil(clamped, now)}m from success-response quota (status rejected)`);
243
+ }
244
+ }
245
+ /** Quota-aware selection is on by default; disable with
246
+ * NEUROLINK_PROXY_QUOTA_ROUTING=off|false|0. Only affects the fill-first
247
+ * strategy (round-robin keeps strict rotation). */
248
+ function isQuotaRoutingEnabled() {
249
+ const v = (process.env.NEUROLINK_PROXY_QUOTA_ROUTING ?? "").toLowerCase();
250
+ return v !== "off" && v !== "false" && v !== "0";
251
+ }
252
+ /** Derive the ordering signals for an account from its latest runtime quota. */
253
+ function accountSortMetrics(accountKey, now) {
254
+ const st = accountRuntimeState.get(accountKey);
255
+ const q = st?.quota;
256
+ const coolingActive = !!st?.coolingUntil && now < st.coolingUntil;
257
+ const weeklyReset = resetEpochToMs(q?.weeklyResetAt, now);
258
+ const sessionReset = resetEpochToMs(q?.sessionResetAt, now);
259
+ const weeklyRejected = q?.weeklyStatus === "rejected" && weeklyReset !== undefined;
260
+ const sessionRejected = q?.sessionStatus === "rejected" && sessionReset !== undefined;
261
+ return {
262
+ usable: !coolingActive && !weeklyRejected && !sessionRejected,
263
+ coolingUntil: st?.coolingUntil ?? 0,
264
+ weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
265
+ sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
266
+ weeklyUsed: q?.weeklyUsed ?? -1,
267
+ };
268
+ }
269
+ /**
270
+ * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
271
+ * spend the account whose window refreshes SOONEST first, so its about-to-reset
272
+ * allowance isn't wasted, then move to accounts with longer-dated resets.
273
+ *
274
+ * Priority among usable accounts:
275
+ * 1. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
276
+ * 2. soonest SESSION (5h) reset
277
+ * 3. highest weekly utilization — finish off the one closest to done
278
+ * Accounts with no quota data yet keep insertion order (stable sort) and sit
279
+ * after those with a known soonest reset. Cooling/rejected accounts sort last,
280
+ * soonest-back-to-service first, as last resort.
281
+ */
282
+ function orderAccountsByQuota(accounts, now) {
283
+ return [...accounts].sort((a, b) => {
284
+ const ma = accountSortMetrics(a.key, now);
285
+ const mb = accountSortMetrics(b.key, now);
286
+ if (ma.usable !== mb.usable) {
287
+ return ma.usable ? -1 : 1;
288
+ }
289
+ if (!ma.usable && !mb.usable) {
290
+ const au = ma.coolingUntil || Number.POSITIVE_INFINITY;
291
+ const bu = mb.coolingUntil || Number.POSITIVE_INFINITY;
292
+ return au - bu;
293
+ }
294
+ if (ma.weeklyReset !== mb.weeklyReset) {
295
+ return ma.weeklyReset - mb.weeklyReset;
296
+ }
297
+ if (ma.sessionReset !== mb.sessionReset) {
298
+ return ma.sessionReset - mb.sessionReset;
299
+ }
300
+ return mb.weeklyUsed - ma.weeklyUsed;
301
+ });
302
+ }
303
+ // ---------------------------------------------------------------------------
135
304
  // OAuth polyfill helpers (extracted to reduce block nesting)
136
305
  // ---------------------------------------------------------------------------
137
306
  const snapshotCache = new Map();
@@ -1120,20 +1289,37 @@ async function loadClaudeProxyAccounts(args) {
1120
1289
  tracer?.end(401, Date.now() - requestStartTime);
1121
1290
  return { response: buildLoggedClaudeError(401, reauthMsg) };
1122
1291
  }
1123
- const orderedAccounts = [...enabledAccounts];
1124
- if (accountStrategy === "round-robin" &&
1125
- orderedAccounts.length !== lastKnownAccountCount) {
1126
- primaryAccountIndex = resolveHomeIndex(orderedAccounts);
1127
- lastKnownAccountCount = orderedAccounts.length;
1128
- }
1129
- if (orderedAccounts.length > 1) {
1130
- const idx = primaryAccountIndex % orderedAccounts.length;
1131
- if (accountStrategy === "round-robin") {
1132
- primaryAccountIndex = (primaryAccountIndex + 1) % orderedAccounts.length;
1292
+ let orderedAccounts = [...enabledAccounts];
1293
+ const quotaOrdered = accountStrategy === "fill-first" &&
1294
+ orderedAccounts.length > 1 &&
1295
+ isQuotaRoutingEnabled();
1296
+ if (quotaOrdered) {
1297
+ // Fill-first with a smart fill order: spend the account whose window resets
1298
+ // soonest first (max utilization), proactively skipping any whose window is
1299
+ // rejected until its reset. Supersedes the static home/primary index.
1300
+ orderedAccounts = orderAccountsByQuota(enabledAccounts, Date.now());
1301
+ if (logger.shouldLog("debug")) {
1302
+ logger.debug(`[proxy] quota-ordered fill sequence: ${orderedAccounts
1303
+ .map((a) => a.label)
1304
+ .join(" → ")}`);
1133
1305
  }
1134
- if (idx > 0) {
1135
- const head = orderedAccounts.splice(0, idx);
1136
- orderedAccounts.push(...head);
1306
+ }
1307
+ else {
1308
+ if (accountStrategy === "round-robin" &&
1309
+ orderedAccounts.length !== lastKnownAccountCount) {
1310
+ primaryAccountIndex = resolveHomeIndex(orderedAccounts);
1311
+ lastKnownAccountCount = orderedAccounts.length;
1312
+ }
1313
+ if (orderedAccounts.length > 1) {
1314
+ const idx = primaryAccountIndex % orderedAccounts.length;
1315
+ if (accountStrategy === "round-robin") {
1316
+ primaryAccountIndex =
1317
+ (primaryAccountIndex + 1) % orderedAccounts.length;
1318
+ }
1319
+ if (idx > 0) {
1320
+ const head = orderedAccounts.splice(0, idx);
1321
+ orderedAccounts.push(...head);
1322
+ }
1137
1323
  }
1138
1324
  }
1139
1325
  const normalizedAnthropicBody = normalizeClaudeRequestForAnthropic(body);
@@ -1466,6 +1652,11 @@ async function handleAnthropicSuccessfulResponse(args) {
1466
1652
  logger.always(`[proxy] ← ${response.status} account=${account.label}`);
1467
1653
  const quota = parseQuotaHeaders(response.headers);
1468
1654
  if (quota) {
1655
+ // Stash the latest quota on runtime state so the next request can pick the
1656
+ // account whose window resets soonest (max-utilization) and proactively
1657
+ // skip any whose window is already rejected — without eating a 429 first.
1658
+ accountState.quota = quota;
1659
+ maybeCoolFromQuota(accountState, quota, Date.now());
1469
1660
  saveAccountQuota(account.label, quota).catch(() => {
1470
1661
  // Non-fatal: quota persistence is best-effort
1471
1662
  });
@@ -1872,9 +2063,14 @@ async function handleAnthropicJsonSuccessResponse(args) {
1872
2063
  return { response: responseJson };
1873
2064
  }
1874
2065
  async function handleAnthropicSuccessfulRetryResponse(args) {
1875
- const { ctx, body, account, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2066
+ const { ctx, body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
1876
2067
  const retryQuota = parseQuotaHeaders(retryResp.headers);
1877
2068
  if (retryQuota) {
2069
+ // Keep the auth-retry success path in parity with the main success path:
2070
+ // stash quota for proactive selection and proactively cool if this
2071
+ // response reveals the window flipped to "rejected".
2072
+ accountState.quota = retryQuota;
2073
+ maybeCoolFromQuota(accountState, retryQuota, Date.now());
1878
2074
  saveAccountQuota(account.label, retryQuota).catch((error) => {
1879
2075
  logger.debug("[proxy] Failed to persist account quota after auth retry", {
1880
2076
  account: account.label,
@@ -2102,6 +2298,7 @@ async function handleAnthropicAuthRetry(args) {
2102
2298
  ctx,
2103
2299
  body,
2104
2300
  account,
2301
+ accountState,
2105
2302
  retryResp,
2106
2303
  tracer,
2107
2304
  requestStartTime,
@@ -2164,6 +2361,19 @@ async function handleAnthropicAuthRetry(args) {
2164
2361
  if (retryStatus === 429 &&
2165
2362
  !isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
2166
2363
  currentSawRateLimit = true;
2364
+ // Cool the account per its real reset window before rotating, so a
2365
+ // session/weekly-exhausted account isn't re-selected next request.
2366
+ const nowRetry = Date.now();
2367
+ const retryQuota429 = parseQuotaHeaders(retryRespHeaders);
2368
+ if (retryQuota429) {
2369
+ accountState.quota = retryQuota429;
2370
+ }
2371
+ const retryPlan = planCooldownFor429(retryQuota429, parseRetryAfterMs(retryRespHeaders["retry-after"] ?? null), nowRetry);
2372
+ if (!accountState.coolingUntil ||
2373
+ retryPlan.coolingUntil > accountState.coolingUntil) {
2374
+ accountState.coolingUntil = retryPlan.coolingUntil;
2375
+ accountState.coolingReason = retryPlan.reason;
2376
+ }
2167
2377
  advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
2168
2378
  break;
2169
2379
  }
@@ -2847,15 +3057,28 @@ async function fetchAnthropicAccountResponse(args) {
2847
3057
  upstreamSpan: undefined,
2848
3058
  };
2849
3059
  }
2850
- logger.always(`[proxy] 429 account=${account.label} retry-after=${retryAfterMs}ms (upstream) ratelimit-status=${errRespHeaders["anthropic-ratelimit-unified-status"] ?? "unknown"}`);
3060
+ // Parse the unified-window quota headers (present on real rate-limit 429s)
3061
+ // and derive a reset-aware cooldown plan. This is the fix for "kept
3062
+ // hammering the 5h/7d-exhausted account instead of switching": on an
3063
+ // exhaustion 429 we rotate immediately and park the account until its
3064
+ // ACTUAL reset, not a 60s hardcap.
3065
+ const now = Date.now();
3066
+ const quota = parseQuotaHeaders(errRespHeaders);
3067
+ const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now);
3068
+ logger.always(`[proxy] ← 429 account=${account.label} reason=${cooldownPlan.reason} ` +
3069
+ `retry-after=${retryAfterMs}ms 5h-status=${errRespHeaders["anthropic-ratelimit-unified-5h-status"] ?? "unknown"} ` +
3070
+ `7d-status=${errRespHeaders["anthropic-ratelimit-unified-7d-status"] ?? "unknown"} ` +
3071
+ `→ ${cooldownPlan.rotateImmediately ? `rotate now, cool ${minutesUntil(cooldownPlan.coolingUntil, now)}m` : "retry same account (transient)"}`);
2851
3072
  logAttempt(429, "rate_limit_error", String(lastError));
2852
3073
  tracer?.setError("rate_limit_error", String(lastError).slice(0, 500));
2853
3074
  tracer?.recordRetry(account.label, "rate_limit");
2854
3075
  currentUpstreamSpan?.end();
2855
3076
  return {
2856
3077
  continueLoop: true,
2857
- retrySameAccount: true,
3078
+ retrySameAccount: !cooldownPlan.rotateImmediately,
2858
3079
  retryAfterMs,
3080
+ cooldownPlan,
3081
+ ...(quota ? { quota } : {}),
2859
3082
  lastError,
2860
3083
  sawRateLimit,
2861
3084
  sawNetworkError,
@@ -2897,7 +3120,14 @@ async function handleAnthropicRoutedClaudeRequest(args) {
2897
3120
  };
2898
3121
  const acctSelectionSpan = tracer?.startAccountSelection();
2899
3122
  // Try to return to the home primary account if its cooling has expired.
2900
- maybeResetPrimaryToHome(enabledAccounts);
3123
+ // Skipped under quota routing, where the fill order is derived per-request
3124
+ // from live quota (soonest-reset-first) rather than a static home index.
3125
+ const usingQuotaOrder = accountStrategy === "fill-first" &&
3126
+ enabledAccounts.length > 1 &&
3127
+ isQuotaRoutingEnabled();
3128
+ if (!usingQuotaOrder) {
3129
+ maybeResetPrimaryToHome(enabledAccounts);
3130
+ }
2901
3131
  // Skip accounts that are still cooling from a recent 429-exhaustion,
2902
3132
  // but keep them as last-resort if ALL accounts are cooling.
2903
3133
  const nonCoolingAccounts = orderedAccounts.filter((a) => !isAccountCooling(a.key));
@@ -2972,24 +3202,38 @@ async function handleAnthropicRoutedClaudeRequest(args) {
2972
3202
  loopState.sawRateLimit = fetchResult.sawRateLimit;
2973
3203
  loopState.sawNetworkError = fetchResult.sawNetworkError;
2974
3204
  if (fetchResult.continueLoop || !fetchResult.response) {
2975
- // 429 with retry-after: wait and retry same account up to 5 times
2976
- if (fetchResult.retrySameAccount &&
2977
- fetchResult.retryAfterMs !== undefined &&
2978
- rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES) {
2979
- rateLimitSameAccountRetries += 1;
2980
- const delayMs = Math.min(fetchResult.retryAfterMs || 1_000, MAX_RATE_LIMIT_RETRY_DELAY_MS);
2981
- logger.always(`[proxy] retrying same account=${account.label} after upstream 429 (${rateLimitSameAccountRetries}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
2982
- await sleep(delayMs);
2983
- continue;
2984
- }
2985
- // Rate-limit retries exhausted for this account — rotate
2986
- if (fetchResult.retrySameAccount &&
2987
- fetchResult.retryAfterMs !== undefined) {
2988
- // Mark account as cooling so subsequent requests don't hammer it
2989
- const coolingMs = Math.min(fetchResult.retryAfterMs || DEFAULT_COOLING_PERIOD_MS, DEFAULT_COOLING_PERIOD_MS);
2990
- accountState.coolingUntil = Date.now() + coolingMs;
3205
+ // Genuine 429 (carries a cooldown plan derived from quota headers).
3206
+ if (fetchResult.cooldownPlan) {
3207
+ const plan = fetchResult.cooldownPlan;
3208
+ // Refresh the account's quota snapshot for proactive selection.
3209
+ if (fetchResult.quota) {
3210
+ accountState.quota = fetchResult.quota;
3211
+ }
3212
+ // Transient burst (window still allowed): a couple of jittered
3213
+ // same-account retries before giving up. Exhaustion plans set
3214
+ // rotateImmediately, so retrySameAccount is false and we skip this.
3215
+ if (fetchResult.retrySameAccount &&
3216
+ fetchResult.retryAfterMs !== undefined &&
3217
+ rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES) {
3218
+ rateLimitSameAccountRetries += 1;
3219
+ const base = Math.min(fetchResult.retryAfterMs || 1_000, MAX_RATE_LIMIT_RETRY_DELAY_MS);
3220
+ // Cap AFTER jitter so the final sleep never exceeds the cap.
3221
+ const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base));
3222
+ logger.always(`[proxy] retrying same account=${account.label} after transient 429 (${rateLimitSameAccountRetries}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
3223
+ await sleep(delayMs);
3224
+ continue;
3225
+ }
3226
+ // Exhaustion, or transient retries used up: park the account until
3227
+ // its ACTUAL reset (5h/7d) — no 60s hardcap — and rotate. Extend-only
3228
+ // so a concurrent short transient plan can't shorten a longer weekly
3229
+ // cooldown already set by another in-flight request.
3230
+ if (!accountState.coolingUntil ||
3231
+ plan.coolingUntil > accountState.coolingUntil) {
3232
+ accountState.coolingUntil = plan.coolingUntil;
3233
+ accountState.coolingReason = plan.reason;
3234
+ }
2991
3235
  advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
2992
- logger.always(`[proxy] exhausted ${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES} rate-limit retries for account=${account.label}; cooling for ${coolingMs}ms, rotating`);
3236
+ logger.always(`[proxy] account=${account.label} rate-limited (${plan.reason}); cooling ~${minutesUntil(plan.coolingUntil, Date.now())}m until ${new Date(plan.coolingUntil).toISOString()}, rotating`);
2993
3237
  continue accountLoop;
2994
3238
  }
2995
3239
  // Transient error retry (network errors, 529 overloaded)
@@ -3092,9 +3336,15 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3092
3336
  }
3093
3337
  break accountLoop;
3094
3338
  }
3095
- // Clear cooling on success — account is healthy again
3096
- if (accountState.coolingUntil) {
3339
+ // Clear cooling on success — but only if the stored cooldown has already
3340
+ // expired, so an older in-flight success can't wipe an active exhaustion
3341
+ // cooldown just set by a concurrent 429. The success handler re-applies a
3342
+ // cooldown via maybeCoolFromQuota if the fresh quota headers report the
3343
+ // window flipped to "rejected" on this very request.
3344
+ if (accountState.coolingUntil &&
3345
+ Date.now() >= accountState.coolingUntil) {
3097
3346
  accountState.coolingUntil = undefined;
3347
+ accountState.coolingReason = undefined;
3098
3348
  }
3099
3349
  const successResult = await handleAnthropicSuccessfulResponse({
3100
3350
  ctx,
@@ -3371,6 +3621,12 @@ export function getTransientSameAccountRetryDelayMs(retryNumber) {
3371
3621
  async function sleep(ms) {
3372
3622
  await new Promise((resolve) => setTimeout(resolve, ms));
3373
3623
  }
3624
+ /** Honor `base` (e.g. retry-after) as a floor and add positive jitter on top,
3625
+ * so a fleet of concurrent requests (parallel subagents) doesn't wake and
3626
+ * retry in the same millisecond and re-trip the limit together. */
3627
+ function jitteredDelay(base) {
3628
+ return base + Math.floor(Math.random() * base * 0.25);
3629
+ }
3374
3630
  /**
3375
3631
  * Get low-level network error code from an unknown error shape.
3376
3632
  */
@@ -3518,6 +3774,9 @@ export function isTransientHttpFailure(status, errBody) {
3518
3774
  export const __testHooks = {
3519
3775
  resolveHomeIndex,
3520
3776
  maybeResetPrimaryToHome,
3777
+ planCooldownFor429,
3778
+ orderAccountsByQuota,
3779
+ resetEpochToMs,
3521
3780
  setConfiguredPrimaryAccountKey: (key) => {
3522
3781
  configuredPrimaryAccountKey = key;
3523
3782
  },
@@ -574,11 +574,26 @@ export type PreparedAnthropicAccountAttempt = {
574
574
  fetchStartMs?: number;
575
575
  upstreamSpan?: Span;
576
576
  };
577
+ /** How to cool an account after a genuine (non-anti-abuse) 429, derived from
578
+ * the response's quota headers + retry-after. */
579
+ export type AccountCooldownPlan = {
580
+ reason: AccountCoolingReason;
581
+ /** Epoch-ms until which the account should not be used. */
582
+ coolingUntil: number;
583
+ /** When true (5h/7d window rejected), rotate immediately — retrying the same
584
+ * account is futile until its window resets. When false (transient burst),
585
+ * a small number of jittered same-account retries is allowed first. */
586
+ rotateImmediately: boolean;
587
+ };
577
588
  export type AnthropicUpstreamFetchResult = {
578
589
  continueLoop: boolean;
579
590
  retrySameAccount?: boolean;
580
591
  /** When set, the caller should wait this many ms before retrying (from upstream retry-after). */
581
592
  retryAfterMs?: number;
593
+ /** Set on a genuine 429: how long / why to cool this account before rotating. */
594
+ cooldownPlan?: AccountCooldownPlan;
595
+ /** Quota snapshot parsed from the response headers (429 or success), if present. */
596
+ quota?: AccountQuota;
582
597
  response?: Response;
583
598
  lastError: unknown;
584
599
  sawRateLimit: boolean;
@@ -640,6 +655,12 @@ export type AccountQuota = {
640
655
  /** Epoch ms when we last captured this data */
641
656
  lastUpdated: number;
642
657
  };
658
+ /** Why an account is currently cooling. Drives cooldown duration and logging.
659
+ * - "weekly" : 7d unified limit rejected — cool until the weekly reset.
660
+ * - "session" : 5h unified limit rejected — cool until the session reset.
661
+ * - "transient" : short per-minute/burst 429 — cool for retry-after only.
662
+ * - "auth" : auth/refresh failures (reserved; currently rotate-only). */
663
+ export type AccountCoolingReason = "weekly" | "session" | "transient" | "auth";
643
664
  /** Runtime state for a proxy account. */
644
665
  export type RuntimeAccountState = {
645
666
  consecutiveRefreshFailures: number;
@@ -647,9 +668,16 @@ export type RuntimeAccountState = {
647
668
  lastToken?: string;
648
669
  lastRefreshToken?: string;
649
670
  /** Epoch-ms timestamp until which the account should not be used for new
650
- * requests (set after 429 retries are exhausted). Other requests arriving
651
- * during this window will skip the account rather than hammering it again. */
671
+ * requests. Set from the ACTUAL Anthropic reset window (5h/7d) on an
672
+ * exhaustion 429, or from retry-after on a transient burst. Other requests
673
+ * arriving during this window skip the account rather than hammering it. */
652
674
  coolingUntil?: number;
675
+ /** Why the account is cooling (set alongside coolingUntil). */
676
+ coolingReason?: AccountCoolingReason;
677
+ /** Latest quota snapshot parsed from Anthropic `anthropic-ratelimit-unified-*`
678
+ * headers on ANY response (success or 429). Drives proactive, reset-aware
679
+ * selection so we don't have to eat a 429 to discover an account is spent. */
680
+ quota?: AccountQuota;
653
681
  };
654
682
  /** A passthrough account used in the proxy route handler. */
655
683
  export type ProxyPassthroughAccount = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/neurolink",
3
- "version": "9.85.0",
3
+ "version": "9.85.1",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "description": "Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applications with 21+ providers: OpenAI, Anthropic, Google AI Studio, Google Vertex, AWS Bedrock, Azure OpenAI, Mistral, LiteLLM, SageMaker, Hugging Face, Ollama, OpenAI-compatible, OpenRouter, DeepSeek, NVIDIA NIM, LM Studio, llama.cpp, plus voice (OpenAI TTS, ElevenLabs, Deepgram, Azure Speech).",
6
6
  "author": {