@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +420 -412
- package/dist/context/contextCompactor.js +16 -2
- package/dist/context/stages/slidingWindowTruncator.js +76 -30
- package/dist/core/conversationMemoryManager.js +13 -2
- package/dist/core/redisConversationMemoryManager.js +10 -1
- package/dist/lib/context/contextCompactor.js +16 -2
- package/dist/lib/context/stages/slidingWindowTruncator.js +76 -30
- package/dist/lib/core/conversationMemoryManager.js +13 -2
- package/dist/lib/core/redisConversationMemoryManager.js +10 -1
- package/dist/lib/neurolink.d.ts +31 -6
- package/dist/lib/neurolink.js +163 -33
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +39 -1
- package/dist/lib/server/routes/claudeProxyRoutes.js +300 -41
- package/dist/lib/skills/skillMatcher.d.ts +33 -4
- package/dist/lib/skills/skillMatcher.js +81 -6
- package/dist/lib/skills/skillSessionTracker.d.ts +52 -0
- package/dist/lib/skills/skillSessionTracker.js +150 -0
- package/dist/lib/skills/skillStoreRedis.d.ts +8 -0
- package/dist/lib/skills/skillStoreRedis.js +18 -2
- package/dist/lib/skills/skillStoreS3.d.ts +17 -2
- package/dist/lib/skills/skillStoreS3.js +78 -6
- package/dist/lib/skills/skillStores.d.ts +16 -2
- package/dist/lib/skills/skillStores.js +94 -5
- package/dist/lib/skills/skillTools.d.ts +26 -10
- package/dist/lib/skills/skillTools.js +190 -79
- package/dist/lib/skills/skillsManager.d.ts +25 -1
- package/dist/lib/skills/skillsManager.js +46 -4
- package/dist/lib/types/config.d.ts +5 -5
- package/dist/lib/types/conversation.d.ts +27 -0
- package/dist/lib/types/proxy.d.ts +30 -2
- package/dist/lib/types/skills.d.ts +145 -14
- package/dist/lib/types/skills.js +3 -3
- package/dist/lib/utils/conversationMemory.d.ts +1 -1
- package/dist/lib/utils/conversationMemory.js +15 -2
- package/dist/neurolink.d.ts +31 -6
- package/dist/neurolink.js +163 -33
- package/dist/server/routes/claudeProxyRoutes.d.ts +39 -1
- package/dist/server/routes/claudeProxyRoutes.js +300 -41
- package/dist/skills/skillMatcher.d.ts +33 -4
- package/dist/skills/skillMatcher.js +81 -6
- package/dist/skills/skillSessionTracker.d.ts +52 -0
- package/dist/skills/skillSessionTracker.js +149 -0
- package/dist/skills/skillStoreRedis.d.ts +8 -0
- package/dist/skills/skillStoreRedis.js +18 -2
- package/dist/skills/skillStoreS3.d.ts +17 -2
- package/dist/skills/skillStoreS3.js +78 -6
- package/dist/skills/skillStores.d.ts +16 -2
- package/dist/skills/skillStores.js +94 -5
- package/dist/skills/skillTools.d.ts +26 -10
- package/dist/skills/skillTools.js +190 -79
- package/dist/skills/skillsManager.d.ts +25 -1
- package/dist/skills/skillsManager.js +46 -4
- package/dist/types/config.d.ts +5 -5
- package/dist/types/conversation.d.ts +27 -0
- package/dist/types/proxy.d.ts +30 -2
- package/dist/types/skills.d.ts +145 -14
- package/dist/types/skills.js +3 -3
- package/dist/utils/conversationMemory.d.ts +1 -1
- package/dist/utils/conversationMemory.js +15 -2
- package/package.json +1 -1
|
@@ -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
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
*
|
|
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
|
-
|
|
1124
|
-
|
|
1125
|
-
orderedAccounts.length
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
2976
|
-
if (fetchResult.
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
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]
|
|
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 —
|
|
3096
|
-
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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;
|
|
@@ -11,6 +11,16 @@ export function toSkillIndexItem(skill) {
|
|
|
11
11
|
const { instructions: _instructions, ...indexItem } = skill;
|
|
12
12
|
return indexItem;
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Sort index entries by name (byte order, locale-independent) so every
|
|
16
|
+
* listing renders byte-identically across calls and store backends.
|
|
17
|
+
* Store enumeration order (Redis SCAN, fs.readdir, S3 listings) is not
|
|
18
|
+
* stable — an unsorted listing would shuffle between calls and invalidate
|
|
19
|
+
* provider prompt caches. Returns a new array.
|
|
20
|
+
*/
|
|
21
|
+
export function sortSkillIndex(items) {
|
|
22
|
+
return [...items].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
23
|
+
}
|
|
14
24
|
/** Filter index entries by query/tag/scope. Active skills only. */
|
|
15
25
|
export function filterSkillIndex(items, query) {
|
|
16
26
|
const lowerQuery = query.query?.toLowerCase();
|
|
@@ -46,10 +56,75 @@ export function filterSkillIndex(items, query) {
|
|
|
46
56
|
return query.limit !== undefined ? matched.slice(0, query.limit) : matched;
|
|
47
57
|
}
|
|
48
58
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
59
|
+
* Reject resource paths that could escape a skill's namespace: absolute
|
|
60
|
+
* paths, traversal segments, and empty segments. The manager validates
|
|
61
|
+
* before reaching any store — this is defense in depth for stores whose
|
|
62
|
+
* keys are built by concatenation (S3, Redis) or path joining.
|
|
63
|
+
*/
|
|
64
|
+
export function isSafeSkillResourcePath(resourcePath) {
|
|
65
|
+
const normalized = resourcePath.replace(/\\/g, "/");
|
|
66
|
+
return (normalized.length > 0 &&
|
|
67
|
+
!normalized.startsWith("/") &&
|
|
68
|
+
!normalized.split("/").some((segment) => segment === ".." || segment === ""));
|
|
69
|
+
}
|
|
70
|
+
/** Whether a skill is visible from the calling scope. */
|
|
71
|
+
export function isSkillVisibleInScope(skill, scopeId) {
|
|
72
|
+
if (!scopeId || skill.scope !== "scoped") {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
return (skill.scopeIds ?? []).includes(scopeId);
|
|
76
|
+
}
|
|
77
|
+
/** Trim a description to its first sentence (or the whole text when unsplittable). */
|
|
78
|
+
function firstSentence(description) {
|
|
79
|
+
const match = description.match(/^[^.!?]*[.!?]/);
|
|
80
|
+
return match ? match[0].trim() : description;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Render the `<available_skills>` block embedded in the use_skill tool
|
|
84
|
+
* description ("tool" discovery mode). One line per skill — name,
|
|
85
|
+
* description, tags — instructions never appear here.
|
|
86
|
+
*
|
|
87
|
+
* Budget handling is uniform and stateless: over budget, every
|
|
88
|
+
* description drops to its first sentence, then to a hard 80-char cap.
|
|
89
|
+
* The render is a pure function of the (sorted) index, so the tool
|
|
90
|
+
* description stays byte-identical across calls — a prerequisite for
|
|
91
|
+
* provider prompt caching. Names are never dropped.
|
|
92
|
+
*/
|
|
93
|
+
export function renderSkillListing(items, budgetChars) {
|
|
94
|
+
if (items.length === 0) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const render = (describe) => {
|
|
98
|
+
const lines = items.map((item) => {
|
|
99
|
+
const tags = item.tags && item.tags.length > 0
|
|
100
|
+
? ` [tags: ${item.tags.join(", ")}]`
|
|
101
|
+
: "";
|
|
102
|
+
return `- ${item.name}: ${describe(item)}${tags}`;
|
|
103
|
+
});
|
|
104
|
+
return ["<available_skills>", ...lines, "</available_skills>"].join("\n");
|
|
105
|
+
};
|
|
106
|
+
const full = render((item) => item.description);
|
|
107
|
+
if (full.length <= budgetChars) {
|
|
108
|
+
return full;
|
|
109
|
+
}
|
|
110
|
+
const sentences = render((item) => firstSentence(item.description));
|
|
111
|
+
if (sentences.length <= budgetChars) {
|
|
112
|
+
return sentences;
|
|
113
|
+
}
|
|
114
|
+
const HARD_CAP = 80;
|
|
115
|
+
// Floor render — returned even if it still exceeds the budget.
|
|
116
|
+
return render((item) => {
|
|
117
|
+
const sentence = firstSentence(item.description);
|
|
118
|
+
return sentence.length > HARD_CAP
|
|
119
|
+
? `${sentence.slice(0, HARD_CAP - 1)}…`
|
|
120
|
+
: sentence;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Render the compact skills index appended to the system prompt
|
|
125
|
+
* ("system-prompt" discovery mode). Names + descriptions only —
|
|
126
|
+
* instructions are never included; the model loads them via use_skill.
|
|
127
|
+
* Returns null when nothing is visible so callers can skip injection.
|
|
53
128
|
*/
|
|
54
129
|
export function formatSkillsPromptIndex(items, maxItems) {
|
|
55
130
|
if (items.length === 0) {
|
|
@@ -66,13 +141,13 @@ export function formatSkillsPromptIndex(items, maxItems) {
|
|
|
66
141
|
return `- ${label}: ${item.description}${tags}`;
|
|
67
142
|
});
|
|
68
143
|
const truncationNote = items.length > visible.length
|
|
69
|
-
? `\n(${items.length - visible.length} more skills exist — use
|
|
144
|
+
? `\n(${items.length - visible.length} more skills exist — use list_skills to discover them.)`
|
|
70
145
|
: "";
|
|
71
146
|
return ([
|
|
72
147
|
"## Available Skills",
|
|
73
148
|
"The following team-defined skills (SOPs, playbooks, workflows) are available.",
|
|
74
149
|
"Before answering from general knowledge, check whether one applies to the user's request.",
|
|
75
|
-
"To use a skill, call the
|
|
150
|
+
"To use a skill, call the use_skill tool with its name to load the full instructions, then follow them exactly.",
|
|
76
151
|
"",
|
|
77
152
|
...lines,
|
|
78
153
|
].join("\n") + truncationNote);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session skill activation state.
|
|
3
|
+
*
|
|
4
|
+
* A skill activated via use_skill is pinned to its session: the tracker
|
|
5
|
+
* holds the pinned instruction message until the turn is persisted
|
|
6
|
+
* (drainPending → StoreConversationTurnOptions.skillMessages) and answers
|
|
7
|
+
* "is this skill already loaded?" so re-invocations return a cheap
|
|
8
|
+
* already_loaded note instead of the full body.
|
|
9
|
+
*
|
|
10
|
+
* The stored session history is the source of truth — pinned messages
|
|
11
|
+
* carry `metadata.isSkill`. hydrate() rebuilds a session's active set from
|
|
12
|
+
* stored messages on every activation attempt (use_skill is rare, one
|
|
13
|
+
* history read per attempt), so dedup stays correct across process
|
|
14
|
+
* restarts, multiple instances sharing a Redis memory backend, and failed
|
|
15
|
+
* persistence: a pin that never reached storage simply re-activates on
|
|
16
|
+
* the next turn instead of being falsely reported as loaded.
|
|
17
|
+
*/
|
|
18
|
+
import type { ChatMessage, SkillActivationRecord, SkillDefinition } from "../types/index.js";
|
|
19
|
+
/** Render the pinned history message for an activated skill. */
|
|
20
|
+
export declare function buildSkillActivationMessage(skill: SkillDefinition): ChatMessage;
|
|
21
|
+
export declare class SkillSessionTracker {
|
|
22
|
+
/** sessionId → skillId → activation record. */
|
|
23
|
+
private active;
|
|
24
|
+
/** sessionId → pinned messages awaiting persistence into the session turn. */
|
|
25
|
+
private pending;
|
|
26
|
+
/** Whether the skill is already active (loaded) in this session. */
|
|
27
|
+
isActive(sessionId: string, skillId: string, name: string): boolean;
|
|
28
|
+
/** Activation record for an active skill (by id, falling back to name). */
|
|
29
|
+
getActivation(sessionId: string, skillId: string, name?: string): SkillActivationRecord | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Record an activation: marks the skill active and queues its pinned
|
|
32
|
+
* message for persistence when the turn is stored.
|
|
33
|
+
*/
|
|
34
|
+
recordActivation(sessionId: string, skill: SkillDefinition): ChatMessage;
|
|
35
|
+
/**
|
|
36
|
+
* Rebuild the active set from stored session history, then merge the
|
|
37
|
+
* in-process pending activations (this turn's pins, not yet stored).
|
|
38
|
+
* Called before every dedup check — the rebuild keeps state truthful
|
|
39
|
+
* when other instances pinned skills or when a past pin never reached
|
|
40
|
+
* storage.
|
|
41
|
+
*/
|
|
42
|
+
hydrate(sessionId: string, storedMessages: ChatMessage[]): void;
|
|
43
|
+
/**
|
|
44
|
+
* Remove and return the pinned messages queued for this session. Called
|
|
45
|
+
* once per turn by the memory-store path; an empty result means nothing
|
|
46
|
+
* was activated this turn.
|
|
47
|
+
*/
|
|
48
|
+
drainPending(sessionId: string): ChatMessage[];
|
|
49
|
+
/** Active skill records for a session (listing/debugging). */
|
|
50
|
+
listActive(sessionId: string): SkillActivationRecord[];
|
|
51
|
+
private recordsFor;
|
|
52
|
+
}
|