@juspay/neurolink 9.87.2 → 9.87.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/browser/neurolink.min.js +381 -382
- package/dist/cli/commands/proxy.d.ts +16 -1
- package/dist/cli/commands/proxy.js +275 -238
- package/dist/lib/proxy/globalInstaller.d.ts +5 -0
- package/dist/lib/proxy/globalInstaller.js +156 -0
- package/dist/lib/proxy/openaiFormat.d.ts +3 -1
- package/dist/lib/proxy/openaiFormat.js +19 -4
- package/dist/lib/proxy/proxyActivity.d.ts +8 -0
- package/dist/lib/proxy/proxyActivity.js +77 -0
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +173 -6
- package/dist/lib/server/routes/claudeProxyRoutes.js +487 -159
- package/dist/lib/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/lib/types/cli.d.ts +4 -0
- package/dist/lib/types/proxy.d.ts +62 -0
- package/dist/proxy/globalInstaller.d.ts +5 -0
- package/dist/proxy/globalInstaller.js +155 -0
- package/dist/proxy/openaiFormat.d.ts +3 -1
- package/dist/proxy/openaiFormat.js +19 -4
- package/dist/proxy/proxyActivity.d.ts +8 -0
- package/dist/proxy/proxyActivity.js +76 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +173 -6
- package/dist/server/routes/claudeProxyRoutes.js +487 -159
- package/dist/server/routes/openaiProxyRoutes.js +47 -6
- package/dist/types/cli.d.ts +4 -0
- package/dist/types/proxy.d.ts +62 -0
- package/package.json +1 -1
|
@@ -23,7 +23,7 @@ import { withSpan } from "../../telemetry/withSpan.js";
|
|
|
23
23
|
import { ProxyTracer, recordFallbackAttempt } from "../../proxy/proxyTracer.js";
|
|
24
24
|
import { createRawStreamCapture } from "../../proxy/rawStreamCapture.js";
|
|
25
25
|
import { relocateClientSystemIntoMessages } from "../../proxy/systemRelocation.js";
|
|
26
|
-
import { logBodyCapture, logRequest, logRequestAttempt,
|
|
26
|
+
import { logBodyCapture, logRequest, logRequestAttempt, } from "../../proxy/requestLogger.js";
|
|
27
27
|
import { createSSEInterceptor } from "../../proxy/sseInterceptor.js";
|
|
28
28
|
import { createStreamTerminalOutcomeTracker, mergeStreamTerminalOutcome, } from "../../proxy/streamOutcome.js";
|
|
29
29
|
import { isPermanentRefreshFailure, needsRefresh, persistTokens, refreshToken, } from "../../proxy/tokenRefresh.js";
|
|
@@ -31,6 +31,7 @@ import { buildProxyTranslationPlan, parseRetryAfterMs, } from "../../proxy/routi
|
|
|
31
31
|
import { writeJsonSnapshotAtomically } from "../../proxy/snapshotPersistence.js";
|
|
32
32
|
import { recordAttempt, recordAttemptError, recordFinalError, recordFinalSuccess, } from "../../proxy/usageStats.js";
|
|
33
33
|
import { logger } from "../../utils/logger.js";
|
|
34
|
+
import { withTimeout } from "../../utils/async/withTimeout.js";
|
|
34
35
|
import { ProviderHealthChecker } from "../../utils/providerHealth.js";
|
|
35
36
|
// ---------------------------------------------------------------------------
|
|
36
37
|
// Helpers
|
|
@@ -61,6 +62,8 @@ let configuredAccountAllowlist;
|
|
|
61
62
|
const MAX_AUTH_RETRIES = 5;
|
|
62
63
|
const MAX_TRANSIENT_SAME_ACCOUNT_RETRIES = 2;
|
|
63
64
|
const TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS = [250, 1_000];
|
|
65
|
+
const MAX_FALLBACK_NETWORK_RETRIES = 1;
|
|
66
|
+
const FALLBACK_STREAM_IDLE_TIMEOUT_MS = 2 * 60 * 1000;
|
|
64
67
|
/** Maximum upstream 429 attempts per account before rotating — for a TRANSIENT
|
|
65
68
|
* burst 429 only (window still "allowed", short retry-after). An exhaustion 429
|
|
66
69
|
* (5h/7d window "rejected") rotates immediately with zero same-account retries,
|
|
@@ -91,12 +94,23 @@ const AUTH_REFRESH_MAX_COOLDOWN_MS = 5 * 60 * 1000;
|
|
|
91
94
|
* thinking from Opus models (which can exceed 5 minutes for large contexts). */
|
|
92
95
|
const UPSTREAM_FETCH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
|
|
93
96
|
const accountRuntimeState = new Map();
|
|
97
|
+
/** Shared across requests so a concurrent burst gets at most two retries for
|
|
98
|
+
* the account/window, rather than every request starting its own retry chain. */
|
|
99
|
+
const transientRateLimitRetryBudgets = new Map();
|
|
100
|
+
/** Pace requests that arrive while the last usable account has a short
|
|
101
|
+
* transient cooldown. This converts a local 429/retry storm into a bounded
|
|
102
|
+
* queue and avoids releasing every waiting request at the same millisecond. */
|
|
103
|
+
const transientCooldownAdmissionSchedules = new Map();
|
|
94
104
|
/** Track whether we've run the one-time startup prune. */
|
|
95
105
|
let startupPruneDone = false;
|
|
96
106
|
/** Default cooling period when retries are exhausted and upstream didn't
|
|
97
107
|
* provide a retry-after header. Short enough to recover quickly, long
|
|
98
108
|
* enough to avoid immediately hammering the same account. */
|
|
99
109
|
const DEFAULT_COOLING_PERIOD_MS = 60_000;
|
|
110
|
+
const MAX_TRANSIENT_QUEUE_WAIT_MS = 90_000;
|
|
111
|
+
const MAX_TRANSIENT_TOTAL_QUEUE_WAIT_MS = 120_000;
|
|
112
|
+
const TRANSIENT_ADMISSION_SPACING_MS = 250;
|
|
113
|
+
const MAX_TRANSIENT_ADMISSION_SPREAD_MS = 15_000;
|
|
100
114
|
/** Advance the primary account index when the current primary is exhausted
|
|
101
115
|
* (429 retries exhausted or auth failure). This is what makes fill-first work:
|
|
102
116
|
* we stick to one account until it's unusable. Only advances when the exhausted
|
|
@@ -159,6 +173,74 @@ function isAccountCooling(accountKey) {
|
|
|
159
173
|
const state = accountRuntimeState.get(accountKey);
|
|
160
174
|
return !!state?.coolingUntil && Date.now() < state.coolingUntil;
|
|
161
175
|
}
|
|
176
|
+
function claimTransientRateLimitRetry(accountKey, coolingUntil, now = Date.now()) {
|
|
177
|
+
let budget = transientRateLimitRetryBudgets.get(accountKey);
|
|
178
|
+
if (!budget || now >= budget.coolingUntil) {
|
|
179
|
+
budget = { coolingUntil, retriesClaimed: 0 };
|
|
180
|
+
transientRateLimitRetryBudgets.set(accountKey, budget);
|
|
181
|
+
}
|
|
182
|
+
else if (coolingUntil > budget.coolingUntil) {
|
|
183
|
+
budget.coolingUntil = coolingUntil;
|
|
184
|
+
}
|
|
185
|
+
if (budget.retriesClaimed >= MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
budget.retriesClaimed += 1;
|
|
189
|
+
return budget.retriesClaimed;
|
|
190
|
+
}
|
|
191
|
+
function claimTransientCooldownAdmission(accountKey, coolingUntil, now = Date.now()) {
|
|
192
|
+
if (coolingUntil <= now) {
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
if (coolingUntil - now > MAX_TRANSIENT_QUEUE_WAIT_MS) {
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
let schedule = transientCooldownAdmissionSchedules.get(accountKey);
|
|
199
|
+
if (!schedule || now >= schedule.coolingUntil) {
|
|
200
|
+
schedule = { coolingUntil, nextAdmissionAt: coolingUntil };
|
|
201
|
+
transientCooldownAdmissionSchedules.set(accountKey, schedule);
|
|
202
|
+
}
|
|
203
|
+
else if (coolingUntil > schedule.coolingUntil) {
|
|
204
|
+
schedule.coolingUntil = coolingUntil;
|
|
205
|
+
schedule.nextAdmissionAt = Math.max(schedule.nextAdmissionAt, coolingUntil);
|
|
206
|
+
}
|
|
207
|
+
const admissionAt = Math.max(now, schedule.coolingUntil, schedule.nextAdmissionAt);
|
|
208
|
+
if (admissionAt - schedule.coolingUntil > MAX_TRANSIENT_ADMISSION_SPREAD_MS) {
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
schedule.nextAdmissionAt = admissionAt + TRANSIENT_ADMISSION_SPACING_MS;
|
|
212
|
+
return admissionAt - now;
|
|
213
|
+
}
|
|
214
|
+
async function waitForTransientAccountAvailability(orderedAccounts) {
|
|
215
|
+
const deadline = Date.now() + MAX_TRANSIENT_TOTAL_QUEUE_WAIT_MS;
|
|
216
|
+
while (Date.now() < deadline) {
|
|
217
|
+
const available = orderedAccounts.filter((account) => !isAccountCooling(account.key));
|
|
218
|
+
if (available.length > 0) {
|
|
219
|
+
return available;
|
|
220
|
+
}
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
const transientCandidate = orderedAccounts
|
|
223
|
+
.map((account) => ({
|
|
224
|
+
account,
|
|
225
|
+
state: getOrCreateRuntimeState(account.key),
|
|
226
|
+
}))
|
|
227
|
+
.filter(({ state }) => state.coolingReason === "transient" &&
|
|
228
|
+
state.coolingUntil !== undefined &&
|
|
229
|
+
state.coolingUntil > now)
|
|
230
|
+
.sort((a, b) => (a.state.coolingUntil ?? Number.POSITIVE_INFINITY) -
|
|
231
|
+
(b.state.coolingUntil ?? Number.POSITIVE_INFINITY))[0];
|
|
232
|
+
if (!transientCandidate?.state.coolingUntil) {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
const waitMs = claimTransientCooldownAdmission(transientCandidate.account.key, transientCandidate.state.coolingUntil, now);
|
|
236
|
+
if (waitMs === undefined || now + waitMs > deadline) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
logger.always(`[proxy] all usable accounts are transiently cooling; queueing request for account=${transientCandidate.account.label} in ${waitMs}ms`);
|
|
240
|
+
await sleep(waitMs);
|
|
241
|
+
}
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
162
244
|
// ---------------------------------------------------------------------------
|
|
163
245
|
// Quota-aware cooldown helpers
|
|
164
246
|
// ---------------------------------------------------------------------------
|
|
@@ -315,45 +397,105 @@ function isQuotaRoutingEnabled() {
|
|
|
315
397
|
const v = (process.env.NEUROLINK_PROXY_QUOTA_ROUTING ?? "").toLowerCase();
|
|
316
398
|
return v !== "off" && v !== "false" && v !== "0";
|
|
317
399
|
}
|
|
318
|
-
/**
|
|
319
|
-
|
|
400
|
+
/** Session-utilization soft limit above which an account is demoted below
|
|
401
|
+
* accounts with headroom (never made unusable). The utilization snapshot is
|
|
402
|
+
* always at least one response stale and requests land in concurrent bursts,
|
|
403
|
+
* so a small buffer under 100% turns the window-exhaustion handoff proactive
|
|
404
|
+
* (scheduled between requests) instead of reactive (a burst of 429s).
|
|
405
|
+
* Override with NEUROLINK_PROXY_SESSION_SOFT_LIMIT (0 < x <= 1; 1 disables). */
|
|
406
|
+
function getSessionSoftLimit() {
|
|
407
|
+
// Number() rejects partially numeric values ("0.5oops") that parseFloat
|
|
408
|
+
// would silently truncate.
|
|
409
|
+
const raw = Number(process.env.NEUROLINK_PROXY_SESSION_SOFT_LIMIT ?? "");
|
|
410
|
+
return Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : 0.97;
|
|
411
|
+
}
|
|
412
|
+
/** Width of the bucket within which two session resets count as "the same
|
|
413
|
+
* time", letting the weekly reset decide. Exact epoch equality never occurs,
|
|
414
|
+
* and bucketing (unlike pairwise closeness) keeps the comparator transitive.
|
|
415
|
+
* Override with NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS. */
|
|
416
|
+
function getSessionResetToleranceMs() {
|
|
417
|
+
// Number() + isInteger rejects partially numeric values ("900000oops")
|
|
418
|
+
// that parseInt would silently truncate.
|
|
419
|
+
const raw = Number(process.env.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS ?? "");
|
|
420
|
+
return Number.isInteger(raw) && raw > 0 ? raw : 15 * 60 * 1000;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Derive the ordering signals for an account from its latest runtime quota.
|
|
424
|
+
*
|
|
425
|
+
* Reset freshening: a window whose reset epoch has already passed is treated
|
|
426
|
+
* as FRESH (0 used, "allowed", reset unknown) at read time — stale snapshot
|
|
427
|
+
* numbers from before the reset can never saturate or reject an account whose
|
|
428
|
+
* window Anthropic has since renewed. The snapshot itself is refreshed from
|
|
429
|
+
* live response headers the next time the account serves a request.
|
|
430
|
+
*/
|
|
431
|
+
function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs) {
|
|
320
432
|
const st = accountRuntimeState.get(accountKey);
|
|
321
433
|
const q = st?.quota;
|
|
322
434
|
const coolingActive = !!st?.coolingUntil && now < st.coolingUntil;
|
|
435
|
+
// resetEpochToMs returns undefined for absent OR passed resets, so a
|
|
436
|
+
// ticking window is exactly "reset !== undefined".
|
|
323
437
|
const weeklyReset = resetEpochToMs(q?.weeklyResetAt, now);
|
|
324
438
|
const sessionReset = resetEpochToMs(q?.sessionResetAt, now);
|
|
325
|
-
const
|
|
326
|
-
const
|
|
439
|
+
const sessionTicking = sessionReset !== undefined;
|
|
440
|
+
const weeklyTicking = weeklyReset !== undefined;
|
|
441
|
+
const sessionUsed = sessionTicking ? (q?.sessionUsed ?? 0) : 0;
|
|
442
|
+
const weeklyUsed = weeklyTicking ? (q?.weeklyUsed ?? -1) : q ? 0 : -1;
|
|
443
|
+
const sessionStatus = sessionTicking
|
|
444
|
+
? (q?.sessionStatus ?? "unknown")
|
|
445
|
+
: "allowed";
|
|
446
|
+
const weeklyStatus = weeklyTicking
|
|
447
|
+
? (q?.weeklyStatus ?? "unknown")
|
|
448
|
+
: "allowed";
|
|
449
|
+
const saturated = sessionStatus === "throttled" ||
|
|
450
|
+
(sessionTicking && sessionUsed >= sessionSoftLimit);
|
|
327
451
|
return {
|
|
328
|
-
usable: !coolingActive &&
|
|
452
|
+
usable: !coolingActive &&
|
|
453
|
+
weeklyStatus !== "rejected" &&
|
|
454
|
+
sessionStatus !== "rejected",
|
|
455
|
+
saturated,
|
|
329
456
|
hasQuota: !!q,
|
|
330
457
|
coolingUntil: st?.coolingUntil ?? 0,
|
|
331
|
-
|
|
458
|
+
sessionResetBucket: sessionTicking
|
|
459
|
+
? Math.floor(sessionReset / sessionResetToleranceMs)
|
|
460
|
+
: Number.POSITIVE_INFINITY,
|
|
332
461
|
sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
|
|
333
|
-
|
|
462
|
+
weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
|
|
463
|
+
weeklyUsed,
|
|
334
464
|
};
|
|
335
465
|
}
|
|
336
466
|
/**
|
|
337
467
|
* Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
|
|
338
|
-
* spend the
|
|
468
|
+
* spend the window that expires SOONEST first, so its about-to-reset
|
|
339
469
|
* allowance isn't wasted, then move to accounts with longer-dated resets.
|
|
340
470
|
*
|
|
341
471
|
* Priority among usable accounts:
|
|
342
472
|
* 1. no quota data yet — probe first: one request reveals its windows and
|
|
343
473
|
* self-corrects the ordering. (Ranking unknowns last would starve them
|
|
344
474
|
* forever: never picked → never observed → never comparable.)
|
|
345
|
-
* 2.
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
475
|
+
* 2. session headroom before session-saturated (>= soft limit or
|
|
476
|
+
* "throttled") — saturated accounts then follow the same bucketed
|
|
477
|
+
* session ordering below: soonest back in service first, weekly
|
|
478
|
+
* deciding same-bucket ties.
|
|
479
|
+
* 3. soonest SESSION (5h) reset — the capacity expiring soonest; compared
|
|
480
|
+
* in tolerance buckets so near-simultaneous resets count as equal.
|
|
481
|
+
* 4. soonest WEEKLY (7d) reset — decides between same-bucket sessions.
|
|
482
|
+
* 5. highest weekly utilization — finish off the one closest to done
|
|
483
|
+
* 6. configured primary account, then insertion order
|
|
484
|
+
* An account with NO ticking session window (fresh, not yet started) has
|
|
485
|
+
* nothing expiring and sorts after ticking windows in step 3.
|
|
349
486
|
* Cooling/rejected accounts sort last, soonest-back-to-service first, as
|
|
350
487
|
* last resort.
|
|
351
488
|
*/
|
|
352
489
|
function orderAccountsByQuota(accounts, now) {
|
|
353
490
|
const primaryKey = configuredPrimaryAccountKey;
|
|
491
|
+
// Read the env-tunable thresholds once per sort: cheaper than per-compare,
|
|
492
|
+
// and a mid-sort env change can never make the comparator intransitive.
|
|
493
|
+
const sessionSoftLimit = getSessionSoftLimit();
|
|
494
|
+
const sessionResetToleranceMs = getSessionResetToleranceMs();
|
|
495
|
+
const metrics = (key) => accountSortMetrics(key, now, sessionSoftLimit, sessionResetToleranceMs);
|
|
354
496
|
return [...accounts].sort((a, b) => {
|
|
355
|
-
const ma =
|
|
356
|
-
const mb =
|
|
497
|
+
const ma = metrics(a.key);
|
|
498
|
+
const mb = metrics(b.key);
|
|
357
499
|
if (ma.usable !== mb.usable) {
|
|
358
500
|
return ma.usable ? -1 : 1;
|
|
359
501
|
}
|
|
@@ -365,12 +507,18 @@ function orderAccountsByQuota(accounts, now) {
|
|
|
365
507
|
if (ma.hasQuota !== mb.hasQuota) {
|
|
366
508
|
return ma.hasQuota ? 1 : -1;
|
|
367
509
|
}
|
|
510
|
+
if (ma.saturated !== mb.saturated) {
|
|
511
|
+
return ma.saturated ? 1 : -1;
|
|
512
|
+
}
|
|
513
|
+
// Saturated pairs fall through to the same bucketed session ordering:
|
|
514
|
+
// soonest-back-in-service at bucket granularity, weekly deciding ties —
|
|
515
|
+
// honoring the configured tolerance instead of exact-epoch comparison.
|
|
516
|
+
if (ma.sessionResetBucket !== mb.sessionResetBucket) {
|
|
517
|
+
return ma.sessionResetBucket - mb.sessionResetBucket;
|
|
518
|
+
}
|
|
368
519
|
if (ma.weeklyReset !== mb.weeklyReset) {
|
|
369
520
|
return ma.weeklyReset - mb.weeklyReset;
|
|
370
521
|
}
|
|
371
|
-
if (ma.sessionReset !== mb.sessionReset) {
|
|
372
|
-
return ma.sessionReset - mb.sessionReset;
|
|
373
|
-
}
|
|
374
522
|
if (ma.weeklyUsed !== mb.weeklyUsed) {
|
|
375
523
|
return mb.weeklyUsed - ma.weeklyUsed;
|
|
376
524
|
}
|
|
@@ -645,13 +793,11 @@ function responseInfoFromStream(data) {
|
|
|
645
793
|
* into the body. Non-CC clients (Curator, custom apps) don't send these —
|
|
646
794
|
* Anthropic rejects without them.
|
|
647
795
|
*/
|
|
648
|
-
function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId) {
|
|
796
|
+
function polyfillOAuthBody(bodyStr, accountToken, snapshot, isClaudeClientRequest, preferredSessionId) {
|
|
649
797
|
try {
|
|
650
798
|
const parsed = JSON.parse(bodyStr);
|
|
651
|
-
// Billing header block
|
|
652
|
-
//
|
|
653
|
-
// Anthropic's prompt caching prefix chain. We keep the real Claude Code
|
|
654
|
-
// version/entrypoint shape when present, but stabilize the volatile cch.
|
|
799
|
+
// Billing header block synthesized for clients that do not already provide
|
|
800
|
+
// the genuine Claude Code identity shape.
|
|
655
801
|
const agentBlock = {
|
|
656
802
|
type: "text",
|
|
657
803
|
text: snapshot?.body?.agentBlock ||
|
|
@@ -661,11 +807,11 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
|
|
|
661
807
|
//
|
|
662
808
|
// The subscription/OAuth path only accepts a `system` it recognises as the
|
|
663
809
|
// genuine Claude Code prompt. A real CC client sends its own billing + agent
|
|
664
|
-
// identity blocks alongside the canonical prompt
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
810
|
+
// identity blocks alongside the canonical prompt, which must pass through
|
|
811
|
+
// unchanged. A custom client (Curator) sends its own arbitrary system prompt
|
|
812
|
+
// with NO agent block; left in `system` it is rejected as
|
|
813
|
+
// `rate_limit_error: "Error"`, so we relocate it into the message stream and
|
|
814
|
+
// send only the recognised billing + agent blocks as `system`.
|
|
669
815
|
if (parsed.system) {
|
|
670
816
|
if (typeof parsed.system === "string") {
|
|
671
817
|
parsed.system = [{ type: "text", text: parsed.system }];
|
|
@@ -679,28 +825,28 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
|
|
|
679
825
|
type: "text",
|
|
680
826
|
text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text ?? snapshot?.body?.billingHeader),
|
|
681
827
|
};
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
for (const idx of indicesToRemove) {
|
|
691
|
-
parsed.system.splice(idx, 1);
|
|
692
|
-
}
|
|
693
|
-
if (!isClaudeCodeClient && parsed.system.length > 0) {
|
|
694
|
-
// Non-CC client: relocate its system into the message stream so the
|
|
695
|
-
// subscription/OAuth path accepts the request, then send only the
|
|
696
|
-
// recognised billing + agent blocks as `system`.
|
|
697
|
-
relocateClientSystemIntoMessages(parsed, parsed.system);
|
|
698
|
-
parsed.system = [billingBlock, agentBlock];
|
|
828
|
+
const hasClaudeCodeSystemIdentity = isClaudeClientRequest && agentIdx >= 0;
|
|
829
|
+
if (hasClaudeCodeSystemIdentity) {
|
|
830
|
+
// Claude Code's subagent billing marker, block order, and cache
|
|
831
|
+
// controls are part of the accepted request shape. Preserve every
|
|
832
|
+
// supplied block exactly; only synthesize a billing block if absent.
|
|
833
|
+
if (billingIdx < 0) {
|
|
834
|
+
parsed.system.unshift(billingBlock);
|
|
835
|
+
}
|
|
699
836
|
}
|
|
700
837
|
else {
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
838
|
+
// Strip generated identity blocks before relocating a custom
|
|
839
|
+
// client's actual system instructions into the message stream.
|
|
840
|
+
const indicesToRemove = [billingIdx, agentIdx]
|
|
841
|
+
.filter((i) => i >= 0)
|
|
842
|
+
.sort((a, b) => b - a);
|
|
843
|
+
for (const idx of indicesToRemove) {
|
|
844
|
+
parsed.system.splice(idx, 1);
|
|
845
|
+
}
|
|
846
|
+
if (parsed.system.length > 0) {
|
|
847
|
+
relocateClientSystemIntoMessages(parsed, parsed.system);
|
|
848
|
+
}
|
|
849
|
+
parsed.system = [billingBlock, agentBlock];
|
|
704
850
|
}
|
|
705
851
|
}
|
|
706
852
|
}
|
|
@@ -1633,25 +1779,55 @@ async function loadClaudeProxyAccounts(args) {
|
|
|
1633
1779
|
}
|
|
1634
1780
|
async function executeClaudeFallbackTranslation(args) {
|
|
1635
1781
|
const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest, options, providerLabel, } = args;
|
|
1782
|
+
const fallbackAbortController = new AbortController();
|
|
1783
|
+
let streamResult;
|
|
1784
|
+
try {
|
|
1785
|
+
streamResult = await withTimeout(ctx.neurolink.stream({
|
|
1786
|
+
...options,
|
|
1787
|
+
abortSignal: fallbackAbortController.signal,
|
|
1788
|
+
}), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} initialization timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms`);
|
|
1789
|
+
}
|
|
1790
|
+
catch (error) {
|
|
1791
|
+
fallbackAbortController.abort(error);
|
|
1792
|
+
throw error;
|
|
1793
|
+
}
|
|
1794
|
+
const consumeFallbackStream = async (onText) => {
|
|
1795
|
+
const iterator = streamResult.stream[Symbol.asyncIterator]();
|
|
1796
|
+
let collectedText = "";
|
|
1797
|
+
try {
|
|
1798
|
+
while (true) {
|
|
1799
|
+
const { value: chunk, done } = await withTimeout(iterator.next(), FALLBACK_STREAM_IDLE_TIMEOUT_MS, `Fallback ${providerLabel} stream timed out after ${FALLBACK_STREAM_IDLE_TIMEOUT_MS}ms of inactivity`);
|
|
1800
|
+
if (done) {
|
|
1801
|
+
return collectedText;
|
|
1802
|
+
}
|
|
1803
|
+
const text = extractText(chunk);
|
|
1804
|
+
if (text) {
|
|
1805
|
+
collectedText += text;
|
|
1806
|
+
onText?.(text);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
catch (error) {
|
|
1811
|
+
fallbackAbortController.abort(error);
|
|
1812
|
+
void iterator.return?.().catch(() => {
|
|
1813
|
+
// Timeout/error already determines the request outcome.
|
|
1814
|
+
});
|
|
1815
|
+
throw error;
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1636
1818
|
if (body.stream) {
|
|
1637
|
-
const streamResult = await ctx.neurolink.stream(options);
|
|
1638
1819
|
const serializer = new ClaudeStreamSerializer(body.model, 0);
|
|
1639
1820
|
// Eagerly consume stream so errors fire synchronously and the
|
|
1640
1821
|
// fallback loop in tryConfiguredClaudeFallbackChain can catch them.
|
|
1641
1822
|
const frames = [];
|
|
1642
|
-
let collectedText = "";
|
|
1643
1823
|
for (const frame of serializer.start()) {
|
|
1644
1824
|
frames.push(frame);
|
|
1645
1825
|
}
|
|
1646
|
-
|
|
1647
|
-
const
|
|
1648
|
-
|
|
1649
|
-
collectedText += text;
|
|
1650
|
-
for (const frame of serializer.pushDelta(text)) {
|
|
1651
|
-
frames.push(frame);
|
|
1652
|
-
}
|
|
1826
|
+
const collectedText = await consumeFallbackStream((text) => {
|
|
1827
|
+
for (const frame of serializer.pushDelta(text)) {
|
|
1828
|
+
frames.push(frame);
|
|
1653
1829
|
}
|
|
1654
|
-
}
|
|
1830
|
+
});
|
|
1655
1831
|
const toolCalls = streamResult.toolCalls ?? [];
|
|
1656
1832
|
if (!hasTranslatedOutput(collectedText, toolCalls)) {
|
|
1657
1833
|
throw new Error(`Translated provider ${providerLabel} returned no content or tool calls`);
|
|
@@ -1696,14 +1872,7 @@ async function executeClaudeFallbackTranslation(args) {
|
|
|
1696
1872
|
}
|
|
1697
1873
|
return sseGenerator();
|
|
1698
1874
|
}
|
|
1699
|
-
const
|
|
1700
|
-
let collectedText = "";
|
|
1701
|
-
for await (const chunk of streamResult.stream) {
|
|
1702
|
-
const text = extractText(chunk);
|
|
1703
|
-
if (text) {
|
|
1704
|
-
collectedText += text;
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1875
|
+
const collectedText = await consumeFallbackStream();
|
|
1707
1876
|
if (!hasTranslatedOutput(collectedText, streamResult.toolCalls)) {
|
|
1708
1877
|
throw new Error(`Translated provider ${providerLabel} returned no content or tool calls`);
|
|
1709
1878
|
}
|
|
@@ -1736,6 +1905,25 @@ async function executeClaudeFallbackTranslation(args) {
|
|
|
1736
1905
|
});
|
|
1737
1906
|
return clientResponse;
|
|
1738
1907
|
}
|
|
1908
|
+
async function executeClaudeFallbackWithRetry(args) {
|
|
1909
|
+
let lastError;
|
|
1910
|
+
for (let retry = 0; retry <= MAX_FALLBACK_NETWORK_RETRIES; retry += 1) {
|
|
1911
|
+
try {
|
|
1912
|
+
return await executeClaudeFallbackTranslation(args);
|
|
1913
|
+
}
|
|
1914
|
+
catch (error) {
|
|
1915
|
+
lastError = error;
|
|
1916
|
+
if (!isRetryableNetworkError(error) ||
|
|
1917
|
+
retry === MAX_FALLBACK_NETWORK_RETRIES) {
|
|
1918
|
+
throw error;
|
|
1919
|
+
}
|
|
1920
|
+
const delayMs = TRANSIENT_SAME_ACCOUNT_RETRY_DELAYS_MS[retry] ?? 250;
|
|
1921
|
+
logger.always(`[proxy] retrying fallback=${args.providerLabel} after transient network error (${retry + 1}/${MAX_FALLBACK_NETWORK_RETRIES}) in ${delayMs}ms: ${describeTransportError(error)}`);
|
|
1922
|
+
await sleep(delayMs);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
throw lastError;
|
|
1926
|
+
}
|
|
1739
1927
|
async function tryConfiguredClaudeFallbackChain(args) {
|
|
1740
1928
|
const { ctx, body, parsedFallbackRequest, modelRouter, tracer, requestStartTime, logProxyBody, logFinalRequest, } = args;
|
|
1741
1929
|
const chain = modelRouter?.getFallbackChain() ?? [];
|
|
@@ -1753,6 +1941,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
|
|
|
1753
1941
|
attemptCount: fallbackPlan.attempts.slice(1).length,
|
|
1754
1942
|
reason: "all_anthropic_accounts_exhausted",
|
|
1755
1943
|
});
|
|
1944
|
+
let lastFallbackError;
|
|
1756
1945
|
for (const fallback of fallbackPlan.attempts.slice(1)) {
|
|
1757
1946
|
if (!fallback.provider || !fallback.model) {
|
|
1758
1947
|
continue;
|
|
@@ -1768,7 +1957,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
|
|
|
1768
1957
|
provider: fallback.provider,
|
|
1769
1958
|
model: fallback.model,
|
|
1770
1959
|
});
|
|
1771
|
-
const response = await
|
|
1960
|
+
const response = await executeClaudeFallbackWithRetry({
|
|
1772
1961
|
ctx,
|
|
1773
1962
|
body,
|
|
1774
1963
|
tracer,
|
|
@@ -1818,7 +2007,7 @@ async function tryConfiguredClaudeFallbackChain(args) {
|
|
|
1818
2007
|
else if (errMsg.includes("Resource exhausted")) {
|
|
1819
2008
|
errorClass = "provider_quota";
|
|
1820
2009
|
}
|
|
1821
|
-
logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} failed [${errorClass}]: ${
|
|
2010
|
+
logger.always(`[proxy] fallback ${fallback.provider}/${fallback.model} failed [${errorClass}]: ${describeTransportError(fallbackErr)}`);
|
|
1822
2011
|
recordFallbackAttempt({
|
|
1823
2012
|
provider: fallback.provider,
|
|
1824
2013
|
model: fallback.model,
|
|
@@ -1826,23 +2015,25 @@ async function tryConfiguredClaudeFallbackChain(args) {
|
|
|
1826
2015
|
errorMessage: `[${errorClass}] ${errMsg}`,
|
|
1827
2016
|
durationMs: Date.now() - fallbackStart,
|
|
1828
2017
|
});
|
|
2018
|
+
lastFallbackError = `[${fallback.provider}/${fallback.model}] ${describeTransportError(fallbackErr)}`;
|
|
1829
2019
|
}
|
|
1830
2020
|
}
|
|
1831
|
-
return { response: null };
|
|
2021
|
+
return { response: null, lastErrorMessage: lastFallbackError };
|
|
1832
2022
|
}
|
|
1833
2023
|
async function tryAutoClaudeFallback(args) {
|
|
1834
2024
|
const { ctx, body, tracer, requestStartTime, logProxyBody, logFinalRequest } = args;
|
|
2025
|
+
const fallbackStart = Date.now();
|
|
1835
2026
|
try {
|
|
1836
2027
|
const parsed = parseClaudeRequest(body);
|
|
1837
2028
|
const plan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, [], body.model, parsed);
|
|
1838
2029
|
logProxyRoutingPlan(logProxyBody, "auto_fallback", plan);
|
|
1839
2030
|
const autoAttempt = plan.attempts.find((attempt) => attempt.label === "auto-provider");
|
|
1840
2031
|
if (!autoAttempt) {
|
|
1841
|
-
return null;
|
|
2032
|
+
return { response: null };
|
|
1842
2033
|
}
|
|
1843
2034
|
logger.always("[proxy] fallback → auto-provider");
|
|
1844
2035
|
const options = buildProxyFallbackOptions(parsed);
|
|
1845
|
-
|
|
2036
|
+
const response = await executeClaudeFallbackWithRetry({
|
|
1846
2037
|
ctx,
|
|
1847
2038
|
body,
|
|
1848
2039
|
tracer,
|
|
@@ -1852,14 +2043,43 @@ async function tryAutoClaudeFallback(args) {
|
|
|
1852
2043
|
options: options,
|
|
1853
2044
|
providerLabel: "auto-provider",
|
|
1854
2045
|
});
|
|
2046
|
+
recordFallbackAttempt({
|
|
2047
|
+
provider: "auto-provider",
|
|
2048
|
+
model: body.model,
|
|
2049
|
+
status: "success",
|
|
2050
|
+
durationMs: Date.now() - fallbackStart,
|
|
2051
|
+
});
|
|
2052
|
+
tracer?.setFallbackInfo({
|
|
2053
|
+
triggered: true,
|
|
2054
|
+
provider: "auto-provider",
|
|
2055
|
+
model: body.model,
|
|
2056
|
+
attemptCount: 1,
|
|
2057
|
+
reason: "fallback_success",
|
|
2058
|
+
});
|
|
2059
|
+
return { response };
|
|
1855
2060
|
}
|
|
1856
2061
|
catch (fallbackErr) {
|
|
1857
|
-
|
|
1858
|
-
|
|
2062
|
+
const errorMessage = describeTransportError(fallbackErr);
|
|
2063
|
+
logger.always(`[proxy] fallback auto-provider failed: ${errorMessage}`);
|
|
2064
|
+
recordFallbackAttempt({
|
|
2065
|
+
provider: "auto-provider",
|
|
2066
|
+
model: body.model,
|
|
2067
|
+
status: "failure",
|
|
2068
|
+
errorMessage,
|
|
2069
|
+
durationMs: Date.now() - fallbackStart,
|
|
2070
|
+
});
|
|
2071
|
+
tracer?.setFallbackInfo({
|
|
2072
|
+
triggered: true,
|
|
2073
|
+
provider: "auto-provider",
|
|
2074
|
+
model: body.model,
|
|
2075
|
+
attemptCount: 1,
|
|
2076
|
+
reason: "fallback_failure",
|
|
2077
|
+
});
|
|
2078
|
+
return { response: null, lastErrorMessage: errorMessage };
|
|
1859
2079
|
}
|
|
1860
2080
|
}
|
|
1861
2081
|
function buildClaudeAnthropicFailureResponse(args) {
|
|
1862
|
-
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
2082
|
+
const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
1863
2083
|
if (authFailureMessage && !sawRateLimit) {
|
|
1864
2084
|
tracer?.setError("authentication_error", authFailureMessage);
|
|
1865
2085
|
tracer?.end(401, Date.now() - requestStartTime);
|
|
@@ -1895,20 +2115,26 @@ function buildClaudeAnthropicFailureResponse(args) {
|
|
|
1895
2115
|
}
|
|
1896
2116
|
}
|
|
1897
2117
|
if ((sawNetworkError || sawTransientFailure) && !sawRateLimit) {
|
|
2118
|
+
const fallbackSuffix = fallbackFailureMessage
|
|
2119
|
+
? ` Fallback also failed: ${fallbackFailureMessage}`
|
|
2120
|
+
: "";
|
|
1898
2121
|
const msg = `All Anthropic accounts failed due to transient upstream/network errors. Last error: ${lastError instanceof Error
|
|
1899
2122
|
? lastError.message
|
|
1900
|
-
: String(lastError ?? "unknown")}`;
|
|
2123
|
+
: String(lastError ?? "unknown")}.${fallbackSuffix}`;
|
|
1901
2124
|
tracer?.setError("transient_error", msg.slice(0, 500));
|
|
1902
2125
|
tracer?.end(502, Date.now() - requestStartTime);
|
|
1903
|
-
return buildLoggedClaudeError(502, msg);
|
|
2126
|
+
return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "transient_error");
|
|
1904
2127
|
}
|
|
1905
2128
|
if (!sawRateLimit) {
|
|
2129
|
+
const fallbackSuffix = fallbackFailureMessage
|
|
2130
|
+
? ` Fallback also failed: ${fallbackFailureMessage}`
|
|
2131
|
+
: "";
|
|
1906
2132
|
const msg = `All Anthropic accounts failed. Last error: ${lastError instanceof Error
|
|
1907
2133
|
? lastError.message
|
|
1908
|
-
: String(lastError ?? "unknown")}`;
|
|
2134
|
+
: String(lastError ?? "unknown")}.${fallbackSuffix}`;
|
|
1909
2135
|
tracer?.setError("all_accounts_failed", msg.slice(0, 500));
|
|
1910
2136
|
tracer?.end(502, Date.now() - requestStartTime);
|
|
1911
|
-
return buildLoggedClaudeError(502, msg);
|
|
2137
|
+
return buildLoggedClaudeError(502, msg, fallbackFailureMessage ? "fallback_exhausted" : "all_accounts_failed");
|
|
1912
2138
|
}
|
|
1913
2139
|
const now = Date.now();
|
|
1914
2140
|
const activeRateLimitCooldowns = orderedAccounts
|
|
@@ -2012,7 +2238,7 @@ async function handleAnthropicSuccessfulResponse(args) {
|
|
|
2012
2238
|
});
|
|
2013
2239
|
}
|
|
2014
2240
|
async function handleAnthropicStreamingSuccessResponse(args) {
|
|
2015
|
-
const {
|
|
2241
|
+
const { account, accountState: _accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
|
|
2016
2242
|
if (!response.body) {
|
|
2017
2243
|
upstreamSpan?.end();
|
|
2018
2244
|
tracer?.setError("stream_error", "No response body from upstream");
|
|
@@ -2093,17 +2319,9 @@ async function handleAnthropicStreamingSuccessResponse(args) {
|
|
|
2093
2319
|
controller.enqueue(value);
|
|
2094
2320
|
}
|
|
2095
2321
|
catch (streamErr) {
|
|
2096
|
-
const errMsg =
|
|
2322
|
+
const errMsg = describeTransportError(streamErr);
|
|
2097
2323
|
logger.always(`[proxy] mid-stream error account=${account.label}: ${errMsg}`);
|
|
2098
2324
|
streamOutcomeTracker.fail(errMsg);
|
|
2099
|
-
logStreamError({
|
|
2100
|
-
timestamp: new Date().toISOString(),
|
|
2101
|
-
requestId: ctx.requestId,
|
|
2102
|
-
account: account.label,
|
|
2103
|
-
model: body.model,
|
|
2104
|
-
errorMessage: errMsg,
|
|
2105
|
-
durationMs: Date.now() - fetchStartMs,
|
|
2106
|
-
});
|
|
2107
2325
|
if (!mainStreamClosed) {
|
|
2108
2326
|
mainStreamClosed = true;
|
|
2109
2327
|
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Upstream stream interrupted: ${errMsg}` } })}\n\n`;
|
|
@@ -2469,7 +2687,7 @@ async function handleAnthropicJsonSuccessResponse(args) {
|
|
|
2469
2687
|
return { response: responseJson };
|
|
2470
2688
|
}
|
|
2471
2689
|
async function handleAnthropicSuccessfulRetryResponse(args) {
|
|
2472
|
-
const {
|
|
2690
|
+
const { body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
|
|
2473
2691
|
const retryQuota = parseQuotaHeaders(retryResp.headers);
|
|
2474
2692
|
if (retryQuota) {
|
|
2475
2693
|
// Keep the auth-retry success path in parity with the main success path:
|
|
@@ -2514,17 +2732,9 @@ async function handleAnthropicSuccessfulRetryResponse(args) {
|
|
|
2514
2732
|
controller.enqueue(value);
|
|
2515
2733
|
}
|
|
2516
2734
|
catch (streamErr) {
|
|
2517
|
-
const errMsg =
|
|
2735
|
+
const errMsg = describeTransportError(streamErr);
|
|
2518
2736
|
logger.always(`[proxy] mid-stream error (auth-retry) account=${account.label}: ${errMsg}`);
|
|
2519
2737
|
streamOutcomeTracker.fail(errMsg);
|
|
2520
|
-
logStreamError({
|
|
2521
|
-
timestamp: new Date().toISOString(),
|
|
2522
|
-
requestId: ctx.requestId,
|
|
2523
|
-
account: account.label,
|
|
2524
|
-
model: body.model,
|
|
2525
|
-
errorMessage: errMsg,
|
|
2526
|
-
durationMs: Date.now() - fetchStartMs,
|
|
2527
|
-
});
|
|
2528
2738
|
if (!retryStreamClosed) {
|
|
2529
2739
|
retryStreamClosed = true;
|
|
2530
2740
|
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Upstream stream interrupted: ${errMsg}` } })}\n\n`;
|
|
@@ -2716,15 +2926,36 @@ async function handleAnthropicAuthRetry(args) {
|
|
|
2716
2926
|
authRetryError = `retry ${authRetry + 1}/${MAX_AUTH_RETRIES} failed with status ${retryStatus}`;
|
|
2717
2927
|
currentLastError = retryBody;
|
|
2718
2928
|
logger.debug(`[proxy] retry ${authRetry + 1} failed: ${retryStatus} ${retryBody.substring(0, 120)}`);
|
|
2719
|
-
recordAttemptError(account.label, account.type, retryStatus);
|
|
2720
|
-
// A real rate-limit 429 rotates accounts. But an anti-abuse / construction
|
|
2721
|
-
// 429 (no rate-limit headers, body "Error") is NOT a real rate limit:
|
|
2722
|
-
// advancing the primary or rotating cannot help and only burns quota. Skip
|
|
2723
|
-
// the rotate path for it so it falls through to the terminal error return
|
|
2724
|
-
// below, surfacing the truthful upstream error — mirroring the initial
|
|
2725
|
-
// fetch path's fast-fail (isAntiAbuseConstruction429).
|
|
2726
2929
|
if (retryStatus === 429 &&
|
|
2727
|
-
|
|
2930
|
+
isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
|
|
2931
|
+
logger.always(`[proxy] ← 429 account=${account.label} anti-abuse/construction rejection after OAuth refresh — returning non-retryable request error`);
|
|
2932
|
+
logAttempt(429, "construction_rejection", retryBody);
|
|
2933
|
+
tracer?.setError("construction_rejection", retryBody.slice(0, 500));
|
|
2934
|
+
currentUpstreamSpan?.end();
|
|
2935
|
+
return {
|
|
2936
|
+
response: finalizeAnthropicTerminalFetchError({
|
|
2937
|
+
terminalError: buildAnthropicConstructionRejectionTerminalError(),
|
|
2938
|
+
account,
|
|
2939
|
+
tracer,
|
|
2940
|
+
requestStartTime,
|
|
2941
|
+
attemptNumber,
|
|
2942
|
+
logProxyBody,
|
|
2943
|
+
logFinalRequest,
|
|
2944
|
+
}),
|
|
2945
|
+
continueLoop: false,
|
|
2946
|
+
lastError: retryBody,
|
|
2947
|
+
authFailureMessage: currentAuthFailureMessage,
|
|
2948
|
+
sawRateLimit: currentSawRateLimit,
|
|
2949
|
+
sawTransientFailure: currentSawTransientFailure,
|
|
2950
|
+
sawNetworkError: currentSawNetworkError,
|
|
2951
|
+
upstreamSpan: undefined,
|
|
2952
|
+
};
|
|
2953
|
+
}
|
|
2954
|
+
recordAttemptError(account.label, account.type, retryStatus);
|
|
2955
|
+
// Construction rejections return through the terminal 400 path above.
|
|
2956
|
+
// Every 429 reaching this branch is a genuine rate limit and must cool
|
|
2957
|
+
// the account according to its reset window before rotating.
|
|
2958
|
+
if (retryStatus === 429) {
|
|
2728
2959
|
currentSawRateLimit = true;
|
|
2729
2960
|
// Cool the account per its real reset window before rotating, so a
|
|
2730
2961
|
// session/weekly-exhausted account isn't re-selected next request.
|
|
@@ -2862,6 +3093,22 @@ function buildAnthropicTerminalErrorResponse(args) {
|
|
|
2862
3093
|
return clientError;
|
|
2863
3094
|
}
|
|
2864
3095
|
}
|
|
3096
|
+
function finalizeAnthropicTerminalFetchError(args) {
|
|
3097
|
+
const { terminalError, account, tracer, requestStartTime, attemptNumber, logProxyBody, logFinalRequest, } = args;
|
|
3098
|
+
recordFinalError(terminalError.status, account.label, account.type);
|
|
3099
|
+
tracer?.end(terminalError.status, Date.now() - requestStartTime);
|
|
3100
|
+
return buildAnthropicTerminalErrorResponse({
|
|
3101
|
+
responseStatus: terminalError.status,
|
|
3102
|
+
account,
|
|
3103
|
+
errBody: terminalError.body,
|
|
3104
|
+
errRespHeaders: terminalError.headers,
|
|
3105
|
+
requestStartTime,
|
|
3106
|
+
attemptNumber,
|
|
3107
|
+
logProxyBody,
|
|
3108
|
+
logFinalRequest,
|
|
3109
|
+
errorType: terminalError.errorType,
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
2865
3112
|
async function handleAnthropicNonOkResponse(args) {
|
|
2866
3113
|
const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
|
|
2867
3114
|
let currentLastError = lastError;
|
|
@@ -3271,7 +3518,7 @@ async function prepareAnthropicAccountAttempt(args) {
|
|
|
3271
3518
|
}
|
|
3272
3519
|
}
|
|
3273
3520
|
const buildUpstreamBody = (token) => isOAuth
|
|
3274
|
-
? polyfillOAuthBody(bodyStr, token, snapshot, headers["x-claude-code-session-id"])
|
|
3521
|
+
? polyfillOAuthBody(bodyStr, token, snapshot, isClaudeClientRequest, headers["x-claude-code-session-id"])
|
|
3275
3522
|
: { bodyStr };
|
|
3276
3523
|
const polyfilledBody = buildUpstreamBody(account.token);
|
|
3277
3524
|
if (isOAuth &&
|
|
@@ -3325,7 +3572,7 @@ async function prepareAnthropicAccountAttempt(args) {
|
|
|
3325
3572
|
* "Error" and which carries NONE of the real rate-limit headers (no retry-after,
|
|
3326
3573
|
* no anthropic-ratelimit-*). This is NOT a capacity limit — retrying or rotating
|
|
3327
3574
|
* accounts cannot fix it and only burns quota, so the caller must fail fast and
|
|
3328
|
-
*
|
|
3575
|
+
* return a non-retryable request error instead of "all accounts rate-limited".
|
|
3329
3576
|
*/
|
|
3330
3577
|
function isAntiAbuseConstruction429(headers, body) {
|
|
3331
3578
|
const hasRetryAfter = !!headers["retry-after"];
|
|
@@ -3335,6 +3582,14 @@ function isAntiAbuseConstruction429(headers, body) {
|
|
|
3335
3582
|
}
|
|
3336
3583
|
return (body.includes("rate_limit_error") && /"message"\s*:\s*"Error"/.test(body));
|
|
3337
3584
|
}
|
|
3585
|
+
function buildAnthropicConstructionRejectionTerminalError() {
|
|
3586
|
+
return {
|
|
3587
|
+
status: 400,
|
|
3588
|
+
body: JSON.stringify(buildClaudeError(400, "Anthropic rejected the OAuth request shape. This is not an account rate limit.", "invalid_request_error")),
|
|
3589
|
+
headers: { "content-type": "application/json" },
|
|
3590
|
+
errorType: "construction_rejection",
|
|
3591
|
+
};
|
|
3592
|
+
}
|
|
3338
3593
|
async function fetchAnthropicAccountResponse(args) {
|
|
3339
3594
|
const { url, headers, finalBodyStr, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
|
|
3340
3595
|
let lastError = currentLastError;
|
|
@@ -3374,9 +3629,7 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
3374
3629
|
};
|
|
3375
3630
|
}
|
|
3376
3631
|
if (response.status === 429) {
|
|
3377
|
-
sawRateLimit = true;
|
|
3378
3632
|
const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
|
|
3379
|
-
recordAttemptError(account.label, account.type, 429);
|
|
3380
3633
|
// Capture full response headers and body for diagnostics (parity with
|
|
3381
3634
|
// handleAnthropicNonOkResponse which does this for all other error statuses).
|
|
3382
3635
|
const errRespHeaders = {};
|
|
@@ -3407,28 +3660,24 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
3407
3660
|
});
|
|
3408
3661
|
// Anti-abuse / request-construction 429 (no rate-limit headers, body
|
|
3409
3662
|
// "Error"): rotating accounts cannot help and only burns quota. Fail fast
|
|
3410
|
-
// and
|
|
3411
|
-
//
|
|
3663
|
+
// and return a non-retryable request error instead of prompting the client
|
|
3664
|
+
// to repeat the same malformed request as a genuine rate limit.
|
|
3412
3665
|
if (isAntiAbuseConstruction429(errRespHeaders, String(lastError))) {
|
|
3413
|
-
logger.always(`[proxy] ← 429 account=${account.label} anti-abuse/construction rejection (no ratelimit headers, body="Error") — NOT a real rate limit; returning
|
|
3666
|
+
logger.always(`[proxy] ← 429 account=${account.label} anti-abuse/construction rejection (no ratelimit headers, body="Error") — NOT a real rate limit; returning non-retryable request error`);
|
|
3414
3667
|
logAttempt(429, "construction_rejection", String(lastError));
|
|
3415
3668
|
tracer?.setError("construction_rejection", String(lastError).slice(0, 500));
|
|
3416
3669
|
currentUpstreamSpan?.end();
|
|
3417
|
-
const passthrough = new Response(String(lastError), {
|
|
3418
|
-
status: 429,
|
|
3419
|
-
headers: {
|
|
3420
|
-
"content-type": errRespHeaders["content-type"] ?? "application/json",
|
|
3421
|
-
},
|
|
3422
|
-
});
|
|
3423
3670
|
return {
|
|
3424
3671
|
continueLoop: false,
|
|
3425
|
-
|
|
3672
|
+
terminalError: buildAnthropicConstructionRejectionTerminalError(),
|
|
3426
3673
|
lastError,
|
|
3427
3674
|
sawRateLimit,
|
|
3428
3675
|
sawNetworkError,
|
|
3429
3676
|
upstreamSpan: undefined,
|
|
3430
3677
|
};
|
|
3431
3678
|
}
|
|
3679
|
+
sawRateLimit = true;
|
|
3680
|
+
recordAttemptError(account.label, account.type, 429);
|
|
3432
3681
|
// Parse the unified-window quota headers (present on real rate-limit 429s)
|
|
3433
3682
|
// and derive a reset-aware cooldown plan. This is the fix for "kept
|
|
3434
3683
|
// hammering the 5h/7d-exhausted account instead of switching": on an
|
|
@@ -3468,6 +3717,9 @@ async function fetchAnthropicAccountResponse(args) {
|
|
|
3468
3717
|
upstreamSpan: currentUpstreamSpan,
|
|
3469
3718
|
};
|
|
3470
3719
|
}
|
|
3720
|
+
function shouldAttemptClaudeFallback(loopState) {
|
|
3721
|
+
return loopState.invalidRequestFailure === null;
|
|
3722
|
+
}
|
|
3471
3723
|
async function handleAnthropicRoutedClaudeRequest(args) {
|
|
3472
3724
|
const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
|
|
3473
3725
|
const parsedRequest = parseClaudeRequest(body);
|
|
@@ -3507,7 +3759,16 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
3507
3759
|
// is cooling, report the earliest persisted retry time without an upstream
|
|
3508
3760
|
// call; restarting the proxy must not erase or bypass this quarantine.
|
|
3509
3761
|
const nonCoolingAccounts = orderedAccounts.filter((a) => !isAccountCooling(a.key));
|
|
3510
|
-
|
|
3762
|
+
let effectiveAccounts = nonCoolingAccounts;
|
|
3763
|
+
// If every usable account is cooling and the soonest recovery is a short
|
|
3764
|
+
// transient burst cooldown, hold this request and pace its admission instead
|
|
3765
|
+
// of returning a proxy-local 429 that makes the client retry in a herd. The
|
|
3766
|
+
// helper rechecks because an in-flight retry can extend the cooldown while
|
|
3767
|
+
// this request is waiting.
|
|
3768
|
+
if (effectiveAccounts.length === 0) {
|
|
3769
|
+
effectiveAccounts =
|
|
3770
|
+
await waitForTransientAccountAvailability(orderedAccounts);
|
|
3771
|
+
}
|
|
3511
3772
|
if (effectiveAccounts.length === 0 && orderedAccounts.length > 0) {
|
|
3512
3773
|
const coolingStates = orderedAccounts.map((account) => getOrCreateRuntimeState(account.key));
|
|
3513
3774
|
const hasRateLimitCooldown = coolingStates.some((state) => state.coolingReason !== "auth");
|
|
@@ -3590,6 +3851,17 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
3590
3851
|
loopState.lastError = fetchResult.lastError;
|
|
3591
3852
|
loopState.sawRateLimit = fetchResult.sawRateLimit;
|
|
3592
3853
|
loopState.sawNetworkError = fetchResult.sawNetworkError;
|
|
3854
|
+
if (fetchResult.terminalError) {
|
|
3855
|
+
return finalizeAnthropicTerminalFetchError({
|
|
3856
|
+
terminalError: fetchResult.terminalError,
|
|
3857
|
+
account,
|
|
3858
|
+
tracer,
|
|
3859
|
+
requestStartTime,
|
|
3860
|
+
attemptNumber: loopState.attemptNumber,
|
|
3861
|
+
logProxyBody,
|
|
3862
|
+
logFinalRequest,
|
|
3863
|
+
});
|
|
3864
|
+
}
|
|
3593
3865
|
if (fetchResult.continueLoop || !fetchResult.response) {
|
|
3594
3866
|
// Genuine 429 (carries a cooldown plan derived from quota headers).
|
|
3595
3867
|
if (fetchResult.cooldownPlan) {
|
|
@@ -3601,32 +3873,41 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
3601
3873
|
// Non-fatal: routing already has the in-memory snapshot.
|
|
3602
3874
|
});
|
|
3603
3875
|
}
|
|
3604
|
-
//
|
|
3605
|
-
//
|
|
3606
|
-
|
|
3876
|
+
// Publish the cooldown before retrying so requests arriving behind
|
|
3877
|
+
// this one skip the throttled account instead of joining the burst.
|
|
3878
|
+
let cooldownExtended = false;
|
|
3879
|
+
if (!accountState.coolingUntil ||
|
|
3880
|
+
plan.coolingUntil > accountState.coolingUntil) {
|
|
3881
|
+
accountState.coolingUntil = plan.coolingUntil;
|
|
3882
|
+
accountState.coolingReason = plan.reason;
|
|
3883
|
+
cooldownExtended = true;
|
|
3884
|
+
}
|
|
3885
|
+
if (cooldownExtended) {
|
|
3886
|
+
await saveAccountCooldown(account.key, accountState.coolingUntil, accountState.coolingReason ?? plan.reason).catch(() => {
|
|
3887
|
+
// Non-fatal: routing already has the in-memory cooldown.
|
|
3888
|
+
});
|
|
3889
|
+
}
|
|
3890
|
+
// Transient retries are budgeted across all concurrent requests for
|
|
3891
|
+
// this account/window. Exhaustion plans rotate immediately and never
|
|
3892
|
+
// claim this budget.
|
|
3893
|
+
const sharedRetrySlot = fetchResult.retrySameAccount
|
|
3894
|
+
? claimTransientRateLimitRetry(account.key, plan.coolingUntil)
|
|
3895
|
+
: undefined;
|
|
3607
3896
|
if (fetchResult.retrySameAccount &&
|
|
3608
3897
|
fetchResult.retryAfterMs !== undefined &&
|
|
3609
|
-
rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES
|
|
3898
|
+
rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES &&
|
|
3899
|
+
sharedRetrySlot !== undefined) {
|
|
3610
3900
|
rateLimitSameAccountRetries += 1;
|
|
3611
3901
|
const base = Math.min(fetchResult.retryAfterMs || 1_000, MAX_RATE_LIMIT_RETRY_DELAY_MS);
|
|
3612
|
-
//
|
|
3613
|
-
|
|
3614
|
-
|
|
3902
|
+
// Stagger the two shared slots, then cap after jitter so the final
|
|
3903
|
+
// sleep never exceeds the configured maximum.
|
|
3904
|
+
const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base * sharedRetrySlot));
|
|
3905
|
+
logger.always(`[proxy] retrying same account=${account.label} after transient 429 (shared slot ${sharedRetrySlot}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
|
|
3615
3906
|
await sleep(delayMs);
|
|
3616
3907
|
continue;
|
|
3617
3908
|
}
|
|
3618
|
-
// Exhaustion, or transient
|
|
3619
|
-
//
|
|
3620
|
-
// so a concurrent short transient plan can't shorten a longer weekly
|
|
3621
|
-
// cooldown already set by another in-flight request.
|
|
3622
|
-
if (!accountState.coolingUntil ||
|
|
3623
|
-
plan.coolingUntil > accountState.coolingUntil) {
|
|
3624
|
-
accountState.coolingUntil = plan.coolingUntil;
|
|
3625
|
-
accountState.coolingReason = plan.reason;
|
|
3626
|
-
}
|
|
3627
|
-
await saveAccountCooldown(account.key, accountState.coolingUntil ?? plan.coolingUntil, accountState.coolingReason ?? plan.reason).catch(() => {
|
|
3628
|
-
// Non-fatal: routing already has the in-memory cooldown.
|
|
3629
|
-
});
|
|
3909
|
+
// Exhaustion, or the shared transient retry budget being used up:
|
|
3910
|
+
// rotate while the already-published cooldown remains active.
|
|
3630
3911
|
advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
|
|
3631
3912
|
logger.always(`[proxy] account=${account.label} rate-limited (${plan.reason}); cooling ~${minutesUntil(plan.coolingUntil, Date.now())}m until ${new Date(plan.coolingUntil).toISOString()}, rotating`);
|
|
3632
3913
|
continue accountLoop;
|
|
@@ -3768,33 +4049,43 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
3768
4049
|
if (loopState.attemptNumber === 0) {
|
|
3769
4050
|
acctSelectionSpan?.end();
|
|
3770
4051
|
}
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
requestStartTime,
|
|
3778
|
-
logProxyBody,
|
|
3779
|
-
logFinalRequest,
|
|
3780
|
-
});
|
|
3781
|
-
if (configuredFallbackResult.response) {
|
|
3782
|
-
return configuredFallbackResult.response;
|
|
3783
|
-
}
|
|
3784
|
-
// Try auto-provider fallback when the configured chain didn't produce a
|
|
3785
|
-
// response (either no chain configured, or all entries failed/deduped).
|
|
3786
|
-
if (!loopState.sawRateLimit) {
|
|
3787
|
-
const autoFallbackResponse = await tryAutoClaudeFallback({
|
|
4052
|
+
// Deterministic invalid requests (for example, prompt-too-long) cannot be
|
|
4053
|
+
// repaired by changing providers. Preserve the authoritative Anthropic 400
|
|
4054
|
+
// rather than delaying it or returning unrelated fallback output.
|
|
4055
|
+
if (shouldAttemptClaudeFallback(loopState)) {
|
|
4056
|
+
let fallbackFailureMessage;
|
|
4057
|
+
const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
|
|
3788
4058
|
ctx,
|
|
3789
4059
|
body,
|
|
4060
|
+
parsedFallbackRequest: parsedRequest,
|
|
4061
|
+
modelRouter,
|
|
3790
4062
|
tracer,
|
|
3791
4063
|
requestStartTime,
|
|
3792
4064
|
logProxyBody,
|
|
3793
4065
|
logFinalRequest,
|
|
3794
4066
|
});
|
|
3795
|
-
if (
|
|
3796
|
-
return
|
|
4067
|
+
if (configuredFallbackResult.response) {
|
|
4068
|
+
return configuredFallbackResult.response;
|
|
3797
4069
|
}
|
|
4070
|
+
fallbackFailureMessage = configuredFallbackResult.lastErrorMessage;
|
|
4071
|
+
// Try auto-provider fallback when the configured chain didn't produce a
|
|
4072
|
+
// response (either no chain configured, or all entries failed/deduped).
|
|
4073
|
+
if (!loopState.sawRateLimit) {
|
|
4074
|
+
const autoFallbackResult = await tryAutoClaudeFallback({
|
|
4075
|
+
ctx,
|
|
4076
|
+
body,
|
|
4077
|
+
tracer,
|
|
4078
|
+
requestStartTime,
|
|
4079
|
+
logProxyBody,
|
|
4080
|
+
logFinalRequest,
|
|
4081
|
+
});
|
|
4082
|
+
if (autoFallbackResult.response) {
|
|
4083
|
+
return autoFallbackResult.response;
|
|
4084
|
+
}
|
|
4085
|
+
fallbackFailureMessage =
|
|
4086
|
+
autoFallbackResult.lastErrorMessage ?? fallbackFailureMessage;
|
|
4087
|
+
}
|
|
4088
|
+
loopState.fallbackFailureMessage = fallbackFailureMessage;
|
|
3798
4089
|
}
|
|
3799
4090
|
return buildClaudeAnthropicFailureResponse({
|
|
3800
4091
|
tracer,
|
|
@@ -3806,6 +4097,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
|
|
|
3806
4097
|
sawTransientFailure: loopState.sawTransientFailure,
|
|
3807
4098
|
sawRateLimit: loopState.sawRateLimit,
|
|
3808
4099
|
lastError: loopState.lastError,
|
|
4100
|
+
fallbackFailureMessage: loopState.fallbackFailureMessage,
|
|
3809
4101
|
orderedAccounts,
|
|
3810
4102
|
buildLoggedClaudeError,
|
|
3811
4103
|
logProxyBody,
|
|
@@ -4077,6 +4369,26 @@ function getErrorCode(error) {
|
|
|
4077
4369
|
const causeCode = cause.code;
|
|
4078
4370
|
return typeof causeCode === "string" ? causeCode : undefined;
|
|
4079
4371
|
}
|
|
4372
|
+
function describeTransportError(error) {
|
|
4373
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
4374
|
+
if (!error || typeof error !== "object") {
|
|
4375
|
+
return message;
|
|
4376
|
+
}
|
|
4377
|
+
const cause = error.cause;
|
|
4378
|
+
const causeMessage = cause instanceof Error
|
|
4379
|
+
? cause.message
|
|
4380
|
+
: cause && typeof cause === "object"
|
|
4381
|
+
? String(cause.message ?? "")
|
|
4382
|
+
: "";
|
|
4383
|
+
const code = getErrorCode(error);
|
|
4384
|
+
const detail = [
|
|
4385
|
+
code,
|
|
4386
|
+
causeMessage && causeMessage !== message && causeMessage,
|
|
4387
|
+
]
|
|
4388
|
+
.filter(Boolean)
|
|
4389
|
+
.join(": ");
|
|
4390
|
+
return detail ? `${message} (${detail})` : message;
|
|
4391
|
+
}
|
|
4080
4392
|
/**
|
|
4081
4393
|
* Determine whether a thrown fetch error is a transient connectivity issue.
|
|
4082
4394
|
*/
|
|
@@ -4111,6 +4423,7 @@ function isRetryableNetworkError(error) {
|
|
|
4111
4423
|
return (normalized.includes("econnrefused") ||
|
|
4112
4424
|
normalized.includes("econnreset") ||
|
|
4113
4425
|
normalized.includes("etimedout") ||
|
|
4426
|
+
normalized.includes("timed out") ||
|
|
4114
4427
|
normalized.includes("connection error") ||
|
|
4115
4428
|
normalized.includes("connect error") ||
|
|
4116
4429
|
normalized.includes("fetch failed") ||
|
|
@@ -4236,10 +4549,25 @@ export const __testHooks = {
|
|
|
4236
4549
|
},
|
|
4237
4550
|
resetAllRuntimeState: () => {
|
|
4238
4551
|
accountRuntimeState.clear();
|
|
4552
|
+
transientRateLimitRetryBudgets.clear();
|
|
4553
|
+
transientCooldownAdmissionSchedules.clear();
|
|
4239
4554
|
primaryAccountIndex = 0;
|
|
4240
4555
|
lastKnownAccountCount = 0;
|
|
4241
4556
|
configuredPrimaryAccountKey = undefined;
|
|
4242
4557
|
configuredAccountAllowlist = undefined;
|
|
4243
4558
|
},
|
|
4559
|
+
polyfillOAuthBody: (bodyStr, isClaudeClientRequest) => polyfillOAuthBody(bodyStr, "test-account-token", null, isClaudeClientRequest),
|
|
4560
|
+
isAntiAbuseConstruction429,
|
|
4561
|
+
fetchAnthropicAccountResponse,
|
|
4562
|
+
finalizeAnthropicTerminalFetchError,
|
|
4563
|
+
handleAnthropicAuthRetry,
|
|
4564
|
+
handleAnthropicStreamingSuccessResponse,
|
|
4565
|
+
claimTransientRateLimitRetry,
|
|
4566
|
+
claimTransientCooldownAdmission,
|
|
4567
|
+
waitForTransientAccountAvailability,
|
|
4568
|
+
describeTransportError,
|
|
4569
|
+
shouldAttemptClaudeFallback,
|
|
4570
|
+
executeClaudeFallbackWithRetry,
|
|
4571
|
+
buildClaudeAnthropicFailureResponse,
|
|
4244
4572
|
};
|
|
4245
4573
|
//# sourceMappingURL=claudeProxyRoutes.js.map
|