@juspay/neurolink 10.8.9 → 10.8.11

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.
@@ -12,6 +12,14 @@
12
12
  import type { AccountQuota } from "../types/index.js";
13
13
  /** Read and normalize Anthropic's authoritative top-level unified status. */
14
14
  export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
15
+ /**
16
+ * Whether Anthropic explicitly permits a request to use overage after a
17
+ * subscription window is exhausted. Fresh responses require all three
18
+ * provider signals. Older persisted snapshots predate the raw fallback and
19
+ * upgrade-path fields, but retain a positive fallback percentage together with
20
+ * an allowed overage status, which is the equivalent provider state.
21
+ */
22
+ export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
15
23
  /**
16
24
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
17
25
  * Returns `null` when key headers are absent.
@@ -39,6 +39,27 @@ export function getUnifiedRateLimitStatus(headers) {
39
39
  const normalized = value?.trim().toLowerCase();
40
40
  return normalized || undefined;
41
41
  }
42
+ /**
43
+ * Whether Anthropic explicitly permits a request to use overage after a
44
+ * subscription window is exhausted. Fresh responses require all three
45
+ * provider signals. Older persisted snapshots predate the raw fallback and
46
+ * upgrade-path fields, but retain a positive fallback percentage together with
47
+ * an allowed overage status, which is the equivalent provider state.
48
+ */
49
+ export function isQuotaOverageAvailable(quota) {
50
+ if (quota?.overageStatus?.trim().toLowerCase() !== "allowed") {
51
+ return false;
52
+ }
53
+ const explicitFallback = quota.fallbackStatus?.trim().toLowerCase();
54
+ const hasExplicitOveragePath = (quota.upgradePaths ?? "")
55
+ .split(",")
56
+ .map((path) => path.trim().toLowerCase())
57
+ .includes("overage");
58
+ if (explicitFallback === "available" && hasExplicitOveragePath) {
59
+ return true;
60
+ }
61
+ return explicitFallback === undefined && (quota.fallbackPercentage ?? 0) > 0;
62
+ }
42
63
  /**
43
64
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
44
65
  * Returns `null` when key headers are absent.
@@ -69,6 +90,8 @@ export function parseQuotaHeaders(headers) {
69
90
  weeklyStatus: getHeader(headers, `${P}unified-7d-status`) ?? "unknown",
70
91
  weeklyResetAt: weeklyResetRaw ? parseInt(weeklyResetRaw, 10) || 0 : 0,
71
92
  fallbackPercentage: fallbackRaw ? parseFloat(fallbackRaw) || 0 : 0,
93
+ fallbackStatus: getHeader(headers, `${P}unified-fallback`) ?? "unknown",
94
+ upgradePaths: getHeader(headers, `${P}unified-upgrade-paths`),
72
95
  overageStatus: getHeader(headers, `${P}unified-overage-status`) ?? "unknown",
73
96
  lastUpdated: Date.now(),
74
97
  };
@@ -87,6 +87,7 @@ function routingCandidateValue(value) {
87
87
  "sessionStatus",
88
88
  "weeklyStatus",
89
89
  ];
90
+ const optionalNullableStringFields = ["fallbackStatus", "upgradePaths"];
90
91
  if (!stringValue(candidate.account) ||
91
92
  typeof candidate.accountType !== "string" ||
92
93
  !ROUTING_ACCOUNT_TYPES.has(candidate.accountType) ||
@@ -97,6 +98,12 @@ function routingCandidateValue(value) {
97
98
  requiredBooleanFields.some((field) => typeof candidate[field] !== "boolean") ||
98
99
  requiredNullableNumberFields.some((field) => !isNullableFiniteNumber(candidate[field])) ||
99
100
  requiredNullableStringFields.some((field) => !isNullableString(candidate[field])) ||
101
+ optionalNullableStringFields.some((field) => field in candidate &&
102
+ candidate[field] !== undefined &&
103
+ !isNullableString(candidate[field])) ||
104
+ ("overageEligible" in candidate &&
105
+ candidate.overageEligible !== undefined &&
106
+ typeof candidate.overageEligible !== "boolean") ||
100
107
  !(candidate.coolingReason === null ||
101
108
  (typeof candidate.coolingReason === "string" &&
102
109
  COOLING_REASONS.has(candidate.coolingReason)))) {
@@ -117,6 +124,9 @@ function routingCandidateValue(value) {
117
124
  coolingReason: candidate.coolingReason,
118
125
  coolingUntil: candidate.coolingUntil,
119
126
  unifiedStatus: candidate.unifiedStatus,
127
+ fallbackStatus: candidate.fallbackStatus,
128
+ upgradePaths: candidate.upgradePaths,
129
+ overageEligible: candidate.overageEligible,
120
130
  overageStatus: candidate.overageStatus,
121
131
  sessionStatus: candidate.sessionStatus,
122
132
  sessionUsed: candidate.sessionUsed,
@@ -12,7 +12,7 @@
12
12
  import { buildTranslationOptions } from "../../proxy/proxyTranslationEngine.js";
13
13
  import { ProxyTracer } from "../../proxy/proxyTracer.js";
14
14
  import { isPermanentRefreshFailure } from "../../proxy/tokenRefresh.js";
15
- import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
15
+ import type { AccountAllowlist, AccountAdmissionLease, AccountCooldownPlan, AccountQuota, AnthropicAttemptLogger, AnthropicAuthRetryResult, AnthropicLoopState, AnthropicNonOkResult, AnthropicSuccessResult, AnthropicUpstreamFetchResult, ClaudeFinalRequestLogger, ClaudeLoggedErrorBuilder, ClaudeRequest, ClaudeProxyRouteRuntimeOptions, ModelRouterInterface, ParsedClaudeError, ProxyAccountRoutingDecision, ProxyAccountSortMetrics, ProxyBodyCaptureLogger, ProxyQuotaCooldownUpdate, ProxyPassthroughAccount, RouteGroup, RuntimeAccountState, ServerContext, StreamTerminalOutcome } from "../../types/index.js";
16
16
  declare function tryAcquireAccountAdmission(accountKey: string, capacity: number): AccountAdmissionLease | undefined;
17
17
  declare function acquireAccountAdmission(accountKey: string, capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<AccountAdmissionLease | undefined>;
18
18
  declare function acquireFirstAvailableAccountAdmission(accountKeys: string[], capacity: number, abortSignal?: AbortSignal, timeoutMs?: number): Promise<{
@@ -45,15 +45,23 @@ declare function resetEpochToMs(resetEpoch: number | undefined, now: number): nu
45
45
  * The unified subscription limits expose per-window status + reset:
46
46
  * - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
47
47
  * - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
48
- * Both mean "retrying this account is futile until its window resets" → rotate
49
- * immediately (no same-account retries) and park the account until the ACTUAL
50
- * reset never the legacy 60s hardcap that let us re-hammer a spent account.
48
+ * Both mean "retrying this account is futile until its window resets" unless
49
+ * the provider explicitly enables overage. In that case, the subscription
50
+ * window is exhausted but the account remains usable for paid fallback.
51
51
  *
52
52
  * Anything else (window still "allowed" but momentarily 429'd — a per-minute
53
53
  * burst / acceleration limit) is transient: honor retry-after as a floor,
54
54
  * allow a couple of jittered same-account retries, then a short cooldown.
55
55
  */
56
56
  declare function planCooldownFor429(quota: AccountQuota | null, retryAfterMs: number, now: number, unifiedStatus?: string | undefined): AccountCooldownPlan;
57
+ /**
58
+ * Proactively cool an account when a SUCCESS response reveals a window has just
59
+ * flipped to "rejected" (the boundary request that spends the last of the quota
60
+ * still returns 200 but reports rejected/next-reset). Parks the account until
61
+ * its reset so the next request skips it instead of discovering the limit via a
62
+ * 429, except when the provider explicitly enables paid overage.
63
+ */
64
+ declare function reconcileCooldownFromQuota(state: RuntimeAccountState, quota: AccountQuota, now: number): ProxyQuotaCooldownUpdate;
57
65
  /**
58
66
  * Seed each account's runtime quota from the persisted snapshots in
59
67
  * ~/.neurolink/account-quotas.json (keyed by label). Runtime state is
@@ -289,6 +297,12 @@ export declare function createClaudeProxyRoutes(modelRouter?: ModelRouterInterfa
289
297
  declare function reconcileEligibleAccountRuntimeState(account: ProxyPassthroughAccount): void;
290
298
  export declare function getTransientSameAccountRetryDelayMs(retryNumber: number): number;
291
299
  declare function describeTransportError(error: unknown): string;
300
+ /**
301
+ * Determine whether a POST can be retried without risking duplicate provider
302
+ * work. Only failures that prove connection establishment did not complete are
303
+ * safe; a reset, socket error, or response timeout can happen after dispatch.
304
+ */
305
+ declare function isRetryableNetworkError(error: unknown): boolean;
292
306
  /**
293
307
  * Parse a Claude error payload when available.
294
308
  */
@@ -329,6 +343,8 @@ export declare const __testHooks: {
329
343
  resolveHomeIndex: typeof resolveHomeIndex;
330
344
  maybeResetPrimaryToHome: typeof maybeResetPrimaryToHome;
331
345
  planCooldownFor429: typeof planCooldownFor429;
346
+ reconcileCooldownFromQuota: typeof reconcileCooldownFromQuota;
347
+ isRetryableNetworkError: typeof isRetryableNetworkError;
332
348
  isPermanentRefreshFailure: typeof isPermanentRefreshFailure;
333
349
  getStreamFailureDetails: typeof getStreamFailureDetails;
334
350
  trackUpstreamReadableStream: typeof trackUpstreamReadableStream;
@@ -12,10 +12,11 @@
12
12
  import { access, readFile } from "node:fs/promises";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
+ import { Agent } from "undici";
15
16
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
16
17
  import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
17
18
  import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
18
- import { getUnifiedRateLimitStatus, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
19
+ import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
19
20
  import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
20
21
  import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
21
22
  import { tracers } from "../../telemetry/tracers.js";
@@ -91,6 +92,21 @@ const AUTH_REFRESH_MAX_COOLDOWN_MS = 5 * 60 * 1000;
91
92
  * to cover the full lifecycle of streaming responses, including extended
92
93
  * thinking from Opus models (which can exceed 5 minutes for large contexts). */
93
94
  const UPSTREAM_FETCH_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
95
+ let anthropicUpstreamDispatcher;
96
+ function fetchAnthropicUpstream(url, init) {
97
+ // Node's global fetch applies Undici's 300s default headers timeout before
98
+ // the route's 15-minute abort signal. Keep both transport deadlines aligned
99
+ // with the proxy contract and instantiate lazily so importing routes has no
100
+ // open transport handles.
101
+ anthropicUpstreamDispatcher ??= new Agent({
102
+ headersTimeout: UPSTREAM_FETCH_TIMEOUT_MS,
103
+ bodyTimeout: UPSTREAM_FETCH_TIMEOUT_MS,
104
+ });
105
+ return fetch(url, {
106
+ ...init,
107
+ dispatcher: anthropicUpstreamDispatcher,
108
+ });
109
+ }
94
110
  const accountRuntimeState = new Map();
95
111
  /** Shared across requests so a concurrent burst gets at most two retries for
96
112
  * the account/window, rather than every request starting its own retry chain. */
@@ -411,9 +427,9 @@ function clampCooldownUntil(untilMs, now) {
411
427
  * The unified subscription limits expose per-window status + reset:
412
428
  * - weekly (7d) "rejected" → hard cap for the week; cool until the 7d reset.
413
429
  * - session (5h) "rejected" → paced out for this session; cool until the 5h reset.
414
- * Both mean "retrying this account is futile until its window resets" → rotate
415
- * immediately (no same-account retries) and park the account until the ACTUAL
416
- * reset never the legacy 60s hardcap that let us re-hammer a spent account.
430
+ * Both mean "retrying this account is futile until its window resets" unless
431
+ * the provider explicitly enables overage. In that case, the subscription
432
+ * window is exhausted but the account remains usable for paid fallback.
417
433
  *
418
434
  * Anything else (window still "allowed" but momentarily 429'd — a per-minute
419
435
  * burst / acceleration limit) is transient: honor retry-after as a floor,
@@ -430,7 +446,8 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
430
446
  rotateImmediately: true,
431
447
  };
432
448
  }
433
- if (quota && quota.sessionStatus === "rejected") {
449
+ const overageAvailable = isQuotaOverageAvailable(quota);
450
+ if (quota && quota.sessionStatus === "rejected" && !overageAvailable) {
434
451
  const reset = resetEpochToMs(quota.sessionResetAt, now) ??
435
452
  (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
436
453
  return {
@@ -442,7 +459,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
442
459
  // Anthropic may reject the authoritative top-level unified limit while both
443
460
  // 5h and 7d sub-window statuses still say "allowed". Treating this as a
444
461
  // transient burst retries a known-exhausted account and delays failover.
445
- if (unifiedStatus?.trim().toLowerCase() === "rejected") {
462
+ if (unifiedStatus?.trim().toLowerCase() === "rejected" && !overageAvailable) {
446
463
  const reset = retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS;
447
464
  return {
448
465
  reason: "unified",
@@ -470,34 +487,53 @@ function minutesUntil(untilMs, now) {
470
487
  * flipped to "rejected" (the boundary request that spends the last of the quota
471
488
  * still returns 200 but reports rejected/next-reset). Parks the account until
472
489
  * its reset so the next request skips it instead of discovering the limit via a
473
- * 429. Never shortens an existing, longer cooldown.
490
+ * 429, except when the provider explicitly enables paid overage.
474
491
  */
475
- function maybeCoolFromQuota(state, quota, now) {
492
+ function reconcileCooldownFromQuota(state, quota, now) {
493
+ const overageAvailable = isQuotaOverageAvailable(quota);
476
494
  let until;
477
495
  let reason;
478
496
  if (quota.weeklyStatus === "rejected") {
479
497
  until = resetEpochToMs(quota.weeklyResetAt, now);
480
498
  reason = "weekly";
481
499
  }
482
- else if (quota.sessionStatus === "rejected") {
500
+ if (until === undefined &&
501
+ overageAvailable &&
502
+ state.coolingUntil &&
503
+ (state.coolingReason === "session" || state.coolingReason === "unified")) {
504
+ const previousCoolingUntil = state.coolingUntil;
505
+ state.coolingUntil = undefined;
506
+ state.coolingReason = undefined;
507
+ logger.always("[proxy] clearing subscription cooldown because Anthropic explicitly permits overage");
508
+ return { kind: "cleared", coolingUntil: previousCoolingUntil };
509
+ }
510
+ if (until === undefined &&
511
+ quota.sessionStatus === "rejected" &&
512
+ !overageAvailable) {
483
513
  until = resetEpochToMs(quota.sessionResetAt, now);
484
514
  reason = "session";
485
515
  }
486
- else if (quota.unifiedStatus === "rejected") {
516
+ else if (until === undefined &&
517
+ quota.unifiedStatus === "rejected" &&
518
+ !overageAvailable) {
487
519
  until = now + DEFAULT_HARD_COOLDOWN_MS;
488
520
  reason = "unified";
489
521
  }
490
522
  if (until === undefined) {
491
- return false;
523
+ return null;
492
524
  }
493
525
  const clamped = clampCooldownUntil(until, now);
494
526
  if (!state.coolingUntil || clamped > state.coolingUntil) {
495
527
  state.coolingUntil = clamped;
496
528
  state.coolingReason = reason;
497
529
  logger.always(`[proxy] proactively cooling account (${reason}) ~${minutesUntil(clamped, now)}m from success-response quota (status rejected)`);
498
- return true;
530
+ return {
531
+ kind: "cooled",
532
+ coolingUntil: clamped,
533
+ coolingReason: reason ?? "unified",
534
+ };
499
535
  }
500
- return false;
536
+ return null;
501
537
  }
502
538
  /**
503
539
  * Seed each account's runtime quota from the persisted snapshots in
@@ -528,6 +564,15 @@ async function seedRuntimeQuotasFromDisk(accounts) {
528
564
  state.coolingUntil = persistedCooldown.coolingUntil;
529
565
  state.coolingReason = persistedCooldown.reason;
530
566
  }
567
+ if (state.quota) {
568
+ const cooldownUpdate = reconcileCooldownFromQuota(state, state.quota, now);
569
+ if (cooldownUpdate?.kind === "cooled") {
570
+ await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason);
571
+ }
572
+ else if (cooldownUpdate?.kind === "cleared") {
573
+ await clearAccountCooldown(account.key, cooldownUpdate.coolingUntil);
574
+ }
575
+ }
531
576
  }
532
577
  }
533
578
  catch {
@@ -594,13 +639,16 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
594
639
  ? (q.weeklyStatus ?? "unknown")
595
640
  : "allowed"
596
641
  : null;
642
+ const overageEligible = isQuotaOverageAvailable(q);
597
643
  const saturated = sessionStatus === "throttled" ||
598
644
  (sessionTicking && (sessionUsed ?? 0) >= sessionSoftLimit);
599
645
  const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
600
646
  return {
601
647
  usable: !coolingActive &&
602
648
  weeklyStatus !== "rejected" &&
603
- sessionStatus !== "rejected",
649
+ (sessionStatus !== "rejected" || overageEligible) &&
650
+ (q?.unifiedStatus?.trim().toLowerCase() !== "rejected" ||
651
+ overageEligible),
604
652
  saturated,
605
653
  hasQuota: !!q,
606
654
  quotaLastUpdated,
@@ -609,6 +657,9 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
609
657
  coolingReason: st?.coolingReason ?? null,
610
658
  coolingUntil: st?.coolingUntil ?? 0,
611
659
  unifiedStatus: q?.unifiedStatus ?? null,
660
+ fallbackStatus: q?.fallbackStatus ?? null,
661
+ upgradePaths: q?.upgradePaths ?? null,
662
+ overageEligible,
612
663
  overageStatus: q?.overageStatus ?? null,
613
664
  sessionStatus,
614
665
  sessionUsed,
@@ -735,6 +786,9 @@ function buildRoutingDecision(args) {
735
786
  ? metrics.coolingUntil
736
787
  : null,
737
788
  unifiedStatus: metrics.unifiedStatus,
789
+ fallbackStatus: metrics.fallbackStatus,
790
+ upgradePaths: metrics.upgradePaths,
791
+ overageEligible: metrics.overageEligible,
738
792
  overageStatus: metrics.overageStatus,
739
793
  sessionStatus: metrics.sessionStatus,
740
794
  sessionUsed: metrics.sessionUsed,
@@ -1368,7 +1422,7 @@ async function handleClaudePassthroughRequest(args) {
1368
1422
  recordAttempt("passthrough", "passthrough");
1369
1423
  let response;
1370
1424
  try {
1371
- response = await fetch("https://api.anthropic.com/v1/messages?beta=true", {
1425
+ response = await fetchAnthropicUpstream("https://api.anthropic.com/v1/messages?beta=true", {
1372
1426
  method: "POST",
1373
1427
  headers: upstreamHeaders,
1374
1428
  body: bodyStr,
@@ -2385,15 +2439,18 @@ async function handleAnthropicSuccessfulResponse(args) {
2385
2439
  if (quota) {
2386
2440
  // Stash the latest quota on runtime state so the next request can pick the
2387
2441
  // account whose window resets soonest (max-utilization) and proactively
2388
- // skip any whose window is already rejected without eating a 429 first.
2442
+ // skip rejected windows unless Anthropic explicitly permits overage.
2389
2443
  accountState.quota = quota;
2390
- if (maybeCoolFromQuota(accountState, quota, Date.now())) {
2391
- const { coolingUntil, coolingReason } = accountState;
2392
- if (coolingUntil !== undefined && coolingReason !== undefined) {
2393
- saveAccountCooldown(account.key, coolingUntil, coolingReason).catch(() => {
2394
- // Non-fatal: cooldown is already active in memory.
2395
- });
2396
- }
2444
+ const cooldownUpdate = reconcileCooldownFromQuota(accountState, quota, Date.now());
2445
+ if (cooldownUpdate?.kind === "cooled") {
2446
+ saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
2447
+ // Non-fatal: cooldown is already active in memory.
2448
+ });
2449
+ }
2450
+ else if (cooldownUpdate?.kind === "cleared") {
2451
+ clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
2452
+ // Non-fatal: the next successful response will reconcile again.
2453
+ });
2397
2454
  }
2398
2455
  saveAccountQuota(account.label, quota).catch(() => {
2399
2456
  // Non-fatal: quota persistence is best-effort
@@ -2469,10 +2526,11 @@ async function handleAnthropicStreamingSuccessResponse(args) {
2469
2526
  if (preflight.kind === "transport_error") {
2470
2527
  const message = describeTransportError(preflight.error);
2471
2528
  const partialBody = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
2472
- logger.always(`[proxy] stream failed before first chunk account=${account.label}: ${message}; trying next account`);
2529
+ // The POST has already returned a response. The upstream may have started
2530
+ // processing it, so replaying it on another account could duplicate work.
2531
+ logger.always(`[proxy] stream failed before first chunk account=${account.label}: ${message}; returning terminal error to avoid replaying an ambiguous request`);
2473
2532
  recordAttemptError(account.label, account.type, 502);
2474
- logAttempt(502, "stream_error", message, { retryable: true });
2475
- tracer?.recordRetry(account.label, "stream_before_first_chunk");
2533
+ logAttempt(502, "stream_error", message, { retryable: false });
2476
2534
  upstreamSpan?.end();
2477
2535
  logProxyBody({
2478
2536
  phase: "upstream_response",
@@ -2488,8 +2546,16 @@ async function handleAnthropicStreamingSuccessResponse(args) {
2488
2546
  metadata: { logicalStatus: 502, transportError: message },
2489
2547
  });
2490
2548
  return {
2491
- retryNextAccount: true,
2492
- failure: { message, rateLimit: false },
2549
+ response: finalizeAnthropicTerminalTransportError({
2550
+ account,
2551
+ tracer,
2552
+ requestStartTime,
2553
+ attemptNumber,
2554
+ logProxyBody,
2555
+ logFinalRequest,
2556
+ errorType: "stream_error",
2557
+ message,
2558
+ }),
2493
2559
  };
2494
2560
  }
2495
2561
  if (preflight.kind === "empty") {
@@ -2995,16 +3061,18 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
2995
3061
  const retryQuota = parseQuotaHeaders(retryResp.headers);
2996
3062
  if (retryQuota) {
2997
3063
  // Keep the auth-retry success path in parity with the main success path:
2998
- // stash quota for proactive selection and proactively cool if this
2999
- // response reveals the window flipped to "rejected".
3064
+ // stash quota for proactive selection and reconcile a rejected window.
3000
3065
  accountState.quota = retryQuota;
3001
- if (maybeCoolFromQuota(accountState, retryQuota, Date.now())) {
3002
- const { coolingUntil, coolingReason } = accountState;
3003
- if (coolingUntil !== undefined && coolingReason !== undefined) {
3004
- saveAccountCooldown(account.key, coolingUntil, coolingReason).catch(() => {
3005
- // Non-fatal: cooldown is already active in memory.
3006
- });
3007
- }
3066
+ const cooldownUpdate = reconcileCooldownFromQuota(accountState, retryQuota, Date.now());
3067
+ if (cooldownUpdate?.kind === "cooled") {
3068
+ saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
3069
+ // Non-fatal: cooldown is already active in memory.
3070
+ });
3071
+ }
3072
+ else if (cooldownUpdate?.kind === "cleared") {
3073
+ clearAccountCooldown(account.key, cooldownUpdate.coolingUntil).catch(() => {
3074
+ // Non-fatal: the next successful response will reconcile again.
3075
+ });
3008
3076
  }
3009
3077
  saveAccountQuota(account.label, retryQuota).catch((error) => {
3010
3078
  logger.debug("[proxy] Failed to persist account quota after auth retry", {
@@ -3133,7 +3201,7 @@ async function handleAnthropicAuthRetry(args) {
3133
3201
  metadata: { upstreamMethod: "POST", upstreamUrl: url },
3134
3202
  });
3135
3203
  try {
3136
- const retryResp = await fetch(url, {
3204
+ const retryResp = await fetchAnthropicUpstream(url, {
3137
3205
  method: "POST",
3138
3206
  headers,
3139
3207
  body: retryBodyStr,
@@ -3344,11 +3412,37 @@ async function handleAnthropicAuthRetry(args) {
3344
3412
  : String(retryFetchErr);
3345
3413
  authRetryError = `network error on retry ${authRetry + 1}: ${message}`;
3346
3414
  currentLastError = authRetryError;
3415
+ const retryable = isRetryableNetworkError(retryFetchErr);
3347
3416
  retryLogAttempt(502, "network_error", message, {
3348
- retryable: isRetryableNetworkError(retryFetchErr),
3417
+ retryable,
3349
3418
  errorCode: getErrorCode(retryFetchErr) ?? "unknown",
3350
3419
  });
3351
3420
  logger.debug(`[proxy] ${authRetryError}`);
3421
+ if (!retryable) {
3422
+ // Once a POST has left this process, a reset/timeout or unknown fetch
3423
+ // failure is ambiguous: retrying it on another account can duplicate
3424
+ // the request. Only connection-establishment failures are replay-safe.
3425
+ currentUpstreamSpan?.end();
3426
+ return {
3427
+ response: finalizeAnthropicTerminalTransportError({
3428
+ account,
3429
+ tracer,
3430
+ requestStartTime,
3431
+ attemptNumber: retryAttemptNumber,
3432
+ logProxyBody,
3433
+ logFinalRequest,
3434
+ errorType: "network_error",
3435
+ message,
3436
+ }),
3437
+ continueLoop: false,
3438
+ lastError: currentLastError,
3439
+ authFailureMessage: currentAuthFailureMessage,
3440
+ sawRateLimit: currentSawRateLimit,
3441
+ sawTransientFailure: currentSawTransientFailure,
3442
+ sawNetworkError: currentSawNetworkError,
3443
+ upstreamSpan: undefined,
3444
+ };
3445
+ }
3352
3446
  break;
3353
3447
  }
3354
3448
  }
@@ -3427,6 +3521,27 @@ function finalizeAnthropicTerminalFetchError(args) {
3427
3521
  errorType: terminalError.errorType,
3428
3522
  });
3429
3523
  }
3524
+ function finalizeAnthropicTerminalTransportError(args) {
3525
+ const { account, tracer, requestStartTime, attemptNumber, logProxyBody, logFinalRequest, errorType, message, } = args;
3526
+ tracer?.setError(errorType, message);
3527
+ tracer?.end(502, Date.now() - requestStartTime);
3528
+ logFinalRequest(502, account.label, account.type, errorType, message);
3529
+ const clientError = buildClaudeError(502, message);
3530
+ const clientErrorBody = JSON.stringify(clientError);
3531
+ logProxyBody({
3532
+ phase: "client_response",
3533
+ headers: { "content-type": "application/json" },
3534
+ body: clientErrorBody,
3535
+ bodySize: Buffer.byteLength(clientErrorBody, "utf8"),
3536
+ contentType: "application/json",
3537
+ account: account.label,
3538
+ accountType: account.type,
3539
+ attempt: attemptNumber,
3540
+ responseStatus: 502,
3541
+ durationMs: Date.now() - requestStartTime,
3542
+ });
3543
+ return clientError;
3544
+ }
3430
3545
  async function handleAnthropicNonOkResponse(args) {
3431
3546
  const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
3432
3547
  let currentLastError = lastError;
@@ -4000,7 +4115,7 @@ async function fetchAnthropicAccountResponse(args) {
4000
4115
  const currentUpstreamSpan = upstreamSpan;
4001
4116
  let response;
4002
4117
  try {
4003
- response = await fetch(url, {
4118
+ response = await fetchAnthropicUpstream(url, {
4004
4119
  method: "POST",
4005
4120
  headers,
4006
4121
  body: finalBodyStr,
@@ -4461,8 +4576,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4461
4576
  // Clear cooling on success — but only if the stored cooldown has already
4462
4577
  // expired, so an older in-flight success can't wipe an active exhaustion
4463
4578
  // cooldown just set by a concurrent 429. The success handler re-applies a
4464
- // cooldown via maybeCoolFromQuota if the fresh quota headers report the
4465
- // window flipped to "rejected" on this very request.
4579
+ // cooldown via reconcileCooldownFromQuota when fresh quota headers
4580
+ // report a rejected window without explicit overage availability.
4466
4581
  if (accountState.coolingUntil &&
4467
4582
  Date.now() >= accountState.coolingUntil) {
4468
4583
  const expiredCooldown = accountState.coolingUntil;
@@ -4955,37 +5070,23 @@ function describeTransportError(error) {
4955
5070
  return detail ? `${message} (${detail})` : message;
4956
5071
  }
4957
5072
  /**
4958
- * Determine whether a thrown fetch error is a transient connectivity issue.
5073
+ * Determine whether a POST can be retried without risking duplicate provider
5074
+ * work. Only failures that prove connection establishment did not complete are
5075
+ * safe; a reset, socket error, or response timeout can happen after dispatch.
4959
5076
  */
4960
5077
  function isRetryableNetworkError(error) {
4961
5078
  const code = getErrorCode(error);
4962
- if (code &&
5079
+ return (code !== undefined &&
4963
5080
  [
4964
5081
  "ECONNREFUSED",
4965
- "ECONNRESET",
5082
+ "EADDRNOTAVAIL",
4966
5083
  // The Anthropic host is fixed, so ENOTFOUND can be a transient resolver
4967
5084
  // outage. Keep it inside the existing bounded same-account retry budget.
4968
5085
  "ENOTFOUND",
4969
- "ETIMEDOUT",
4970
5086
  "EHOSTUNREACH",
4971
5087
  "UND_ERR_CONNECT_TIMEOUT",
4972
5088
  "UND_ERR_CONNECT",
4973
- "UND_ERR_SOCKET",
4974
- "UND_ERR_HEADERS_TIMEOUT",
4975
- ].includes(code)) {
4976
- return true;
4977
- }
4978
- const message = error instanceof Error ? error.message : String(error);
4979
- const normalized = message.toLowerCase();
4980
- return (normalized.includes("econnrefused") ||
4981
- normalized.includes("econnreset") ||
4982
- normalized.includes("enotfound") ||
4983
- normalized.includes("etimedout") ||
4984
- normalized.includes("timed out") ||
4985
- normalized.includes("connection error") ||
4986
- normalized.includes("connect error") ||
4987
- normalized.includes("fetch failed") ||
4988
- normalized.includes("socket hang up"));
5089
+ ].includes(code));
4989
5090
  }
4990
5091
  const TRANSIENT_HTTP_STATUSES = new Set([
4991
5092
  408, 500, 502, 503, 504, 520, 521, 522, 523, 524, 525, 526, 529,
@@ -5114,6 +5215,8 @@ export const __testHooks = {
5114
5215
  resolveHomeIndex,
5115
5216
  maybeResetPrimaryToHome,
5116
5217
  planCooldownFor429,
5218
+ reconcileCooldownFromQuota,
5219
+ isRetryableNetworkError,
5117
5220
  isPermanentRefreshFailure,
5118
5221
  getStreamFailureDetails,
5119
5222
  trackUpstreamReadableStream,
@@ -445,6 +445,9 @@ export type ProxyAccountRoutingCandidate = {
445
445
  coolingReason: AccountCoolingReason | null;
446
446
  coolingUntil: number | null;
447
447
  unifiedStatus: string | null;
448
+ fallbackStatus?: string | null;
449
+ upgradePaths?: string | null;
450
+ overageEligible?: boolean;
448
451
  overageStatus: string | null;
449
452
  sessionStatus: string | null;
450
453
  sessionUsed: number | null;
@@ -480,6 +483,9 @@ export type ProxyAccountSortMetrics = {
480
483
  coolingReason: AccountCoolingReason | null;
481
484
  coolingUntil: number;
482
485
  unifiedStatus: string | null;
486
+ fallbackStatus?: string | null;
487
+ upgradePaths?: string | null;
488
+ overageEligible?: boolean;
483
489
  overageStatus: string | null;
484
490
  sessionStatus: string | null;
485
491
  sessionUsed: number | null;
@@ -737,6 +743,14 @@ export type AccountCooldownPlan = {
737
743
  * burst), a small number of jittered same-account retries is allowed first. */
738
744
  rotateImmediately: boolean;
739
745
  };
746
+ export type ProxyQuotaCooldownUpdate = {
747
+ kind: "cooled";
748
+ coolingUntil: number;
749
+ coolingReason: AccountCoolingReason;
750
+ } | {
751
+ kind: "cleared";
752
+ coolingUntil: number;
753
+ } | null;
740
754
  export type TransientRateLimitRetryBudget = {
741
755
  coolingUntil: number;
742
756
  retriesClaimed: number;
@@ -921,6 +935,10 @@ export type AccountQuota = {
921
935
  weeklyResetAt: number;
922
936
  /** 0.0-1.0 (from fallback-percentage) */
923
937
  fallbackPercentage: number;
938
+ /** Provider fallback availability, for example "available". */
939
+ fallbackStatus?: string;
940
+ /** Comma-separated provider upgrade paths, for example "overage". */
941
+ upgradePaths?: string;
924
942
  /** "allowed" | "rejected" */
925
943
  overageStatus: string;
926
944
  /** Epoch ms when we last captured this data */
@@ -12,6 +12,14 @@
12
12
  import type { AccountQuota } from "../types/index.js";
13
13
  /** Read and normalize Anthropic's authoritative top-level unified status. */
14
14
  export declare function getUnifiedRateLimitStatus(headers: Headers | Record<string, string>): string | undefined;
15
+ /**
16
+ * Whether Anthropic explicitly permits a request to use overage after a
17
+ * subscription window is exhausted. Fresh responses require all three
18
+ * provider signals. Older persisted snapshots predate the raw fallback and
19
+ * upgrade-path fields, but retain a positive fallback percentage together with
20
+ * an allowed overage status, which is the equivalent provider state.
21
+ */
22
+ export declare function isQuotaOverageAvailable(quota: Pick<AccountQuota, "fallbackPercentage" | "fallbackStatus" | "overageStatus" | "upgradePaths"> | null | undefined): boolean;
15
23
  /**
16
24
  * Parse Anthropic rate-limit / quota headers into an `AccountQuota`.
17
25
  * Returns `null` when key headers are absent.