@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.
- package/CHANGELOG.md +12 -0
- package/dist/browser/neurolink.min.js +365 -366
- package/dist/lib/server/routes/claudeProxyRoutes.d.ts +118 -1
- package/dist/lib/server/routes/claudeProxyRoutes.js +282 -112
- package/dist/lib/types/proxy.d.ts +13 -0
- package/dist/lib/types/safeFetch.d.ts +9 -0
- package/dist/lib/utils/safeFetch.d.ts +3 -3
- package/dist/lib/utils/safeFetch.js +23 -15
- package/dist/lib/utils/ssrfGuard.d.ts +13 -5
- package/dist/lib/utils/ssrfGuard.js +48 -13
- package/dist/server/routes/claudeProxyRoutes.d.ts +118 -1
- package/dist/server/routes/claudeProxyRoutes.js +282 -112
- package/dist/types/proxy.d.ts +13 -0
- package/dist/types/safeFetch.d.ts +9 -0
- package/dist/utils/safeFetch.d.ts +3 -3
- package/dist/utils/safeFetch.js +23 -15
- package/dist/utils/ssrfGuard.d.ts +13 -5
- package/dist/utils/ssrfGuard.js +48 -13
- 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";
|
|
@@ -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
|
|
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.
|
|
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
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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
|
-
|
|
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];
|
|
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
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
3411
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
3605
|
-
//
|
|
3606
|
-
|
|
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
|
-
//
|
|
3613
|
-
|
|
3614
|
-
|
|
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
|
|
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
|
-
});
|
|
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
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
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 (
|
|
3796
|
-
return
|
|
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;
|