@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.
@@ -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, logStreamError, } from "../../proxy/requestLogger.js";
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
- /** Derive the ordering signals for an account from its latest runtime quota. */
319
- function accountSortMetrics(accountKey, now) {
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 weeklyRejected = q?.weeklyStatus === "rejected" && weeklyReset !== undefined;
326
- const sessionRejected = q?.sessionStatus === "rejected" && sessionReset !== undefined;
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 && !weeklyRejected && !sessionRejected,
452
+ usable: !coolingActive &&
453
+ weeklyStatus !== "rejected" &&
454
+ sessionStatus !== "rejected",
455
+ saturated,
329
456
  hasQuota: !!q,
330
457
  coolingUntil: st?.coolingUntil ?? 0,
331
- weeklyReset: weeklyReset ?? Number.POSITIVE_INFINITY,
458
+ sessionResetBucket: sessionTicking
459
+ ? Math.floor(sessionReset / sessionResetToleranceMs)
460
+ : Number.POSITIVE_INFINITY,
332
461
  sessionReset: sessionReset ?? Number.POSITIVE_INFINITY,
333
- weeklyUsed: q?.weeklyUsed ?? -1,
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 account whose window refreshes SOONEST first, so its about-to-reset
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. soonest WEEKLY (7d) reset — the scarce, use-it-or-lose-it ceiling
346
- * 3. soonest SESSION (5h) reset
347
- * 4. highest weekly utilization finish off the one closest to done
348
- * 5. configured primary account, then insertion order
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 = accountSortMetrics(a.key, now);
356
- const mb = accountSortMetrics(b.key, now);
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 (required by Anthropic for OAuth)
652
- // NOTE: This block MUST be deterministic (no random values) to preserve
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 we keep those in place and
665
- // just stabilise the volatile billing `cch`. A custom client (Curator) sends
666
- // its own arbitrary system prompt with NO agent block; left in `system` it is
667
- // rejected as `rate_limit_error: "Error"`, so we relocate it into the message
668
- // stream and send only the recognised billing + agent blocks as `system`.
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
- // A genuine Claude Code client supplies its own agent-identity block;
683
- // a custom client (Curator) does not.
684
- const isClaudeCodeClient = agentIdx >= 0;
685
- // Strip billing/agent from their positions (reverse order so indices
686
- // stay valid). What remains is the client's "extra" system content.
687
- const indicesToRemove = [billingIdx, agentIdx]
688
- .filter((i) => i >= 0)
689
- .sort((a, b) => b - a);
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
- // Genuine Claude Code (or no extra blocks): keep the recognised system
702
- // and append the deterministic billing + agent blocks at the end.
703
- parsed.system = [...parsed.system, billingBlock, agentBlock];
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
- for await (const chunk of streamResult.stream) {
1647
- const text = extractText(chunk);
1648
- if (text) {
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 streamResult = await ctx.neurolink.stream(options);
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 executeClaudeFallbackTranslation({
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}]: ${errMsg}`);
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
- return await executeClaudeFallbackTranslation({
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
- logger.debug(`[proxy] fallback auto-provider failed: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`);
1858
- return null;
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 { ctx, body, account, accountState: _accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
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 = streamErr instanceof Error ? streamErr.message : String(streamErr);
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 { ctx, body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
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 = streamErr instanceof Error ? streamErr.message : String(streamErr);
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
- !isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
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
- * surface the truthful upstream error instead of "all accounts rate-limited".
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 surface the truthful upstream error instead of the misleading
3411
- // "all accounts rate-limited" after 44 wasted attempts.
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 upstream error without rotating`);
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
- response: passthrough,
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
- const effectiveAccounts = nonCoolingAccounts;
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
- // Transient burst (window still allowed): a couple of jittered
3605
- // same-account retries before giving up. Exhaustion plans set
3606
- // rotateImmediately, so retrySameAccount is false and we skip this.
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
- // Cap AFTER jitter so the final sleep never exceeds the cap.
3613
- const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base));
3614
- logger.always(`[proxy] retrying same account=${account.label} after transient 429 (${rateLimitSameAccountRetries}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
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 retries used up: park the account until
3619
- // its ACTUAL reset (5h/7d) no 60s hardcap — and rotate. Extend-only
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
- const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
3772
- ctx,
3773
- body,
3774
- parsedFallbackRequest: parsedRequest,
3775
- modelRouter,
3776
- tracer,
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 (autoFallbackResponse) {
3796
- return autoFallbackResponse;
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,9 +4549,24 @@ 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
  };