@juspay/neurolink 9.87.1 → 9.87.3

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";
@@ -91,12 +91,23 @@ const AUTH_REFRESH_MAX_COOLDOWN_MS = 5 * 60 * 1000;
91
91
  * thinking from Opus models (which can exceed 5 minutes for large contexts). */
92
92
  const UPSTREAM_FETCH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
93
93
  const accountRuntimeState = new Map();
94
+ /** Shared across requests so a concurrent burst gets at most two retries for
95
+ * the account/window, rather than every request starting its own retry chain. */
96
+ const transientRateLimitRetryBudgets = new Map();
97
+ /** Pace requests that arrive while the last usable account has a short
98
+ * transient cooldown. This converts a local 429/retry storm into a bounded
99
+ * queue and avoids releasing every waiting request at the same millisecond. */
100
+ const transientCooldownAdmissionSchedules = new Map();
94
101
  /** Track whether we've run the one-time startup prune. */
95
102
  let startupPruneDone = false;
96
103
  /** Default cooling period when retries are exhausted and upstream didn't
97
104
  * provide a retry-after header. Short enough to recover quickly, long
98
105
  * enough to avoid immediately hammering the same account. */
99
106
  const DEFAULT_COOLING_PERIOD_MS = 60_000;
107
+ const MAX_TRANSIENT_QUEUE_WAIT_MS = 90_000;
108
+ const MAX_TRANSIENT_TOTAL_QUEUE_WAIT_MS = 120_000;
109
+ const TRANSIENT_ADMISSION_SPACING_MS = 250;
110
+ const MAX_TRANSIENT_ADMISSION_SPREAD_MS = 15_000;
100
111
  /** Advance the primary account index when the current primary is exhausted
101
112
  * (429 retries exhausted or auth failure). This is what makes fill-first work:
102
113
  * we stick to one account until it's unusable. Only advances when the exhausted
@@ -159,6 +170,74 @@ function isAccountCooling(accountKey) {
159
170
  const state = accountRuntimeState.get(accountKey);
160
171
  return !!state?.coolingUntil && Date.now() < state.coolingUntil;
161
172
  }
173
+ function claimTransientRateLimitRetry(accountKey, coolingUntil, now = Date.now()) {
174
+ let budget = transientRateLimitRetryBudgets.get(accountKey);
175
+ if (!budget || now >= budget.coolingUntil) {
176
+ budget = { coolingUntil, retriesClaimed: 0 };
177
+ transientRateLimitRetryBudgets.set(accountKey, budget);
178
+ }
179
+ else if (coolingUntil > budget.coolingUntil) {
180
+ budget.coolingUntil = coolingUntil;
181
+ }
182
+ if (budget.retriesClaimed >= MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES) {
183
+ return undefined;
184
+ }
185
+ budget.retriesClaimed += 1;
186
+ return budget.retriesClaimed;
187
+ }
188
+ function claimTransientCooldownAdmission(accountKey, coolingUntil, now = Date.now()) {
189
+ if (coolingUntil <= now) {
190
+ return 0;
191
+ }
192
+ if (coolingUntil - now > MAX_TRANSIENT_QUEUE_WAIT_MS) {
193
+ return undefined;
194
+ }
195
+ let schedule = transientCooldownAdmissionSchedules.get(accountKey);
196
+ if (!schedule || now >= schedule.coolingUntil) {
197
+ schedule = { coolingUntil, nextAdmissionAt: coolingUntil };
198
+ transientCooldownAdmissionSchedules.set(accountKey, schedule);
199
+ }
200
+ else if (coolingUntil > schedule.coolingUntil) {
201
+ schedule.coolingUntil = coolingUntil;
202
+ schedule.nextAdmissionAt = Math.max(schedule.nextAdmissionAt, coolingUntil);
203
+ }
204
+ const admissionAt = Math.max(now, schedule.coolingUntil, schedule.nextAdmissionAt);
205
+ if (admissionAt - schedule.coolingUntil > MAX_TRANSIENT_ADMISSION_SPREAD_MS) {
206
+ return undefined;
207
+ }
208
+ schedule.nextAdmissionAt = admissionAt + TRANSIENT_ADMISSION_SPACING_MS;
209
+ return admissionAt - now;
210
+ }
211
+ async function waitForTransientAccountAvailability(orderedAccounts) {
212
+ const deadline = Date.now() + MAX_TRANSIENT_TOTAL_QUEUE_WAIT_MS;
213
+ while (Date.now() < deadline) {
214
+ const available = orderedAccounts.filter((account) => !isAccountCooling(account.key));
215
+ if (available.length > 0) {
216
+ return available;
217
+ }
218
+ const now = Date.now();
219
+ const transientCandidate = orderedAccounts
220
+ .map((account) => ({
221
+ account,
222
+ state: getOrCreateRuntimeState(account.key),
223
+ }))
224
+ .filter(({ state }) => state.coolingReason === "transient" &&
225
+ state.coolingUntil !== undefined &&
226
+ state.coolingUntil > now)
227
+ .sort((a, b) => (a.state.coolingUntil ?? Number.POSITIVE_INFINITY) -
228
+ (b.state.coolingUntil ?? Number.POSITIVE_INFINITY))[0];
229
+ if (!transientCandidate?.state.coolingUntil) {
230
+ return [];
231
+ }
232
+ const waitMs = claimTransientCooldownAdmission(transientCandidate.account.key, transientCandidate.state.coolingUntil, now);
233
+ if (waitMs === undefined || now + waitMs > deadline) {
234
+ return [];
235
+ }
236
+ logger.always(`[proxy] all usable accounts are transiently cooling; queueing request for account=${transientCandidate.account.label} in ${waitMs}ms`);
237
+ await sleep(waitMs);
238
+ }
239
+ return [];
240
+ }
162
241
  // ---------------------------------------------------------------------------
163
242
  // Quota-aware cooldown helpers
164
243
  // ---------------------------------------------------------------------------
@@ -645,13 +724,11 @@ function responseInfoFromStream(data) {
645
724
  * into the body. Non-CC clients (Curator, custom apps) don't send these —
646
725
  * Anthropic rejects without them.
647
726
  */
648
- function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId) {
727
+ function polyfillOAuthBody(bodyStr, accountToken, snapshot, isClaudeClientRequest, preferredSessionId) {
649
728
  try {
650
729
  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.
730
+ // Billing header block synthesized for clients that do not already provide
731
+ // the genuine Claude Code identity shape.
655
732
  const agentBlock = {
656
733
  type: "text",
657
734
  text: snapshot?.body?.agentBlock ||
@@ -661,11 +738,11 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
661
738
  //
662
739
  // The subscription/OAuth path only accepts a `system` it recognises as the
663
740
  // 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`.
741
+ // identity blocks alongside the canonical prompt, which must pass through
742
+ // unchanged. A custom client (Curator) sends its own arbitrary system prompt
743
+ // with NO agent block; left in `system` it is rejected as
744
+ // `rate_limit_error: "Error"`, so we relocate it into the message stream and
745
+ // send only the recognised billing + agent blocks as `system`.
669
746
  if (parsed.system) {
670
747
  if (typeof parsed.system === "string") {
671
748
  parsed.system = [{ type: "text", text: parsed.system }];
@@ -679,28 +756,28 @@ function polyfillOAuthBody(bodyStr, accountToken, snapshot, preferredSessionId)
679
756
  type: "text",
680
757
  text: buildStableClaudeCodeBillingHeader(parsed.system[billingIdx]?.text ?? snapshot?.body?.billingHeader),
681
758
  };
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];
759
+ const hasClaudeCodeSystemIdentity = isClaudeClientRequest && agentIdx >= 0;
760
+ if (hasClaudeCodeSystemIdentity) {
761
+ // Claude Code's subagent billing marker, block order, and cache
762
+ // controls are part of the accepted request shape. Preserve every
763
+ // supplied block exactly; only synthesize a billing block if absent.
764
+ if (billingIdx < 0) {
765
+ parsed.system.unshift(billingBlock);
766
+ }
699
767
  }
700
768
  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];
769
+ // Strip generated identity blocks before relocating a custom
770
+ // client's actual system instructions into the message stream.
771
+ const indicesToRemove = [billingIdx, agentIdx]
772
+ .filter((i) => i >= 0)
773
+ .sort((a, b) => b - a);
774
+ for (const idx of indicesToRemove) {
775
+ parsed.system.splice(idx, 1);
776
+ }
777
+ if (parsed.system.length > 0) {
778
+ relocateClientSystemIntoMessages(parsed, parsed.system);
779
+ }
780
+ parsed.system = [billingBlock, agentBlock];
704
781
  }
705
782
  }
706
783
  }
@@ -2012,7 +2089,7 @@ async function handleAnthropicSuccessfulResponse(args) {
2012
2089
  });
2013
2090
  }
2014
2091
  async function handleAnthropicStreamingSuccessResponse(args) {
2015
- const { ctx, body, account, accountState: _accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2092
+ const { account, accountState: _accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2016
2093
  if (!response.body) {
2017
2094
  upstreamSpan?.end();
2018
2095
  tracer?.setError("stream_error", "No response body from upstream");
@@ -2093,17 +2170,9 @@ async function handleAnthropicStreamingSuccessResponse(args) {
2093
2170
  controller.enqueue(value);
2094
2171
  }
2095
2172
  catch (streamErr) {
2096
- const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
2173
+ const errMsg = describeTransportError(streamErr);
2097
2174
  logger.always(`[proxy] mid-stream error account=${account.label}: ${errMsg}`);
2098
2175
  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
2176
  if (!mainStreamClosed) {
2108
2177
  mainStreamClosed = true;
2109
2178
  const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Upstream stream interrupted: ${errMsg}` } })}\n\n`;
@@ -2469,7 +2538,7 @@ async function handleAnthropicJsonSuccessResponse(args) {
2469
2538
  return { response: responseJson };
2470
2539
  }
2471
2540
  async function handleAnthropicSuccessfulRetryResponse(args) {
2472
- const { ctx, body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2541
+ const { body, account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logProxyBody, logFinalRequest, } = args;
2473
2542
  const retryQuota = parseQuotaHeaders(retryResp.headers);
2474
2543
  if (retryQuota) {
2475
2544
  // Keep the auth-retry success path in parity with the main success path:
@@ -2514,17 +2583,9 @@ async function handleAnthropicSuccessfulRetryResponse(args) {
2514
2583
  controller.enqueue(value);
2515
2584
  }
2516
2585
  catch (streamErr) {
2517
- const errMsg = streamErr instanceof Error ? streamErr.message : String(streamErr);
2586
+ const errMsg = describeTransportError(streamErr);
2518
2587
  logger.always(`[proxy] mid-stream error (auth-retry) account=${account.label}: ${errMsg}`);
2519
2588
  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
2589
  if (!retryStreamClosed) {
2529
2590
  retryStreamClosed = true;
2530
2591
  const errorEvent = `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: `Upstream stream interrupted: ${errMsg}` } })}\n\n`;
@@ -2716,15 +2777,36 @@ async function handleAnthropicAuthRetry(args) {
2716
2777
  authRetryError = `retry ${authRetry + 1}/${MAX_AUTH_RETRIES} failed with status ${retryStatus}`;
2717
2778
  currentLastError = retryBody;
2718
2779
  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
2780
  if (retryStatus === 429 &&
2727
- !isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
2781
+ isAntiAbuseConstruction429(retryRespHeaders, retryBody)) {
2782
+ logger.always(`[proxy] ← 429 account=${account.label} anti-abuse/construction rejection after OAuth refresh — returning non-retryable request error`);
2783
+ logAttempt(429, "construction_rejection", retryBody);
2784
+ tracer?.setError("construction_rejection", retryBody.slice(0, 500));
2785
+ currentUpstreamSpan?.end();
2786
+ return {
2787
+ response: finalizeAnthropicTerminalFetchError({
2788
+ terminalError: buildAnthropicConstructionRejectionTerminalError(),
2789
+ account,
2790
+ tracer,
2791
+ requestStartTime,
2792
+ attemptNumber,
2793
+ logProxyBody,
2794
+ logFinalRequest,
2795
+ }),
2796
+ continueLoop: false,
2797
+ lastError: retryBody,
2798
+ authFailureMessage: currentAuthFailureMessage,
2799
+ sawRateLimit: currentSawRateLimit,
2800
+ sawTransientFailure: currentSawTransientFailure,
2801
+ sawNetworkError: currentSawNetworkError,
2802
+ upstreamSpan: undefined,
2803
+ };
2804
+ }
2805
+ recordAttemptError(account.label, account.type, retryStatus);
2806
+ // Construction rejections return through the terminal 400 path above.
2807
+ // Every 429 reaching this branch is a genuine rate limit and must cool
2808
+ // the account according to its reset window before rotating.
2809
+ if (retryStatus === 429) {
2728
2810
  currentSawRateLimit = true;
2729
2811
  // Cool the account per its real reset window before rotating, so a
2730
2812
  // session/weekly-exhausted account isn't re-selected next request.
@@ -2862,6 +2944,22 @@ function buildAnthropicTerminalErrorResponse(args) {
2862
2944
  return clientError;
2863
2945
  }
2864
2946
  }
2947
+ function finalizeAnthropicTerminalFetchError(args) {
2948
+ const { terminalError, account, tracer, requestStartTime, attemptNumber, logProxyBody, logFinalRequest, } = args;
2949
+ recordFinalError(terminalError.status, account.label, account.type);
2950
+ tracer?.end(terminalError.status, Date.now() - requestStartTime);
2951
+ return buildAnthropicTerminalErrorResponse({
2952
+ responseStatus: terminalError.status,
2953
+ account,
2954
+ errBody: terminalError.body,
2955
+ errRespHeaders: terminalError.headers,
2956
+ requestStartTime,
2957
+ attemptNumber,
2958
+ logProxyBody,
2959
+ logFinalRequest,
2960
+ errorType: terminalError.errorType,
2961
+ });
2962
+ }
2865
2963
  async function handleAnthropicNonOkResponse(args) {
2866
2964
  const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
2867
2965
  let currentLastError = lastError;
@@ -3271,7 +3369,7 @@ async function prepareAnthropicAccountAttempt(args) {
3271
3369
  }
3272
3370
  }
3273
3371
  const buildUpstreamBody = (token) => isOAuth
3274
- ? polyfillOAuthBody(bodyStr, token, snapshot, headers["x-claude-code-session-id"])
3372
+ ? polyfillOAuthBody(bodyStr, token, snapshot, isClaudeClientRequest, headers["x-claude-code-session-id"])
3275
3373
  : { bodyStr };
3276
3374
  const polyfilledBody = buildUpstreamBody(account.token);
3277
3375
  if (isOAuth &&
@@ -3325,7 +3423,7 @@ async function prepareAnthropicAccountAttempt(args) {
3325
3423
  * "Error" and which carries NONE of the real rate-limit headers (no retry-after,
3326
3424
  * no anthropic-ratelimit-*). This is NOT a capacity limit — retrying or rotating
3327
3425
  * 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".
3426
+ * return a non-retryable request error instead of "all accounts rate-limited".
3329
3427
  */
3330
3428
  function isAntiAbuseConstruction429(headers, body) {
3331
3429
  const hasRetryAfter = !!headers["retry-after"];
@@ -3335,6 +3433,14 @@ function isAntiAbuseConstruction429(headers, body) {
3335
3433
  }
3336
3434
  return (body.includes("rate_limit_error") && /"message"\s*:\s*"Error"/.test(body));
3337
3435
  }
3436
+ function buildAnthropicConstructionRejectionTerminalError() {
3437
+ return {
3438
+ status: 400,
3439
+ body: JSON.stringify(buildClaudeError(400, "Anthropic rejected the OAuth request shape. This is not an account rate limit.", "invalid_request_error")),
3440
+ headers: { "content-type": "application/json" },
3441
+ errorType: "construction_rejection",
3442
+ };
3443
+ }
3338
3444
  async function fetchAnthropicAccountResponse(args) {
3339
3445
  const { url, headers, finalBodyStr, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
3340
3446
  let lastError = currentLastError;
@@ -3374,9 +3480,7 @@ async function fetchAnthropicAccountResponse(args) {
3374
3480
  };
3375
3481
  }
3376
3482
  if (response.status === 429) {
3377
- sawRateLimit = true;
3378
3483
  const retryAfterMs = parseRetryAfterMs(response.headers.get("retry-after"));
3379
- recordAttemptError(account.label, account.type, 429);
3380
3484
  // Capture full response headers and body for diagnostics (parity with
3381
3485
  // handleAnthropicNonOkResponse which does this for all other error statuses).
3382
3486
  const errRespHeaders = {};
@@ -3407,28 +3511,24 @@ async function fetchAnthropicAccountResponse(args) {
3407
3511
  });
3408
3512
  // Anti-abuse / request-construction 429 (no rate-limit headers, body
3409
3513
  // "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.
3514
+ // and return a non-retryable request error instead of prompting the client
3515
+ // to repeat the same malformed request as a genuine rate limit.
3412
3516
  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`);
3517
+ 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
3518
  logAttempt(429, "construction_rejection", String(lastError));
3415
3519
  tracer?.setError("construction_rejection", String(lastError).slice(0, 500));
3416
3520
  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
3521
  return {
3424
3522
  continueLoop: false,
3425
- response: passthrough,
3523
+ terminalError: buildAnthropicConstructionRejectionTerminalError(),
3426
3524
  lastError,
3427
3525
  sawRateLimit,
3428
3526
  sawNetworkError,
3429
3527
  upstreamSpan: undefined,
3430
3528
  };
3431
3529
  }
3530
+ sawRateLimit = true;
3531
+ recordAttemptError(account.label, account.type, 429);
3432
3532
  // Parse the unified-window quota headers (present on real rate-limit 429s)
3433
3533
  // and derive a reset-aware cooldown plan. This is the fix for "kept
3434
3534
  // hammering the 5h/7d-exhausted account instead of switching": on an
@@ -3468,6 +3568,9 @@ async function fetchAnthropicAccountResponse(args) {
3468
3568
  upstreamSpan: currentUpstreamSpan,
3469
3569
  };
3470
3570
  }
3571
+ function shouldAttemptClaudeFallback(loopState) {
3572
+ return loopState.invalidRequestFailure === null;
3573
+ }
3471
3574
  async function handleAnthropicRoutedClaudeRequest(args) {
3472
3575
  const { ctx, body, modelRouter, tracer, requestStartTime, accountStrategy, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
3473
3576
  const parsedRequest = parseClaudeRequest(body);
@@ -3507,7 +3610,16 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3507
3610
  // is cooling, report the earliest persisted retry time without an upstream
3508
3611
  // call; restarting the proxy must not erase or bypass this quarantine.
3509
3612
  const nonCoolingAccounts = orderedAccounts.filter((a) => !isAccountCooling(a.key));
3510
- const effectiveAccounts = nonCoolingAccounts;
3613
+ let effectiveAccounts = nonCoolingAccounts;
3614
+ // If every usable account is cooling and the soonest recovery is a short
3615
+ // transient burst cooldown, hold this request and pace its admission instead
3616
+ // of returning a proxy-local 429 that makes the client retry in a herd. The
3617
+ // helper rechecks because an in-flight retry can extend the cooldown while
3618
+ // this request is waiting.
3619
+ if (effectiveAccounts.length === 0) {
3620
+ effectiveAccounts =
3621
+ await waitForTransientAccountAvailability(orderedAccounts);
3622
+ }
3511
3623
  if (effectiveAccounts.length === 0 && orderedAccounts.length > 0) {
3512
3624
  const coolingStates = orderedAccounts.map((account) => getOrCreateRuntimeState(account.key));
3513
3625
  const hasRateLimitCooldown = coolingStates.some((state) => state.coolingReason !== "auth");
@@ -3590,6 +3702,17 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3590
3702
  loopState.lastError = fetchResult.lastError;
3591
3703
  loopState.sawRateLimit = fetchResult.sawRateLimit;
3592
3704
  loopState.sawNetworkError = fetchResult.sawNetworkError;
3705
+ if (fetchResult.terminalError) {
3706
+ return finalizeAnthropicTerminalFetchError({
3707
+ terminalError: fetchResult.terminalError,
3708
+ account,
3709
+ tracer,
3710
+ requestStartTime,
3711
+ attemptNumber: loopState.attemptNumber,
3712
+ logProxyBody,
3713
+ logFinalRequest,
3714
+ });
3715
+ }
3593
3716
  if (fetchResult.continueLoop || !fetchResult.response) {
3594
3717
  // Genuine 429 (carries a cooldown plan derived from quota headers).
3595
3718
  if (fetchResult.cooldownPlan) {
@@ -3601,32 +3724,41 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3601
3724
  // Non-fatal: routing already has the in-memory snapshot.
3602
3725
  });
3603
3726
  }
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.
3727
+ // Publish the cooldown before retrying so requests arriving behind
3728
+ // this one skip the throttled account instead of joining the burst.
3729
+ let cooldownExtended = false;
3730
+ if (!accountState.coolingUntil ||
3731
+ plan.coolingUntil > accountState.coolingUntil) {
3732
+ accountState.coolingUntil = plan.coolingUntil;
3733
+ accountState.coolingReason = plan.reason;
3734
+ cooldownExtended = true;
3735
+ }
3736
+ if (cooldownExtended) {
3737
+ await saveAccountCooldown(account.key, accountState.coolingUntil, accountState.coolingReason ?? plan.reason).catch(() => {
3738
+ // Non-fatal: routing already has the in-memory cooldown.
3739
+ });
3740
+ }
3741
+ // Transient retries are budgeted across all concurrent requests for
3742
+ // this account/window. Exhaustion plans rotate immediately and never
3743
+ // claim this budget.
3744
+ const sharedRetrySlot = fetchResult.retrySameAccount
3745
+ ? claimTransientRateLimitRetry(account.key, plan.coolingUntil)
3746
+ : undefined;
3607
3747
  if (fetchResult.retrySameAccount &&
3608
3748
  fetchResult.retryAfterMs !== undefined &&
3609
- rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES) {
3749
+ rateLimitSameAccountRetries < MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES &&
3750
+ sharedRetrySlot !== undefined) {
3610
3751
  rateLimitSameAccountRetries += 1;
3611
3752
  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`);
3753
+ // Stagger the two shared slots, then cap after jitter so the final
3754
+ // sleep never exceeds the configured maximum.
3755
+ const delayMs = Math.min(MAX_RATE_LIMIT_RETRY_DELAY_MS, jitteredDelay(base * sharedRetrySlot));
3756
+ logger.always(`[proxy] retrying same account=${account.label} after transient 429 (shared slot ${sharedRetrySlot}/${MAX_RATE_LIMIT_SAME_ACCOUNT_RETRIES}) in ${delayMs}ms`);
3615
3757
  await sleep(delayMs);
3616
3758
  continue;
3617
3759
  }
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
- });
3760
+ // Exhaustion, or the shared transient retry budget being used up:
3761
+ // rotate while the already-published cooldown remains active.
3630
3762
  advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
3631
3763
  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
3764
  continue accountLoop;
@@ -3768,32 +3900,37 @@ async function handleAnthropicRoutedClaudeRequest(args) {
3768
3900
  if (loopState.attemptNumber === 0) {
3769
3901
  acctSelectionSpan?.end();
3770
3902
  }
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({
3903
+ // Deterministic invalid requests (for example, prompt-too-long) cannot be
3904
+ // repaired by changing providers. Preserve the authoritative Anthropic 400
3905
+ // rather than delaying it or returning unrelated fallback output.
3906
+ if (shouldAttemptClaudeFallback(loopState)) {
3907
+ const configuredFallbackResult = await tryConfiguredClaudeFallbackChain({
3788
3908
  ctx,
3789
3909
  body,
3910
+ parsedFallbackRequest: parsedRequest,
3911
+ modelRouter,
3790
3912
  tracer,
3791
3913
  requestStartTime,
3792
3914
  logProxyBody,
3793
3915
  logFinalRequest,
3794
3916
  });
3795
- if (autoFallbackResponse) {
3796
- return autoFallbackResponse;
3917
+ if (configuredFallbackResult.response) {
3918
+ return configuredFallbackResult.response;
3919
+ }
3920
+ // Try auto-provider fallback when the configured chain didn't produce a
3921
+ // response (either no chain configured, or all entries failed/deduped).
3922
+ if (!loopState.sawRateLimit) {
3923
+ const autoFallbackResponse = await tryAutoClaudeFallback({
3924
+ ctx,
3925
+ body,
3926
+ tracer,
3927
+ requestStartTime,
3928
+ logProxyBody,
3929
+ logFinalRequest,
3930
+ });
3931
+ if (autoFallbackResponse) {
3932
+ return autoFallbackResponse;
3933
+ }
3797
3934
  }
3798
3935
  }
3799
3936
  return buildClaudeAnthropicFailureResponse({
@@ -4077,6 +4214,26 @@ function getErrorCode(error) {
4077
4214
  const causeCode = cause.code;
4078
4215
  return typeof causeCode === "string" ? causeCode : undefined;
4079
4216
  }
4217
+ function describeTransportError(error) {
4218
+ const message = error instanceof Error ? error.message : String(error);
4219
+ if (!error || typeof error !== "object") {
4220
+ return message;
4221
+ }
4222
+ const cause = error.cause;
4223
+ const causeMessage = cause instanceof Error
4224
+ ? cause.message
4225
+ : cause && typeof cause === "object"
4226
+ ? String(cause.message ?? "")
4227
+ : "";
4228
+ const code = getErrorCode(error);
4229
+ const detail = [
4230
+ code,
4231
+ causeMessage && causeMessage !== message && causeMessage,
4232
+ ]
4233
+ .filter(Boolean)
4234
+ .join(": ");
4235
+ return detail ? `${message} (${detail})` : message;
4236
+ }
4080
4237
  /**
4081
4238
  * Determine whether a thrown fetch error is a transient connectivity issue.
4082
4239
  */
@@ -4236,10 +4393,23 @@ export const __testHooks = {
4236
4393
  },
4237
4394
  resetAllRuntimeState: () => {
4238
4395
  accountRuntimeState.clear();
4396
+ transientRateLimitRetryBudgets.clear();
4397
+ transientCooldownAdmissionSchedules.clear();
4239
4398
  primaryAccountIndex = 0;
4240
4399
  lastKnownAccountCount = 0;
4241
4400
  configuredPrimaryAccountKey = undefined;
4242
4401
  configuredAccountAllowlist = undefined;
4243
4402
  },
4403
+ polyfillOAuthBody: (bodyStr, isClaudeClientRequest) => polyfillOAuthBody(bodyStr, "test-account-token", null, isClaudeClientRequest),
4404
+ isAntiAbuseConstruction429,
4405
+ fetchAnthropicAccountResponse,
4406
+ finalizeAnthropicTerminalFetchError,
4407
+ handleAnthropicAuthRetry,
4408
+ handleAnthropicStreamingSuccessResponse,
4409
+ claimTransientRateLimitRetry,
4410
+ claimTransientCooldownAdmission,
4411
+ waitForTransientAccountAvailability,
4412
+ describeTransportError,
4413
+ shouldAttemptClaudeFallback,
4244
4414
  };
4245
4415
  //# sourceMappingURL=claudeProxyRoutes.js.map
@@ -586,6 +586,10 @@ export type AccountCooldownPlan = {
586
586
  * burst), a small number of jittered same-account retries is allowed first. */
587
587
  rotateImmediately: boolean;
588
588
  };
589
+ export type TransientRateLimitRetryBudget = {
590
+ coolingUntil: number;
591
+ retriesClaimed: number;
592
+ };
589
593
  export type AnthropicUpstreamFetchResult = {
590
594
  continueLoop: boolean;
591
595
  retrySameAccount?: boolean;
@@ -595,6 +599,15 @@ export type AnthropicUpstreamFetchResult = {
595
599
  cooldownPlan?: AccountCooldownPlan;
596
600
  /** Quota snapshot parsed from the response headers (429 or success), if present. */
597
601
  quota?: AccountQuota;
602
+ /** A terminal upstream rejection already captured and classified by the
603
+ * fetch layer. The route must finalize it directly instead of feeding it
604
+ * through the generic non-OK handler a second time. */
605
+ terminalError?: {
606
+ status: number;
607
+ body: string;
608
+ headers: Record<string, string>;
609
+ errorType: "construction_rejection";
610
+ };
598
611
  response?: Response;
599
612
  lastError: unknown;
600
613
  sawRateLimit: boolean;
@@ -3,6 +3,15 @@
3
3
  *
4
4
  * Runtime helper lives in `src/lib/utils/safeFetch.ts`.
5
5
  */
6
+ /**
7
+ * One validated address the pinned connect layer is allowed to dial.
8
+ * Produced by `ssrfGuard.ts:validateAndResolveUrl`, consumed by
9
+ * `safeFetch.ts:buildPinnedAgent`.
10
+ */
11
+ export type PinnedAddress = {
12
+ ip: string;
13
+ family: 4 | 6;
14
+ };
6
15
  export type SafeDownloadOptions = {
7
16
  /** Hard cap on response size in bytes. Pass MAX_VIDEO_BYTES/MAX_AUDIO_BYTES/MAX_IMAGE_BYTES from sizeGuard. */
8
17
  maxBytes: number;