@juspay/neurolink 9.85.0 → 9.86.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 (61) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/browser/neurolink.min.js +420 -412
  3. package/dist/context/contextCompactor.js +16 -2
  4. package/dist/context/stages/slidingWindowTruncator.js +76 -30
  5. package/dist/core/conversationMemoryManager.js +13 -2
  6. package/dist/core/redisConversationMemoryManager.js +10 -1
  7. package/dist/lib/context/contextCompactor.js +16 -2
  8. package/dist/lib/context/stages/slidingWindowTruncator.js +76 -30
  9. package/dist/lib/core/conversationMemoryManager.js +13 -2
  10. package/dist/lib/core/redisConversationMemoryManager.js +10 -1
  11. package/dist/lib/neurolink.d.ts +31 -6
  12. package/dist/lib/neurolink.js +163 -33
  13. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +39 -1
  14. package/dist/lib/server/routes/claudeProxyRoutes.js +300 -41
  15. package/dist/lib/skills/skillMatcher.d.ts +33 -4
  16. package/dist/lib/skills/skillMatcher.js +81 -6
  17. package/dist/lib/skills/skillSessionTracker.d.ts +52 -0
  18. package/dist/lib/skills/skillSessionTracker.js +150 -0
  19. package/dist/lib/skills/skillStoreRedis.d.ts +8 -0
  20. package/dist/lib/skills/skillStoreRedis.js +18 -2
  21. package/dist/lib/skills/skillStoreS3.d.ts +17 -2
  22. package/dist/lib/skills/skillStoreS3.js +78 -6
  23. package/dist/lib/skills/skillStores.d.ts +16 -2
  24. package/dist/lib/skills/skillStores.js +94 -5
  25. package/dist/lib/skills/skillTools.d.ts +26 -10
  26. package/dist/lib/skills/skillTools.js +190 -79
  27. package/dist/lib/skills/skillsManager.d.ts +25 -1
  28. package/dist/lib/skills/skillsManager.js +46 -4
  29. package/dist/lib/types/config.d.ts +5 -5
  30. package/dist/lib/types/conversation.d.ts +27 -0
  31. package/dist/lib/types/proxy.d.ts +30 -2
  32. package/dist/lib/types/skills.d.ts +145 -14
  33. package/dist/lib/types/skills.js +3 -3
  34. package/dist/lib/utils/conversationMemory.d.ts +1 -1
  35. package/dist/lib/utils/conversationMemory.js +15 -2
  36. package/dist/neurolink.d.ts +31 -6
  37. package/dist/neurolink.js +163 -33
  38. package/dist/server/routes/claudeProxyRoutes.d.ts +39 -1
  39. package/dist/server/routes/claudeProxyRoutes.js +300 -41
  40. package/dist/skills/skillMatcher.d.ts +33 -4
  41. package/dist/skills/skillMatcher.js +81 -6
  42. package/dist/skills/skillSessionTracker.d.ts +52 -0
  43. package/dist/skills/skillSessionTracker.js +149 -0
  44. package/dist/skills/skillStoreRedis.d.ts +8 -0
  45. package/dist/skills/skillStoreRedis.js +18 -2
  46. package/dist/skills/skillStoreS3.d.ts +17 -2
  47. package/dist/skills/skillStoreS3.js +78 -6
  48. package/dist/skills/skillStores.d.ts +16 -2
  49. package/dist/skills/skillStores.js +94 -5
  50. package/dist/skills/skillTools.d.ts +26 -10
  51. package/dist/skills/skillTools.js +190 -79
  52. package/dist/skills/skillsManager.d.ts +25 -1
  53. package/dist/skills/skillsManager.js +46 -4
  54. package/dist/types/config.d.ts +5 -5
  55. package/dist/types/conversation.d.ts +27 -0
  56. package/dist/types/proxy.d.ts +30 -2
  57. package/dist/types/skills.d.ts +145 -14
  58. package/dist/types/skills.js +3 -3
  59. package/dist/utils/conversationMemory.d.ts +1 -1
  60. package/dist/utils/conversationMemory.js +15 -2
  61. package/package.json +1 -1
@@ -11,7 +11,7 @@
11
11
  */
12
12
  import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
13
13
  import type { ModelRouter } from "../../proxy/modelRouter.js";
14
- import type { ParsedClaudeError, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState } from "../../types/index.js";
14
+ import type { AccountCooldownPlan, AccountQuota, ParsedClaudeError, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState } from "../../types/index.js";
15
15
  /** Resolve the configured primary's stable key to its current index in the
16
16
  * request's enabledAccounts list. Returns 0 (insertion-order fallback) when
17
17
  * no key is configured or the key cannot be matched (account disabled/
@@ -23,6 +23,41 @@ declare function resolveHomeIndex(enabledAccounts: ProxyPassthroughAccount[]): n
23
23
  * account once its rate limit window expires. Called at the start of each
24
24
  * request. Home is resolved fresh per call via resolveHomeIndex. */
25
25
  declare function maybeResetPrimaryToHome(enabledAccounts: ProxyPassthroughAccount[]): void;
26
+ /** Convert an Anthropic unified-window reset (Unix epoch SECONDS, per the
27
+ * `anthropic-ratelimit-unified-*-reset` headers) into epoch-ms. Tolerates a
28
+ * value already expressed in ms (some intermediaries normalise it). Returns
29
+ * undefined for absent/zero/past-or-garbage timestamps so callers can fall
30
+ * back to retry-after. */
31
+ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): number | undefined;
32
+ /**
33
+ * Decide how to cool an account after a genuine (non-anti-abuse) 429.
34
+ *
35
+ * The unified subscription limits expose per-window status + reset:
36
+ * - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
37
+ * - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
38
+ * Both mean "retrying this account is futile until its window resets" → rotate
39
+ * immediately (no same-account retries) and park the account until the ACTUAL
40
+ * reset — never the legacy 60s hardcap that let us re-hammer a spent account.
41
+ *
42
+ * Anything else (window still "allowed" but momentarily 429'd — a per-minute
43
+ * burst / acceleration limit) is transient: honor retry-after as a floor,
44
+ * allow a couple of jittered same-account retries, then a short cooldown.
45
+ */
46
+ declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number): AccountCooldownPlan;
47
+ /**
48
+ * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
49
+ * spend the account whose window refreshes SOONEST first, so its about-to-reset
50
+ * allowance isn't wasted, then move to accounts with longer-dated resets.
51
+ *
52
+ * Priority among usable accounts:
53
+ * 1. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
54
+ * 2. soonest SESSION (5h) reset
55
+ * 3. highest weekly utilization — finish off the one closest to done
56
+ * Accounts with no quota data yet keep insertion order (stable sort) and sit
57
+ * after those with a known soonest reset. Cooling/rejected accounts sort last,
58
+ * soonest-back-to-service first, as last resort.
59
+ */
60
+ declare function orderAccountsByQuota(accounts: ProxyPassthroughAccount[], now: number): ProxyPassthroughAccount[];
26
61
  /**
27
62
  * Create Claude-compatible proxy routes.
28
63
  *
@@ -57,6 +92,9 @@ export declare function isTransientHttpFailure(status: number, errBody: string):
57
92
  export declare const __testHooks: {
58
93
  resolveHomeIndex: typeof resolveHomeIndex;
59
94
  maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
95
+ planCooldownFor429: typeof planCooldownFor429;
96
+ orderAccountsByQuota: typeof orderAccountsByQuota;
97
+ resetEpochToMs: typeof resetEpochToMs;
60
98
  setConfiguredPrimaryAccountKey: (key: string | undefined) => void;
61
99
  getConfiguredPrimaryAccountKey: () => string | undefined;
62
100
  setPrimaryAccountIndex: (index: number) => void;
@@ -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
  },
@@ -9,12 +9,41 @@
9
9
  import type { SkillDefinition, SkillIndexItem, SkillSearchQuery } from "../types/index.js";
10
10
  /** Strip instructions from a full definition to form an index entry. */
11
11
  export declare function toSkillIndexItem(skill: SkillDefinition): SkillIndexItem;
12
+ /**
13
+ * Sort index entries by name (byte order, locale-independent) so every
14
+ * listing renders byte-identically across calls and store backends.
15
+ * Store enumeration order (Redis SCAN, fs.readdir, S3 listings) is not
16
+ * stable — an unsorted listing would shuffle between calls and invalidate
17
+ * provider prompt caches. Returns a new array.
18
+ */
19
+ export declare function sortSkillIndex(items: SkillIndexItem[]): SkillIndexItem[];
12
20
  /** Filter index entries by query/tag/scope. Active skills only. */
13
21
  export declare function filterSkillIndex(items: SkillIndexItem[], query: SkillSearchQuery): SkillIndexItem[];
14
22
  /**
15
- * Render the compact skills index injected into the system prompt.
16
- * Names + descriptions only instructions are never included; the model
17
- * loads them via search_skills. Returns null when nothing is visible so
18
- * callers can skip injection entirely.
23
+ * Reject resource paths that could escape a skill's namespace: absolute
24
+ * paths, traversal segments, and empty segments. The manager validates
25
+ * before reaching any store this is defense in depth for stores whose
26
+ * keys are built by concatenation (S3, Redis) or path joining.
27
+ */
28
+ export declare function isSafeSkillResourcePath(resourcePath: string): boolean;
29
+ /** Whether a skill is visible from the calling scope. */
30
+ export declare function isSkillVisibleInScope(skill: Pick<SkillDefinition, "scope" | "scopeIds">, scopeId?: string): boolean;
31
+ /**
32
+ * Render the `<available_skills>` block embedded in the use_skill tool
33
+ * description ("tool" discovery mode). One line per skill — name,
34
+ * description, tags — instructions never appear here.
35
+ *
36
+ * Budget handling is uniform and stateless: over budget, every
37
+ * description drops to its first sentence, then to a hard 80-char cap.
38
+ * The render is a pure function of the (sorted) index, so the tool
39
+ * description stays byte-identical across calls — a prerequisite for
40
+ * provider prompt caching. Names are never dropped.
41
+ */
42
+ export declare function renderSkillListing(items: SkillIndexItem[], budgetChars: number): string | null;
43
+ /**
44
+ * Render the compact skills index appended to the system prompt
45
+ * ("system-prompt" discovery mode). Names + descriptions only —
46
+ * instructions are never included; the model loads them via use_skill.
47
+ * Returns null when nothing is visible so callers can skip injection.
19
48
  */
20
49
  export declare function formatSkillsPromptIndex(items: SkillIndexItem[], maxItems: number): string | null;