@juspay/neurolink 12.24.1 → 12.25.1

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.
@@ -24,7 +24,8 @@ import { fetchCodexAccountUsage, listCodexAccountsForUsage, resolveProxyStatusAc
24
24
  import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
25
25
  import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoordinator.js";
26
26
  import { MAX_COOLDOWN_MS_BY_REASON } from "../../proxy/routingEvidence.js";
27
- import { compareExpiryFirst, rankAccounts, } from "../../proxy/accountRanking.js";
27
+ import { applyAffinityAndPrimary, applySpill, compareExpiryFirst, isPrecedenceEligible, rankAccounts, } from "../../proxy/accountRanking.js";
28
+ import { sessionAffinity } from "../../proxy/sessionAffinity.js";
28
29
  import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
29
30
  import { buildClaudeError, ClaudeStreamSerializer, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
30
31
  import { getProxyUpstreamFailure } from "../../proxy/proxyFailureDetails.js";
@@ -1506,17 +1507,24 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
1506
1507
  weeklyUsedForSort: weeklyUsed ?? -1,
1507
1508
  };
1508
1509
  }
1509
- function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel) {
1510
- const metricsByKey = new Map(accounts.map((account) => [
1510
+ function buildAccountSortMetricsByKey(accounts, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel) {
1511
+ return new Map(accounts.map((account) => [
1511
1512
  account.key,
1512
1513
  accountSortMetrics(account.key, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
1513
1514
  ]));
1514
- const { orderedAccounts } = rankAccounts({
1515
+ }
1516
+ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, options) {
1517
+ const policy = options?.policy;
1518
+ const metricsByKey = buildAccountSortMetricsByKey(accounts, now, sessionSoftLimit, sessionResetToleranceMs, options?.requestedModel);
1519
+ const { orderedAccounts, reason, affinitySkippedReason } = rankAccounts({
1515
1520
  accounts,
1516
1521
  metricsByKey,
1517
1522
  primaryKey,
1523
+ ranking: policy?.ranking,
1524
+ affinityKey: policy?.affinityKey,
1525
+ preferPrimary: policy?.preferPrimary,
1518
1526
  });
1519
- return { orderedAccounts, metricsByKey };
1527
+ return { orderedAccounts, metricsByKey, reason, affinitySkippedReason };
1520
1528
  }
1521
1529
  /**
1522
1530
  * Order accounts to MAXIMIZE quota utilization (fill-first, smart order):
@@ -1543,7 +1551,7 @@ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftL
1543
1551
  * last resort.
1544
1552
  */
1545
1553
  function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) {
1546
- return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel).orderedAccounts;
1554
+ return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, { requestedModel }).orderedAccounts;
1547
1555
  }
1548
1556
  function scheduleAdaptiveQuotaRefreshes(accounts, orderedAccounts, sessionSoftLimit, routingMetrics) {
1549
1557
  for (const account of accounts) {
@@ -1673,7 +1681,9 @@ function buildRoutingDecision(args) {
1673
1681
  }
1674
1682
  else if (quotaOrdered) {
1675
1683
  mode = "quota";
1676
- selectionReason = compareExpiryFirst(orderedAccounts[0], orderedAccounts[1], metricsByKey, primaryKey)[1];
1684
+ selectionReason =
1685
+ args.policySelectionReason ??
1686
+ compareExpiryFirst(orderedAccounts[0], orderedAccounts[1], metricsByKey, primaryKey)[1];
1677
1687
  }
1678
1688
  else if (strategy === "round-robin") {
1679
1689
  mode = "round_robin";
@@ -1682,9 +1692,10 @@ function buildRoutingDecision(args) {
1682
1692
  else {
1683
1693
  mode = "primary";
1684
1694
  selectionReason =
1685
- configuredPrimaryMatched && initialAccount?.key === primaryKey
1686
- ? "configured_primary"
1687
- : "insertion_order";
1695
+ args.policySelectionReason ??
1696
+ (configuredPrimaryMatched && initialAccount?.key === primaryKey
1697
+ ? "configured_primary"
1698
+ : "insertion_order");
1688
1699
  }
1689
1700
  return {
1690
1701
  schemaVersion: 1,
@@ -1701,10 +1712,54 @@ function buildRoutingDecision(args) {
1701
1712
  rotationOffset,
1702
1713
  initialAccount: initialAccount?.label ?? "",
1703
1714
  candidates,
1715
+ ...(args.policy ? { policy: args.policy } : {}),
1716
+ ...(args.affinity ? { affinity: args.affinity } : {}),
1717
+ ...(args.spill ? { spill: args.spill } : {}),
1704
1718
  };
1705
1719
  }
1720
+ // A policy error can repeat on every request, so each (site, error) pair is
1721
+ // logged once. Keyed per pair rather than once per worker, so a later,
1722
+ // unrelated failure still reaches the log. Insertion order is eviction order.
1723
+ const MAX_LOGGED_ROUTING_POLICY_ERRORS = 32;
1724
+ const loggedRoutingPolicyErrors = new Set();
1725
+ function logRoutingPolicyErrorOnce(error, fallbackOrder) {
1726
+ const message = error instanceof Error ? error.message : String(error);
1727
+ const signature = JSON.stringify([
1728
+ fallbackOrder,
1729
+ error instanceof Error ? error.name : typeof error,
1730
+ message,
1731
+ ]);
1732
+ if (loggedRoutingPolicyErrors.has(signature)) {
1733
+ return;
1734
+ }
1735
+ loggedRoutingPolicyErrors.add(signature);
1736
+ if (loggedRoutingPolicyErrors.size > MAX_LOGGED_ROUTING_POLICY_ERRORS) {
1737
+ const oldest = loggedRoutingPolicyErrors.values().next().value;
1738
+ if (oldest !== undefined) {
1739
+ loggedRoutingPolicyErrors.delete(oldest);
1740
+ }
1741
+ }
1742
+ logger.always(`[proxy] routing policy threw; falling back to ${fallbackOrder}: ${message}`);
1743
+ }
1744
+ // Test-only: makes maybeSpill() throw before its own guard, including on a
1745
+ // "session_affinity" first choice it would otherwise skip. That is the only
1746
+ // deterministic way to reach the policy catch after a genuine reorder, since
1747
+ // the policy steps return the input order unchanged when nothing applies.
1748
+ let forceSpillThrowForTests = false;
1749
+ // Test-only: make the selection-time affinity lookup, or the post-serve
1750
+ // binding re-check, throw. Each enters its own policy catch without reaching
1751
+ // into the binding store, so the store can stay frozen.
1752
+ let forceAffinityLookupThrowForTests = false;
1753
+ let forceBindRecheckThrowForTests = false;
1706
1754
  function selectClaudeProxyAccountOrder(args) {
1707
- const { enabledAccounts, accountStrategy, primaryAccountKey, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, requestedModel, setRoutingDecision, } = args;
1755
+ const { enabledAccounts, accountStrategy, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, requestedModel, sessionId, ranking, preferPrimary, sessionAffinityEnabled, sessionAffinityIdleTtlMs, spillInflight, setRoutingDecision, } = args;
1756
+ // The configured primary arrives normalized (lower-cased, prefixed), while
1757
+ // account keys keep the token store's spelling, and every primary check
1758
+ // below compares with ===. Resolve it to the loaded account's own key once.
1759
+ const configuredPrimaryKey = args.primaryAccountKey;
1760
+ const primaryAccountKey = configuredPrimaryKey === undefined
1761
+ ? undefined
1762
+ : (enabledAccounts.find((account) => anthropicAccountKeysEqual(account.key, configuredPrimaryKey))?.key ?? configuredPrimaryKey);
1708
1763
  let orderedAccounts = [...enabledAccounts];
1709
1764
  const evaluatedAt = Date.now();
1710
1765
  let metricsByKey;
@@ -1712,14 +1767,111 @@ function selectClaudeProxyAccountOrder(args) {
1712
1767
  const quotaOrdered = accountStrategy === "fill-first" &&
1713
1768
  orderedAccounts.length > 1 &&
1714
1769
  quotaRoutingEnabled;
1770
+ // strategy: round-robin ignores every routing policy key.
1771
+ const policiesApply = accountStrategy === "fill-first" && orderedAccounts.length > 1;
1772
+ const policy = policiesApply
1773
+ ? {
1774
+ ranking,
1775
+ preferPrimary,
1776
+ sessionAffinity: sessionAffinityEnabled,
1777
+ sessionAffinityIdleTtlMs,
1778
+ spillInflight,
1779
+ }
1780
+ : undefined;
1781
+ let policySelectionReason;
1782
+ let affinity;
1783
+ let spill;
1784
+ const resolveAffinityKey = () => {
1785
+ if (!sessionAffinityEnabled || !sessionId) {
1786
+ return undefined;
1787
+ }
1788
+ if (forceAffinityLookupThrowForTests) {
1789
+ throw new Error("forced failure for affinity lookup throw test");
1790
+ }
1791
+ return sessionAffinity.get(sessionId, evaluatedAt, sessionAffinityIdleTtlMs);
1792
+ };
1793
+ const buildAffinityEvidence = (boundAccountKey, applied, skippedFromPrecedence) => ({
1794
+ sessionBound: !!boundAccountKey,
1795
+ boundAccount: (boundAccountKey &&
1796
+ enabledAccounts.find((account) => account.key === boundAccountKey)
1797
+ ?.label) ??
1798
+ null,
1799
+ applied,
1800
+ skippedReason: !sessionAffinityEnabled
1801
+ ? "disabled"
1802
+ : !sessionId
1803
+ ? "no_session"
1804
+ : !boundAccountKey
1805
+ ? "expired"
1806
+ : applied
1807
+ ? null
1808
+ : skippedFromPrecedence,
1809
+ });
1810
+ const labelOf = (accountKey) => enabledAccounts.find((account) => account.key === accountKey)?.label ??
1811
+ accountKey;
1812
+ const maybeSpill = (order, reason, metrics) => {
1813
+ if (forceSpillThrowForTests) {
1814
+ throw new Error("forced failure for spill throw test");
1815
+ }
1816
+ // Spill never splits a bound session, but a preferred-primary first
1817
+ // choice has no binding and can still be relieved.
1818
+ if (spillInflight <= 0 || reason === "session_affinity") {
1819
+ return undefined;
1820
+ }
1821
+ const inflightByKey = new Map(order.map((account) => [account.key, getAccountInflight(account.key)]));
1822
+ const spillResult = applySpill({
1823
+ orderedAccounts: order,
1824
+ inflightByKey,
1825
+ metricsByKey: metrics,
1826
+ spillInflight,
1827
+ });
1828
+ const move = spillResult.spill;
1829
+ if (!move) {
1830
+ return undefined;
1831
+ }
1832
+ return {
1833
+ orderedAccounts: spillResult.orderedAccounts,
1834
+ spill: {
1835
+ from: labelOf(move.from),
1836
+ to: labelOf(move.to),
1837
+ inflight: move.inflight,
1838
+ },
1839
+ };
1840
+ };
1715
1841
  if (!quotaOrdered && accountStrategy === "fill-first") {
1716
1842
  // A hot-reloaded primary change must apply to this request.
1717
1843
  maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
1718
1844
  }
1719
1845
  if (quotaOrdered) {
1720
- const quotaOrder = orderAccountsByQuotaWithMetrics(enabledAccounts, evaluatedAt, primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
1721
- orderedAccounts = quotaOrder.orderedAccounts;
1722
- metricsByKey = quotaOrder.metricsByKey;
1846
+ // Metrics are not a policy step: a throw here propagates, as it did
1847
+ // before the policies existed, instead of reading as a policy failure.
1848
+ metricsByKey = buildAccountSortMetricsByKey(enabledAccounts, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
1849
+ try {
1850
+ const boundAccountKey = resolveAffinityKey();
1851
+ const quotaOrder = rankAccounts({
1852
+ accounts: enabledAccounts,
1853
+ metricsByKey,
1854
+ primaryKey: primaryAccountKey,
1855
+ ranking,
1856
+ affinityKey: boundAccountKey,
1857
+ preferPrimary,
1858
+ });
1859
+ const spilled = maybeSpill(quotaOrder.orderedAccounts, quotaOrder.reason, metricsByKey);
1860
+ const affinityEvidence = buildAffinityEvidence(boundAccountKey, quotaOrder.reason === "session_affinity", quotaOrder.affinitySkippedReason);
1861
+ orderedAccounts = spilled?.orderedAccounts ?? quotaOrder.orderedAccounts;
1862
+ policySelectionReason = spilled ? "spill_inflight" : quotaOrder.reason;
1863
+ affinity = affinityEvidence;
1864
+ spill = spilled?.spill;
1865
+ }
1866
+ catch (error) {
1867
+ logRoutingPolicyErrorOnce(error, "expiry-first order");
1868
+ orderedAccounts = rankAccounts({
1869
+ accounts: enabledAccounts,
1870
+ metricsByKey,
1871
+ primaryKey: primaryAccountKey,
1872
+ }).orderedAccounts;
1873
+ policySelectionReason = "routing_policy_error";
1874
+ }
1723
1875
  if (logger.shouldLog("debug")) {
1724
1876
  logger.debug(`[proxy] quota-ordered fill sequence: ${orderedAccounts
1725
1877
  .map((account) => account.label)
@@ -1743,10 +1895,36 @@ function selectClaudeProxyAccountOrder(args) {
1743
1895
  orderedAccounts.push(...head);
1744
1896
  }
1745
1897
  }
1746
- metricsByKey = new Map(enabledAccounts.map((account) => [
1747
- account.key,
1748
- accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
1749
- ]));
1898
+ metricsByKey = buildAccountSortMetricsByKey(enabledAccounts, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
1899
+ // Quota routing off: the rotation above stays the base order, and
1900
+ // affinity/prefer-primary/spill still apply on top of it. Nothing is
1901
+ // assigned until every policy step has succeeded, so a throw leaves the
1902
+ // pre-policy order in place.
1903
+ if (policiesApply) {
1904
+ try {
1905
+ const boundAccountKey = resolveAffinityKey();
1906
+ const precedence = applyAffinityAndPrimary({
1907
+ orderedAccounts,
1908
+ metricsByKey,
1909
+ affinityKey: boundAccountKey,
1910
+ primaryKey: primaryAccountKey,
1911
+ preferPrimary,
1912
+ });
1913
+ const spilled = maybeSpill(precedence.orderedAccounts, precedence.reason, metricsByKey);
1914
+ const affinityEvidence = buildAffinityEvidence(boundAccountKey, precedence.reason === "session_affinity", precedence.affinitySkippedReason);
1915
+ orderedAccounts =
1916
+ spilled?.orderedAccounts ?? precedence.orderedAccounts;
1917
+ policySelectionReason = spilled
1918
+ ? "spill_inflight"
1919
+ : (precedence.reason ?? undefined);
1920
+ affinity = affinityEvidence;
1921
+ spill = spilled?.spill;
1922
+ }
1923
+ catch (error) {
1924
+ logRoutingPolicyErrorOnce(error, "pre-policy order");
1925
+ policySelectionReason = "routing_policy_error";
1926
+ }
1927
+ }
1750
1928
  }
1751
1929
  const routingDecision = buildRoutingDecision({
1752
1930
  accounts: enabledAccounts,
@@ -1760,12 +1938,50 @@ function selectClaudeProxyAccountOrder(args) {
1760
1938
  sessionSoftLimit,
1761
1939
  sessionResetToleranceMs,
1762
1940
  rotationOffset,
1941
+ policySelectionReason,
1942
+ policy,
1943
+ affinity,
1944
+ spill,
1763
1945
  });
1764
1946
  if (routingDecision) {
1765
1947
  setRoutingDecision(routingDecision);
1766
1948
  }
1767
1949
  return { orderedAccounts, metricsByKey };
1768
1950
  }
1951
+ /**
1952
+ * Records that a session's request was served. A session keeps its current
1953
+ * binding only when this request never tried the bound account and that
1954
+ * account is still eligible: a request that overflowed its bound account's
1955
+ * admission cap onto another account must not move the session off its warm
1956
+ * prompt cache. Otherwise the serving account becomes the binding. Tried
1957
+ * outranks eligible because an account that failed this request can look
1958
+ * eligible again by now: a short 429 cooldown can lapse while the next
1959
+ * account is still answering.
1960
+ */
1961
+ function bindSessionAfterServe(args) {
1962
+ const now = Date.now();
1963
+ // This runs after the response has already been served (and, on the
1964
+ // stream path, after admission-lease ownership has transferred to the
1965
+ // stream): a throw here must never drop that response or leak the lease,
1966
+ // so eligibility re-checks fall back to binding the serving account.
1967
+ let accountKey = args.servingAccountKey;
1968
+ try {
1969
+ if (forceBindRecheckThrowForTests) {
1970
+ throw new Error("forced failure for bind recheck throw test");
1971
+ }
1972
+ const boundKey = sessionAffinity.get(args.sessionId, now, args.idleTtlMs);
1973
+ if (boundKey !== undefined &&
1974
+ !args.attemptedAccountKeys.has(boundKey) &&
1975
+ args.enabledAccounts.some((account) => account.key === boundKey) &&
1976
+ isPrecedenceEligible(accountSortMetrics(boundKey, now, args.sessionSoftLimit, args.sessionResetToleranceMs, args.requestedModel))) {
1977
+ accountKey = boundKey;
1978
+ }
1979
+ }
1980
+ catch (error) {
1981
+ logRoutingPolicyErrorOnce(error, "serving-account binding");
1982
+ }
1983
+ sessionAffinity.bind(args.sessionId, accountKey, now);
1984
+ }
1769
1985
  // ---------------------------------------------------------------------------
1770
1986
  // OAuth polyfill helpers (extracted to reduce block nesting)
1771
1987
  // ---------------------------------------------------------------------------
@@ -3254,7 +3470,7 @@ function earliestShareRecoverySeconds(views, now) {
3254
3470
  return Math.max(1, Math.round((Math.min(...resets) - now) / 1000));
3255
3471
  }
3256
3472
  async function loadClaudeProxyAccounts(args) {
3257
- const { ctx, body, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), setRoutingDecision, } = args;
3473
+ const { ctx, body, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), ranking = "expiry-first", preferPrimary = false, sessionAffinityEnabled = false, sessionAffinityIdleTtlMs = 3_600_000, spillInflight = 0, setRoutingDecision, } = args;
3258
3474
  const fs = await import("fs");
3259
3475
  const os = await import("os");
3260
3476
  const accounts = [];
@@ -3510,6 +3726,13 @@ async function loadClaudeProxyAccounts(args) {
3510
3726
  },
3511
3727
  };
3512
3728
  }
3729
+ // Resolved ahead of account selection (rather than after, as before) so
3730
+ // sessionId is available to selectClaudeProxyAccountOrder for session
3731
+ // affinity. normalizeClaudeRequestForAnthropic below returns a
3732
+ // shallow clone and never mutates `body`, so extracting earlier sees
3733
+ // exactly the same snapshot extractSnapshotBody always saw.
3734
+ const clientHeaders = ctx.headers ?? {};
3735
+ const clientSnapshotBody = extractSnapshotBody(body);
3513
3736
  const { orderedAccounts, metricsByKey } = selectClaudeProxyAccountOrder({
3514
3737
  enabledAccounts,
3515
3738
  accountStrategy,
@@ -3518,6 +3741,12 @@ async function loadClaudeProxyAccounts(args) {
3518
3741
  sessionSoftLimit,
3519
3742
  sessionResetToleranceMs,
3520
3743
  requestedModel: typeof body.model === "string" ? body.model : undefined,
3744
+ sessionId: clientSnapshotBody?.sessionId,
3745
+ ranking,
3746
+ preferPrimary,
3747
+ sessionAffinityEnabled,
3748
+ sessionAffinityIdleTtlMs,
3749
+ spillInflight,
3521
3750
  setRoutingDecision,
3522
3751
  });
3523
3752
  if (accountStrategy === "fill-first" &&
@@ -3530,8 +3759,6 @@ async function loadClaudeProxyAccounts(args) {
3530
3759
  const requestStart = Date.now();
3531
3760
  const toolCount = Array.isArray(body.tools) ? body.tools.length : 0;
3532
3761
  const url = "https://api.anthropic.com/v1/messages?beta=true";
3533
- const clientHeaders = ctx.headers ?? {};
3534
- const clientSnapshotBody = extractSnapshotBody(body);
3535
3762
  return {
3536
3763
  accounts,
3537
3764
  enabledAccounts,
@@ -3542,6 +3769,7 @@ async function loadClaudeProxyAccounts(args) {
3542
3769
  url,
3543
3770
  clientHeaders,
3544
3771
  isClaudeClientRequest: isLikelyClaudeClient(clientHeaders, clientSnapshotBody),
3772
+ sessionId: clientSnapshotBody?.sessionId,
3545
3773
  };
3546
3774
  }
3547
3775
  /**
@@ -4658,7 +4886,7 @@ async function handleAnthropicStreamingSuccessResponse(args) {
4658
4886
  responseStatus: 502,
4659
4887
  durationMs: Date.now() - requestStartTime,
4660
4888
  });
4661
- return { response: clientError };
4889
+ return { response: clientError, served: false };
4662
4890
  }
4663
4891
  const reader = response.body.getReader();
4664
4892
  const preflight = await preflightAnthropicStream(reader);
@@ -4695,6 +4923,7 @@ async function handleAnthropicStreamingSuccessResponse(args) {
4695
4923
  errorType: "stream_error",
4696
4924
  message,
4697
4925
  }),
4926
+ served: false,
4698
4927
  };
4699
4928
  }
4700
4929
  if (preflight.kind === "empty") {
@@ -4858,7 +5087,7 @@ async function handleAnthropicStreamingSuccessResponse(args) {
4858
5087
  logFinalRequest,
4859
5088
  });
4860
5089
  void telemetryDone.then(() => onStreamTerminal?.(), () => onStreamTerminal?.());
4861
- return { response: result, holdsAccountAdmission: true };
5090
+ return { response: result, holdsAccountAdmission: true, served: true };
4862
5091
  }
4863
5092
  function getStreamFailureDetails(outcome) {
4864
5093
  if (outcome.kind === "upstream_error") {
@@ -5235,7 +5464,7 @@ async function handleAnthropicJsonSuccessResponse(args) {
5235
5464
  cacheReadTokens: noTracerUsage?.cache_read_input_tokens,
5236
5465
  });
5237
5466
  }
5238
- return { response: responseJson };
5467
+ return { response: responseJson, served: true };
5239
5468
  }
5240
5469
  async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
5241
5470
  const { ctx, account, accountState, requestedModel, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
@@ -5330,7 +5559,7 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
5330
5559
  return retryJson;
5331
5560
  }
5332
5561
  async function handleAnthropicAuthRetry(args) {
5333
- const { ctx, body, account, accountState, headers, buildUpstreamBody, url, enabledAccounts, orderedAccounts, tracer, requestStartTime, allocateAttemptNumber, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, lastError, authFailureMessage, entitlementFailure, sawRateLimit, sawTransientFailure, sawNetworkError, } = args;
5562
+ const { ctx, body, account, accountState, headers, buildUpstreamBody, url, enabledAccounts, orderedAccounts, tracer, requestStartTime, allocateAttemptNumber, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, lastError, authFailureMessage, entitlementFailure, sawRateLimit, sawTransientFailure, sawNetworkError, bindServedSession, } = args;
5334
5563
  recordAttemptError(account.label, account.type, 401);
5335
5564
  logAttempt(401, "authentication_error", "received 401 from Anthropic", {
5336
5565
  retryable: true,
@@ -5432,6 +5661,10 @@ async function handleAnthropicAuthRetry(args) {
5432
5661
  logProxyBody,
5433
5662
  logFinalRequest,
5434
5663
  }),
5664
+ // handleAnthropicSuccessfulNonStreamRetryResponse is only ever
5665
+ // called when retryResp.ok — unlike the streaming path, it has
5666
+ // no terminal-error return sharing this shape.
5667
+ served: true,
5435
5668
  };
5436
5669
  if ("retryNextAccount" in successResult) {
5437
5670
  const failure = successResult.failure;
@@ -5450,6 +5683,9 @@ async function handleAnthropicAuthRetry(args) {
5450
5683
  upstreamSpan: undefined,
5451
5684
  };
5452
5685
  }
5686
+ if (bindServedSession && successResult.served) {
5687
+ bindServedSession(account.key);
5688
+ }
5453
5689
  return {
5454
5690
  response: successResult.response,
5455
5691
  holdsAccountAdmission: successResult.holdsAccountAdmission,
@@ -6750,7 +6986,7 @@ function buildDeferredClaudeAccountFailureResponse(args) {
6750
6986
  return buildLoggedClaudeError(failure.status, failure.message, failure.errorType);
6751
6987
  }
6752
6988
  async function handleAnthropicRoutedClaudeRequest(args) {
6753
- const { ctx, body, modelRouter, configGeneration, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), buildLoggedClaudeError, logProxyBody, logFinalRequest, setRoutingDecision, } = args;
6989
+ const { ctx, body, modelRouter, configGeneration, tracer, requestStartTime, accountStrategy, primaryAccountKey, accountAllowlist, quotaRoutingEnabled = isQuotaRoutingEnabled(), sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), ranking = "expiry-first", preferPrimary = false, sessionAffinityEnabled = false, sessionAffinityIdleTtlMs = 3_600_000, spillInflight = 0, buildLoggedClaudeError, logProxyBody, logFinalRequest, setRoutingDecision, } = args;
6754
6990
  const parsedRequest = parseClaudeRequest(body);
6755
6991
  const configuredFallbackPlan = buildProxyTranslationPlan({ provider: "anthropic", model: body.model }, modelRouter?.getFallbackChain() ?? [], body.model, parsedRequest);
6756
6992
  const hasConfiguredFallback = configuredFallbackPlan.attempts
@@ -6765,6 +7001,11 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6765
7001
  quotaRoutingEnabled,
6766
7002
  sessionSoftLimit,
6767
7003
  sessionResetToleranceMs,
7004
+ ranking,
7005
+ preferPrimary,
7006
+ sessionAffinityEnabled,
7007
+ sessionAffinityIdleTtlMs,
7008
+ spillInflight,
6768
7009
  setRoutingDecision,
6769
7010
  });
6770
7011
  if ("failure" in loadedAccounts) {
@@ -6826,7 +7067,27 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6826
7067
  buildLoggedClaudeError,
6827
7068
  });
6828
7069
  }
6829
- const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, } = loadedAccounts;
7070
+ const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, sessionId, } = loadedAccounts;
7071
+ // Accounts this request sent an upstream attempt to; one skipped only for
7072
+ // its admission cap is not among them.
7073
+ const attemptedAccountKeys = new Set();
7074
+ // Only fill-first over more than one account reads a binding (see
7075
+ // selectClaudeProxyAccountOrder), so nothing else records one.
7076
+ const bindServedSession = sessionAffinityEnabled &&
7077
+ sessionId &&
7078
+ accountStrategy === "fill-first" &&
7079
+ enabledAccounts.length > 1
7080
+ ? (servingAccountKey) => bindSessionAfterServe({
7081
+ sessionId,
7082
+ servingAccountKey,
7083
+ attemptedAccountKeys,
7084
+ enabledAccounts,
7085
+ idleTtlMs: sessionAffinityIdleTtlMs,
7086
+ sessionSoftLimit,
7087
+ sessionResetToleranceMs,
7088
+ requestedModel: typeof body.model === "string" ? body.model : undefined,
7089
+ })
7090
+ : undefined;
6830
7091
  // Snapshot the operator policy once. Reading the module value later would let
6831
7092
  // a concurrent /limits call or a hot config reload change this request's
6832
7093
  // answer partway through its own account loop.
@@ -6999,6 +7260,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
6999
7260
  }
7000
7261
  continue accountLoop;
7001
7262
  }
7263
+ attemptedAccountKeys.add(account.key);
7002
7264
  let admissionTransferredToStream = false;
7003
7265
  try {
7004
7266
  let fetchResult;
@@ -7164,6 +7426,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
7164
7426
  sawRateLimit: loopState.sawRateLimit,
7165
7427
  sawTransientFailure: loopState.sawTransientFailure,
7166
7428
  sawNetworkError: loopState.sawNetworkError,
7429
+ bindServedSession,
7167
7430
  });
7168
7431
  loopState.lastError = authRetryResult.lastError;
7169
7432
  loopState.authFailureMessage = authRetryResult.authFailureMessage;
@@ -7281,6 +7544,9 @@ async function handleAnthropicRoutedClaudeRequest(args) {
7281
7544
  }
7282
7545
  admissionTransferredToStream =
7283
7546
  successResult.holdsAccountAdmission === true;
7547
+ if (bindServedSession && successResult.served) {
7548
+ bindServedSession(account.key);
7549
+ }
7284
7550
  return successResult.response;
7285
7551
  }
7286
7552
  finally {
@@ -7539,6 +7805,11 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
7539
7805
  sessionSoftLimit: getSessionSoftLimit(),
7540
7806
  sessionResetToleranceMs: getSessionResetToleranceMs(),
7541
7807
  useOverage: "auto",
7808
+ accountRanking: "expiry-first",
7809
+ preferPrimary: false,
7810
+ sessionAffinity: false,
7811
+ sessionAffinityIdleTtlMs: 3_600_000,
7812
+ spillInflight: 0,
7542
7813
  };
7543
7814
  setOveragePolicy(requestRouting.useOverage);
7544
7815
  const requestModelRouter = requestRouting.modelRouter;
@@ -7612,6 +7883,11 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
7612
7883
  quotaRoutingEnabled: requestRouting.quotaRoutingEnabled,
7613
7884
  sessionSoftLimit: requestRouting.sessionSoftLimit,
7614
7885
  sessionResetToleranceMs: requestRouting.sessionResetToleranceMs,
7886
+ ranking: requestRouting.accountRanking,
7887
+ preferPrimary: requestRouting.preferPrimary,
7888
+ sessionAffinityEnabled: requestRouting.sessionAffinity,
7889
+ sessionAffinityIdleTtlMs: requestRouting.sessionAffinityIdleTtlMs,
7890
+ spillInflight: requestRouting.spillInflight,
7615
7891
  buildLoggedClaudeError,
7616
7892
  logProxyBody,
7617
7893
  logFinalRequest,
@@ -8822,8 +9098,8 @@ export const __testHooks = {
8822
9098
  scheduleAdaptiveQuotaRefreshes,
8823
9099
  scheduleHandoffQuotaRefresh,
8824
9100
  getQuotaRefreshState: (key) => accountQuotaRefreshCoordinator.getState(key),
8825
- buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) => {
8826
- const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
9101
+ buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), options) => {
9102
+ const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, options);
8827
9103
  return buildRoutingDecision({
8828
9104
  accounts,
8829
9105
  orderedAccounts: order.orderedAccounts,
@@ -8836,6 +9112,7 @@ export const __testHooks = {
8836
9112
  sessionSoftLimit,
8837
9113
  sessionResetToleranceMs,
8838
9114
  rotationOffset: 0,
9115
+ policySelectionReason: order.reason,
8839
9116
  });
8840
9117
  },
8841
9118
  buildRoutingDecision,
@@ -8862,10 +9139,28 @@ export const __testHooks = {
8862
9139
  accountAdmissionStates.clear();
8863
9140
  accountQuotaRefreshCoordinator.clear();
8864
9141
  providerTransportCoordinator.clear();
9142
+ loggedRoutingPolicyErrors.clear();
9143
+ forceSpillThrowForTests = false;
9144
+ forceAffinityLookupThrowForTests = false;
9145
+ forceBindRecheckThrowForTests = false;
8865
9146
  primaryAccountIndex = 0;
8866
9147
  lastKnownAccountCount = 0;
8867
9148
  },
8868
9149
  polyfillOAuthBody: (bodyStr, isClaudeClientRequest) => polyfillOAuthBody(bodyStr, "test-account-token", null, isClaudeClientRequest),
9150
+ // Pins the sessionId path loadClaudeProxyAccounts resolves ahead of
9151
+ // account selection and feeds into selectClaudeProxyAccountOrder for
9152
+ // session affinity.
9153
+ extractSnapshotBodySessionIdForTests: (body) => extractSnapshotBody(body)?.sessionId,
9154
+ selectClaudeProxyAccountOrderForTests: selectClaudeProxyAccountOrder,
9155
+ setForceSpillThrowForTests: (value) => {
9156
+ forceSpillThrowForTests = value;
9157
+ },
9158
+ setForceAffinityLookupThrowForTests: (value) => {
9159
+ forceAffinityLookupThrowForTests = value;
9160
+ },
9161
+ setForceBindRecheckThrowForTests: (value) => {
9162
+ forceBindRecheckThrowForTests = value;
9163
+ },
8869
9164
  isAntiAbuseConstruction429,
8870
9165
  fetchAnthropicAccountResponse,
8871
9166
  finalizeAnthropicTerminalFetchError,
@@ -534,6 +534,9 @@ export type ProxyAccountRoutingDecision = {
534
534
  rotationOffset: number;
535
535
  initialAccount: string;
536
536
  candidates: ProxyAccountRoutingCandidate[];
537
+ policy?: ProxyRoutingPolicySnapshot;
538
+ affinity?: ProxyAccountRoutingAffinityEvidence;
539
+ spill?: ProxyAccountRoutingSpillEvidence;
537
540
  };
538
541
  export type ProxyAccountSortMetrics = {
539
542
  usable: boolean;
@@ -578,6 +581,36 @@ export type ProxyAccountSortMetrics = {
578
581
  scopedUsedForSort: number;
579
582
  scopedSaturated: boolean;
580
583
  };
584
+ export type ProxyAccountRankingPolicy = "expiry-first" | "headroom-first";
585
+ export type ProxyAccountRoutingAffinitySkipReason = "unusable" | "session_saturated" | "expired" | "no_session" | "disabled";
586
+ /** The five routing.* values in effect for one request, as reported on the decision. */
587
+ export type ProxyRoutingPolicySnapshot = {
588
+ ranking: ProxyAccountRankingPolicy;
589
+ preferPrimary: boolean;
590
+ sessionAffinity: boolean;
591
+ sessionAffinityIdleTtlMs: number;
592
+ spillInflight: number;
593
+ };
594
+ export type ProxyAccountRoutingAffinityEvidence = {
595
+ sessionBound: boolean;
596
+ boundAccount: string | null;
597
+ applied: boolean;
598
+ skippedReason: ProxyAccountRoutingAffinitySkipReason | null;
599
+ };
600
+ export type ProxyAccountRoutingSpillEvidence = {
601
+ from: string;
602
+ to: string;
603
+ inflight: number;
604
+ };
605
+ /** Why the precedence step skipped a bound account it could see. */
606
+ export type ProxyAffinityPrecedenceSkipReason = Extract<ProxyAccountRoutingAffinitySkipReason, "unusable" | "session_saturated">;
607
+ /** The reasons a precedence step (affinity, then prefer-primary) places rank 0. */
608
+ export type ProxyAccountPrecedenceReason = Extract<ProxyAccountRoutingReason, "session_affinity" | "preferred_primary">;
609
+ /**
610
+ * A spill decision in account keys; the route reports it with account labels
611
+ * as ProxyAccountRoutingSpillEvidence.
612
+ */
613
+ export type ProxyAccountSpillMove = ProxyAccountRoutingSpillEvidence;
581
614
  export type RequestLogEntry = {
582
615
  /** Whether model identifies observed serving output rather than a routing target. */
583
616
  servingModelStatus?: "observed" | "unavailable";
@@ -1075,6 +1108,7 @@ export type LoadedClaudeAccountContext = {
1075
1108
  url: string;
1076
1109
  clientHeaders: Record<string, string | undefined>;
1077
1110
  isClaudeClientRequest: boolean;
1111
+ sessionId?: string;
1078
1112
  };
1079
1113
  export type AnthropicSuccessResult = {
1080
1114
  retryNextAccount: true;
@@ -1086,6 +1120,15 @@ export type AnthropicSuccessResult = {
1086
1120
  } | {
1087
1121
  response: Response | unknown;
1088
1122
  holdsAccountAdmission?: boolean;
1123
+ /**
1124
+ * True only when an Anthropic account genuinely produced this response.
1125
+ * False for a same-shaped terminal error synthesized locally (e.g. no
1126
+ * upstream body, or the stream failing before its first chunk) — these
1127
+ * reuse the `{ response }` shape because the client still gets a
1128
+ * response, but no account served anything, so session affinity must
1129
+ * not bind on them.
1130
+ */
1131
+ served: boolean;
1089
1132
  };
1090
1133
  /** A release handle for one in-flight request admitted to an OAuth account. */
1091
1134
  export type AccountAdmissionLease = {
@@ -3119,6 +3162,11 @@ export type ProxyRequestRoutingSnapshot = {
3119
3162
  /** Operator policy on spending paid extra usage once a subscription window is
3120
3163
  * spent. Only "never" can override the provider's own signal. */
3121
3164
  useOverage: ProxyOveragePolicy;
3165
+ accountRanking: ProxyAccountRankingPolicy;
3166
+ preferPrimary: boolean;
3167
+ sessionAffinity: boolean;
3168
+ sessionAffinityIdleTtlMs: number;
3169
+ spillInflight: number;
3122
3170
  };
3123
3171
  /** Operator policy for paid extra usage. */
3124
3172
  export type ProxyOveragePolicy = "auto" | "always" | "never";