@juspay/neurolink 11.2.0 → 11.2.2

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/auth/codexOAuth.d.ts +67 -0
  3. package/dist/auth/codexOAuth.js +202 -0
  4. package/dist/auth/index.d.ts +1 -0
  5. package/dist/auth/index.js +4 -0
  6. package/dist/browser/neurolink.min.js +419 -419
  7. package/dist/cli/commands/auth.d.ts +27 -8
  8. package/dist/cli/commands/auth.js +425 -6
  9. package/dist/cli/commands/proxy.js +230 -5
  10. package/dist/cli/factories/authCommandFactory.d.ts +8 -0
  11. package/dist/cli/factories/authCommandFactory.js +74 -1
  12. package/dist/lib/auth/codexOAuth.d.ts +67 -0
  13. package/dist/lib/auth/codexOAuth.js +203 -0
  14. package/dist/lib/auth/index.d.ts +1 -0
  15. package/dist/lib/auth/index.js +4 -0
  16. package/dist/lib/providers/openaiChatCompletionsBase.js +26 -14
  17. package/dist/lib/proxy/accountCooldown.js +35 -2
  18. package/dist/lib/proxy/accountQuota.d.ts +29 -3
  19. package/dist/lib/proxy/accountQuota.js +203 -12
  20. package/dist/lib/proxy/accountUsage.js +15 -2
  21. package/dist/lib/proxy/codexAccountUsage.d.ts +26 -0
  22. package/dist/lib/proxy/codexAccountUsage.js +174 -0
  23. package/dist/lib/proxy/proxyAnalysis.js +12 -1
  24. package/dist/lib/proxy/proxyConfig.js +24 -0
  25. package/dist/lib/proxy/routingEvidence.d.ts +12 -1
  26. package/dist/lib/proxy/routingEvidence.js +23 -0
  27. package/dist/lib/proxy/runtimeConfig.js +3 -0
  28. package/dist/lib/server/routes/claudeProxyRoutes.d.ts +79 -5
  29. package/dist/lib/server/routes/claudeProxyRoutes.js +653 -72
  30. package/dist/lib/server/routes/codexProxyRoutes.d.ts +64 -0
  31. package/dist/lib/server/routes/codexProxyRoutes.js +454 -0
  32. package/dist/lib/types/cli.d.ts +7 -1
  33. package/dist/lib/types/codex.d.ts +95 -0
  34. package/dist/lib/types/codex.js +15 -0
  35. package/dist/lib/types/index.d.ts +1 -0
  36. package/dist/lib/types/index.js +1 -0
  37. package/dist/lib/types/proxy.d.ts +83 -0
  38. package/dist/lib/types/subscription.d.ts +13 -0
  39. package/dist/providers/openaiChatCompletionsBase.js +26 -14
  40. package/dist/proxy/accountCooldown.js +35 -2
  41. package/dist/proxy/accountQuota.d.ts +29 -3
  42. package/dist/proxy/accountQuota.js +203 -12
  43. package/dist/proxy/accountUsage.js +15 -2
  44. package/dist/proxy/codexAccountUsage.d.ts +26 -0
  45. package/dist/proxy/codexAccountUsage.js +173 -0
  46. package/dist/proxy/proxyAnalysis.js +12 -1
  47. package/dist/proxy/proxyConfig.js +24 -0
  48. package/dist/proxy/routingEvidence.d.ts +12 -1
  49. package/dist/proxy/routingEvidence.js +23 -0
  50. package/dist/proxy/runtimeConfig.js +3 -0
  51. package/dist/server/routes/claudeProxyRoutes.d.ts +79 -5
  52. package/dist/server/routes/claudeProxyRoutes.js +653 -72
  53. package/dist/server/routes/codexProxyRoutes.d.ts +64 -0
  54. package/dist/server/routes/codexProxyRoutes.js +453 -0
  55. package/dist/types/cli.d.ts +7 -1
  56. package/dist/types/codex.d.ts +95 -0
  57. package/dist/types/codex.js +14 -0
  58. package/dist/types/index.d.ts +1 -0
  59. package/dist/types/index.js +1 -0
  60. package/dist/types/proxy.d.ts +83 -0
  61. package/dist/types/subscription.d.ts +13 -0
  62. package/package.json +3 -1
@@ -16,10 +16,11 @@ import { Agent } from "undici";
16
16
  import { buildStableClaudeCodeBillingHeader, CLAUDE_CLI_USER_AGENT, CLAUDE_CODE_OAUTH_BETAS, getOrCreateClaudeCodeIdentity, parseClaudeCodeUserId, } from "../../auth/anthropicOAuth.js";
17
17
  import { clearAccountCooldown, loadAccountCooldowns, saveAccountCooldown, } from "../../proxy/accountCooldown.js";
18
18
  import { anthropicAccountKeysEqual, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
19
- import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
19
+ import { getUnifiedRateLimitStatus, isQuotaOverageAvailable, loadAccountQuotas, mergeQuotaSnapshot, modelFamilyToken, parseQuotaHeaders, saveAccountQuota, } from "../../proxy/accountQuota.js";
20
20
  import { fetchAccountUsage, listAnthropicAccountsForUsage, usageToQuota, } from "../../proxy/accountUsage.js";
21
21
  import { AccountQuotaRefreshCoordinator } from "../../proxy/accountQuotaRefreshCoordinator.js";
22
22
  import { ProviderTransportCoordinator } from "../../proxy/providerTransportCoordinator.js";
23
+ import { MAX_COOLDOWN_MS_BY_REASON } from "../../proxy/routingEvidence.js";
23
24
  import { buildProxyLimitHeaders, summarizePoolHeadroom, } from "../../proxy/quotaHeaders.js";
24
25
  import { buildClaudeError, ClaudeStreamSerializer, generateToolUseId, parseClaudeRequest, serializeClaudeResponse, } from "../../proxy/claudeFormat.js";
25
26
  import { buildAnthropicModelsListResponse, buildTranslationOptions, extractText, extractToolArgs, extractUsageFromStreamResult, handleTranslatedJsonRequest, handleTranslatedStreamRequest, hasTranslatedOutput, } from "../../proxy/proxyTranslationEngine.js";
@@ -487,9 +488,15 @@ function publishLimitHeaders(ctx, args) {
487
488
  logger.debug(`[proxy] failed to publish limit headers: ${error instanceof Error ? error.message : String(error)}`);
488
489
  }
489
490
  }
490
- /** Clamp a cooldown target epoch-ms into [now+MIN, now+MAX]. */
491
- function clampCooldownUntil(untilMs, now) {
492
- return Math.min(Math.max(untilMs, now + MIN_COOLDOWN_MS), now + MAX_COOLDOWN_MS);
491
+ /**
492
+ * Clamp a cooldown target epoch-ms into [now+MIN, now+MAX], additionally capped
493
+ * by what the reason can plausibly mean — a "session" cooldown describes a
494
+ * 5-hour window, so a stale or malformed reset must not be able to park the
495
+ * account for days under that label.
496
+ */
497
+ function clampCooldownUntil(untilMs, now, reason) {
498
+ const ceiling = Math.min(MAX_COOLDOWN_MS, (reason && MAX_COOLDOWN_MS_BY_REASON[reason]) ?? MAX_COOLDOWN_MS);
499
+ return Math.min(Math.max(untilMs, now + MIN_COOLDOWN_MS), now + ceiling);
493
500
  }
494
501
  /**
495
502
  * Decide how to cool an account after a genuine (non-anti-abuse) 429.
@@ -505,24 +512,24 @@ function clampCooldownUntil(untilMs, now) {
505
512
  * burst / acceleration limit) is transient: honor retry-after as a floor,
506
513
  * allow a couple of jittered same-account retries, then a short cooldown.
507
514
  */
508
- function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus) {
515
+ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.unifiedStatus, policy = overagePolicy) {
509
516
  // Weekly exhaustion takes precedence — it's the longest, hardest ceiling.
510
517
  if (quota && quota.weeklyStatus === "rejected") {
511
518
  const reset = resetEpochToMs(quota.weeklyResetAt, now) ??
512
519
  (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
513
520
  return {
514
521
  reason: "weekly",
515
- coolingUntil: clampCooldownUntil(reset, now),
522
+ coolingUntil: clampCooldownUntil(reset, now, "weekly"),
516
523
  rotateImmediately: true,
517
524
  };
518
525
  }
519
- const overageAvailable = isQuotaOverageAvailable(quota);
526
+ const overageAvailable = isOverageUsable(quota, policy);
520
527
  if (quota && quota.sessionStatus === "rejected" && !overageAvailable) {
521
528
  const reset = resetEpochToMs(quota.sessionResetAt, now) ??
522
529
  (retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_COOLING_PERIOD_MS);
523
530
  return {
524
531
  reason: "session",
525
- coolingUntil: clampCooldownUntil(reset, now),
532
+ coolingUntil: clampCooldownUntil(reset, now, "session"),
526
533
  rotateImmediately: true,
527
534
  };
528
535
  }
@@ -533,7 +540,7 @@ function planCooldownFor429(quota, retryAfterMs, now, unifiedStatus = quota?.uni
533
540
  const reset = retryAfterMs > 0 ? now + retryAfterMs : now + DEFAULT_HARD_COOLDOWN_MS;
534
541
  return {
535
542
  reason: "unified",
536
- coolingUntil: clampCooldownUntil(reset, now),
543
+ coolingUntil: clampCooldownUntil(reset, now, "unified"),
537
544
  rotateImmediately: true,
538
545
  };
539
546
  }
@@ -559,8 +566,8 @@ function minutesUntil(untilMs, now) {
559
566
  * its reset so the next request skips it instead of discovering the limit via a
560
567
  * 429, except when the provider explicitly enables paid overage.
561
568
  */
562
- function reconcileCooldownFromQuota(state, quota, now) {
563
- const overageAvailable = isQuotaOverageAvailable(quota);
569
+ function reconcileCooldownFromQuota(state, quota, now, policy = overagePolicy) {
570
+ const overageAvailable = isOverageUsable(quota, policy);
564
571
  let until;
565
572
  let reason;
566
573
  if (quota.weeklyStatus === "rejected") {
@@ -592,7 +599,7 @@ function reconcileCooldownFromQuota(state, quota, now) {
592
599
  if (until === undefined) {
593
600
  return null;
594
601
  }
595
- const clamped = clampCooldownUntil(until, now);
602
+ const clamped = clampCooldownUntil(until, now, reason);
596
603
  if (!state.coolingUntil || clamped > state.coolingUntil) {
597
604
  state.coolingUntil = clamped;
598
605
  state.coolingReason = reason;
@@ -669,7 +676,7 @@ async function applyAccountUsageResult(account, fetchResult, observedAt, prior)
669
676
  if (quota.lastUpdated < (state.quota?.lastUpdated ?? 0)) {
670
677
  return state.quota ?? null;
671
678
  }
672
- state.quota = quota;
679
+ state.quota = mergeQuotaSnapshot(state.quota, quota);
673
680
  const cooldownUpdate = reconcileCooldownFromQuota(state, quota, Date.now());
674
681
  if (cooldownUpdate?.kind === "cooled") {
675
682
  await saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
@@ -837,6 +844,46 @@ function getSessionResetToleranceMs() {
837
844
  const raw = Number(process.env.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS ?? "");
838
845
  return Number.isInteger(raw) && raw > 0 ? raw : 15 * 60 * 1000;
839
846
  }
847
+ /**
848
+ * Operator policy on spending paid extra usage, refreshed from the runtime
849
+ * config snapshot on every request so a config edit applies mid-flight.
850
+ *
851
+ * Module-level rather than threaded through because every routing and cooldown
852
+ * decision consults it, and the value is uniform for a given proxy generation.
853
+ */
854
+ let overagePolicy = "auto";
855
+ /**
856
+ * Publish the operator's extra-usage policy for paths that cannot receive it
857
+ * explicitly. Callers that make a routing or cooldown decision take it as a
858
+ * parameter instead — see {@link isOverageUsable} — so a concurrent request or
859
+ * a hot config reload cannot change the answer mid-flight.
860
+ *
861
+ * `undefined` means "no runtime config on this path", which is not a request to
862
+ * clear an operator's setting, so the current value is kept.
863
+ */
864
+ function setOveragePolicy(policy) {
865
+ if (policy !== undefined) {
866
+ overagePolicy = policy;
867
+ }
868
+ }
869
+ /** The policy in force for a request that did not capture one explicitly. */
870
+ function currentOveragePolicy() {
871
+ return overagePolicy;
872
+ }
873
+ /**
874
+ * Whether this account may keep serving on paid extra usage.
875
+ *
876
+ * The provider decides what is possible; the operator decides what is
877
+ * permitted. `never` therefore vetoes an enabled account, while `always` can
878
+ * only confirm a signal Anthropic already gives — nothing here can switch on
879
+ * extra usage the organization has disabled.
880
+ */
881
+ function isOverageUsable(quota, policy = overagePolicy) {
882
+ if (policy === "never") {
883
+ return false;
884
+ }
885
+ return isQuotaOverageAvailable(quota);
886
+ }
840
887
  /**
841
888
  * Derive the ordering signals for an account from its latest runtime quota.
842
889
  *
@@ -846,7 +893,152 @@ function getSessionResetToleranceMs() {
846
893
  * window Anthropic has since renewed. The snapshot itself is refreshed from
847
894
  * live response headers the next time the account serves a request.
848
895
  */
849
- function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs) {
896
+ /** Shortest scope token we will match on, so a degenerately broad scope name
897
+ * (e.g. "Claude") cannot silently apply a per-model cap to every model. */
898
+ const MIN_SCOPE_TOKEN_LENGTH = 4;
899
+ function normalizeModelToken(value) {
900
+ return value.toLowerCase().replace(/[^a-z0-9]/g, "");
901
+ }
902
+ /**
903
+ * The distinguishing part of a scope display name. The shared vendor prefix is
904
+ * dropped so a window scoped to plain "Claude" collapses to "" and is rejected
905
+ * by the length guard rather than matching every Claude model.
906
+ */
907
+ function scopeMatchToken(scopeModel) {
908
+ const normalized = normalizeModelToken(scopeModel);
909
+ return normalized.startsWith("claude") ? normalized.slice(6) : normalized;
910
+ }
911
+ /**
912
+ * How well a window's scope identifies the requested model, or null when it does
913
+ * not apply. Higher wins; an exact wire-id agreement is unambiguous and so
914
+ * outranks every display-name match.
915
+ */
916
+ function scopeMatchScore(window, normalizedModel, requestedFamily) {
917
+ if (window.scopeModelId &&
918
+ normalizeModelToken(modelFamilyToken(window.scopeModelId)) ===
919
+ requestedFamily) {
920
+ return Number.MAX_SAFE_INTEGER;
921
+ }
922
+ if (!window.scopeModel) {
923
+ return null;
924
+ }
925
+ const scopeToken = scopeMatchToken(window.scopeModel);
926
+ if (scopeToken.length < MIN_SCOPE_TOKEN_LENGTH ||
927
+ !normalizedModel.includes(scopeToken)) {
928
+ return null;
929
+ }
930
+ return scopeToken.length;
931
+ }
932
+ /**
933
+ * When a scoped window was last observed. Falls back through the usage-API
934
+ * sweep timestamp to the flat snapshot time.
935
+ *
936
+ * Needed because the flat fields refresh on every response while `windows` only
937
+ * changes when the served model has a scoped cap — so a days-old scoped window
938
+ * rides along on a `lastUpdated` from seconds ago and looks current.
939
+ */
940
+ function scopedWindowObservedAt(quota, window) {
941
+ return window.updatedAt ?? quota.windowsUpdatedAt ?? quota.lastUpdated;
942
+ }
943
+ /**
944
+ * Find the model-scoped quota window that applies to the requested model.
945
+ *
946
+ * Two sources describe the same cap differently: response headers carry the wire
947
+ * id of the model just served, while the usage API reports a DISPLAY name
948
+ * ("Fable", "Claude Opus 4.6"). So an exact wire-id match is tried first and
949
+ * display-name containment over alphanumeric-normalized forms is the fallback.
950
+ * Among equally specific matches the freshest observation wins, which favours
951
+ * the continuously-updated header window over a manually-refreshed one.
952
+ *
953
+ * Returns null whenever the account reports no applicable, still-fresh scoped
954
+ * cap — the common case.
955
+ */
956
+ function matchScopedQuotaWindow(quota, requestedModel, now = Date.now()) {
957
+ if (!quota?.windows?.length || !requestedModel) {
958
+ return null;
959
+ }
960
+ const normalizedModel = normalizeModelToken(requestedModel);
961
+ if (!normalizedModel) {
962
+ return null;
963
+ }
964
+ const requestedFamily = normalizeModelToken(modelFamilyToken(requestedModel));
965
+ let best = null;
966
+ let bestScore = -1;
967
+ let bestObservedAt = -1;
968
+ for (const window of quota.windows) {
969
+ // `isActive` marks which window the provider considers binding right now,
970
+ // not whether the cap applies — a scoped cap reads as inactive until it is
971
+ // the tightest constraint, which is far too late to route around it.
972
+ if (now - scopedWindowObservedAt(quota, window) >
973
+ QUOTA_SNAPSHOT_FRESHNESS_MS) {
974
+ continue;
975
+ }
976
+ const score = scopeMatchScore(window, normalizedModel, requestedFamily);
977
+ if (score === null) {
978
+ continue;
979
+ }
980
+ const observedAt = scopedWindowObservedAt(quota, window);
981
+ if (score > bestScore ||
982
+ (score === bestScore && observedAt > bestObservedAt)) {
983
+ best = window;
984
+ bestScore = score;
985
+ bestObservedAt = observedAt;
986
+ }
987
+ }
988
+ return best;
989
+ }
990
+ /**
991
+ * Split a candidate set by whether each account can still serve the requested
992
+ * model under its model-scoped cap.
993
+ *
994
+ * An account is scoped out only on evidence strong enough to act on: a fresh
995
+ * window, a reset that is actually ticking, a rejected/spent reading, and no
996
+ * paid extra usage to absorb the overflow. Anything weaker leaves the account
997
+ * eligible — a stale or mis-parsed window must never be able to empty the pool.
998
+ *
999
+ * `exhaustion` is populated only when every candidate is scoped out AND the
1000
+ * evidence is trustworthy, which is what lets the caller report "switch model"
1001
+ * instead of a generic rate limit.
1002
+ */
1003
+ function evaluateScopedExhaustion(accounts, requestedModel, now = Date.now(), policy = overagePolicy) {
1004
+ if (!requestedModel || accounts.length === 0) {
1005
+ return { eligible: accounts, exhaustion: null };
1006
+ }
1007
+ const eligible = [];
1008
+ const exhausted = [];
1009
+ let overageDisabledReason;
1010
+ for (const account of accounts) {
1011
+ const quota = accountRuntimeState.get(account.key)?.quota;
1012
+ const window = matchScopedQuotaWindow(quota, requestedModel, now);
1013
+ const reset = window ? resetEpochToMs(window.resetsAt, now) : undefined;
1014
+ const spent = window !== null &&
1015
+ reset !== undefined &&
1016
+ ((window.status ?? "").trim().toLowerCase() === "rejected" ||
1017
+ (window.used ?? 0) >= 1) &&
1018
+ !isOverageUsable(quota, policy);
1019
+ if (!spent || !window) {
1020
+ eligible.push(account);
1021
+ continue;
1022
+ }
1023
+ exhausted.push({ label: account.label, window });
1024
+ overageDisabledReason ??= quota?.overageDisabledReason;
1025
+ }
1026
+ if (eligible.length > 0 || exhausted.length === 0) {
1027
+ return { eligible, exhaustion: null };
1028
+ }
1029
+ const earliestResetMs = Math.min(...exhausted.map((entry) => resetEpochToMs(entry.window.resetsAt, now) ?? Number.POSITIVE_INFINITY));
1030
+ return {
1031
+ eligible,
1032
+ exhaustion: {
1033
+ model: requestedModel,
1034
+ scopeModel: exhausted[0]?.window.scopeModel ?? modelFamilyToken(requestedModel),
1035
+ earliestResetMs,
1036
+ accounts: exhausted.map((entry) => entry.label),
1037
+ ...(overageDisabledReason ? { overageDisabledReason } : {}),
1038
+ },
1039
+ };
1040
+ }
1041
+ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel, policy = overagePolicy) {
850
1042
  const st = accountRuntimeState.get(accountKey);
851
1043
  const q = st?.quota;
852
1044
  const quotaLastUpdated = q && Number.isFinite(q.lastUpdated) ? q.lastUpdated : null;
@@ -902,7 +1094,9 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
902
1094
  ? (routingQuota.weeklyStatus ?? "unknown")
903
1095
  : "allowed"
904
1096
  : null;
905
- const overageEligible = isQuotaOverageAvailable(routingQuota);
1097
+ // isOverageUsable layers the operator's routing.use-overage policy over the
1098
+ // provider signal isQuotaOverageAvailable reads.
1099
+ const overageEligible = isOverageUsable(routingQuota, policy);
906
1100
  const hardSaturated = !overageEligible &&
907
1101
  (sessionStatus === "rejected" ||
908
1102
  sessionStatus === "throttled" ||
@@ -916,9 +1110,41 @@ function accountSortMetrics(accountKey, now, sessionSoftLimit, sessionResetToler
916
1110
  sessionTicking &&
917
1111
  (sessionUsed ?? 0) >= sessionSoftLimit;
918
1112
  const saturated = hardSaturated || softSaturated;
1113
+ // Model-scoped weekly cap for the model this request actually asks for.
1114
+ // Reset-freshened exactly like session/weekly: a window whose reset has
1115
+ // passed reads as renewed, never as spent.
1116
+ const scopedWindow = matchScopedQuotaWindow(routingQuota, requestedModel, now);
1117
+ const scopedReset = scopedWindow
1118
+ ? resetEpochToMs(scopedWindow.resetsAt, now)
1119
+ : undefined;
1120
+ const scopedTicking = scopedReset !== undefined;
1121
+ const scopedUsed = scopedWindow
1122
+ ? scopedTicking
1123
+ ? scopedWindow.used
1124
+ : 0
1125
+ : null;
1126
+ const scopedStatus = scopedWindow
1127
+ ? scopedTicking
1128
+ ? (scopedWindow.status ?? "unknown")
1129
+ : "allowed"
1130
+ : null;
1131
+ const scopedSaturated = !quotaStale && scopedTicking && (scopedUsed ?? 0) >= sessionSoftLimit;
919
1132
  return {
920
- usable: !coolingActive && !hardSaturated,
1133
+ // A rejected model-scoped window means this account cannot serve THIS
1134
+ // model, even though it may be perfectly healthy for others. Excluding it
1135
+ // here only affects ordering — the real gate is evaluateScopedExhaustion,
1136
+ // and neither cools the account, which would wrongly withhold it from
1137
+ // every other model.
1138
+ usable: !coolingActive &&
1139
+ !hardSaturated &&
1140
+ (scopedStatus !== "rejected" || overageEligible),
921
1141
  saturated,
1142
+ scopedModel: scopedWindow?.scopeModel ?? null,
1143
+ scopedStatus,
1144
+ scopedUsed,
1145
+ scopedReset: scopedReset ?? Number.POSITIVE_INFINITY,
1146
+ scopedUsedForSort: scopedUsed ?? -1,
1147
+ scopedSaturated,
922
1148
  hasQuota: !!q,
923
1149
  quotaEvidenceRank: quotaFreshness === "fresh" || quotaFreshness === "stale_known"
924
1150
  ? 0
@@ -988,6 +1214,12 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
988
1214
  if (ma.saturated !== mb.saturated) {
989
1215
  return [ma.saturated ? 1 : -1, "session_headroom"];
990
1216
  }
1217
+ // Per-model headroom, after overall session capacity: an account whose cap
1218
+ // for THIS model is nearly spent is demoted even when its 5h/7d are healthy.
1219
+ // No-op when neither account reports a scoped window for the model.
1220
+ if (ma.scopedSaturated !== mb.scopedSaturated) {
1221
+ return [ma.scopedSaturated ? 1 : -1, "scoped_headroom"];
1222
+ }
991
1223
  if (ma.saturated && mb.saturated) {
992
1224
  if (ma.sessionResetBucket !== mb.sessionResetBucket) {
993
1225
  return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
@@ -1004,6 +1236,19 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
1004
1236
  return [ma.sessionResetBucket - mb.sessionResetBucket, "session_reset"];
1005
1237
  }
1006
1238
  }
1239
+ // Fill-first within the per-model allowance: finish off the account closest
1240
+ // to spending its cap for this model before opening a fresher one. Ranked
1241
+ // above overall weekly utilization because it is the tighter constraint.
1242
+ // Both sides must actually report a scoped window. Comparing a real
1243
+ // utilization against the "absent" sentinel would rank the account that has a
1244
+ // window above one that does not — and since only the account serving a model
1245
+ // gets that model's window, it would funnel all of a model's traffic onto
1246
+ // whichever account happened to serve it first.
1247
+ if (ma.scopedUsed !== null &&
1248
+ mb.scopedUsed !== null &&
1249
+ ma.scopedUsedForSort !== mb.scopedUsedForSort) {
1250
+ return [mb.scopedUsedForSort - ma.scopedUsedForSort, "scoped_utilization"];
1251
+ }
1007
1252
  if (ma.weeklyUsedForSort !== mb.weeklyUsedForSort) {
1008
1253
  return [mb.weeklyUsedForSort - ma.weeklyUsedForSort, "weekly_utilization"];
1009
1254
  }
@@ -1012,10 +1257,10 @@ function compareAccountRoutingFactors(a, b, metricsByKey, primaryKey) {
1012
1257
  }
1013
1258
  return [0, "insertion_order"];
1014
1259
  }
1015
- function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs) {
1260
+ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel) {
1016
1261
  const metricsByKey = new Map(accounts.map((account) => [
1017
1262
  account.key,
1018
- accountSortMetrics(account.key, now, sessionSoftLimit, sessionResetToleranceMs),
1263
+ accountSortMetrics(account.key, now, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
1019
1264
  ]));
1020
1265
  return {
1021
1266
  orderedAccounts: [...accounts].sort((a, b) => compareAccountRoutingFactors(a, b, metricsByKey, primaryKey)[0]),
@@ -1046,8 +1291,8 @@ function orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftL
1046
1291
  * Cooling/rejected accounts sort last, soonest-back-to-service first, as
1047
1292
  * last resort.
1048
1293
  */
1049
- function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) {
1050
- return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs).orderedAccounts;
1294
+ function orderAccountsByQuota(accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) {
1295
+ return orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel).orderedAccounts;
1051
1296
  }
1052
1297
  function scheduleAdaptiveQuotaRefreshes(accounts, orderedAccounts, sessionSoftLimit, routingMetrics) {
1053
1298
  for (const account of accounts) {
@@ -1160,6 +1405,12 @@ function buildRoutingDecision(args) {
1160
1405
  weeklyResetAt: Number.isFinite(metrics.weeklyReset)
1161
1406
  ? metrics.weeklyReset
1162
1407
  : null,
1408
+ scopedModel: metrics.scopedModel,
1409
+ scopedStatus: metrics.scopedStatus,
1410
+ scopedUsed: metrics.scopedUsed,
1411
+ scopedResetAt: Number.isFinite(metrics.scopedReset)
1412
+ ? metrics.scopedReset
1413
+ : null,
1163
1414
  });
1164
1415
  }
1165
1416
  const initialAccount = orderedAccounts[0];
@@ -1202,7 +1453,7 @@ function buildRoutingDecision(args) {
1202
1453
  };
1203
1454
  }
1204
1455
  function selectClaudeProxyAccountOrder(args) {
1205
- const { enabledAccounts, accountStrategy, primaryAccountKey, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, setRoutingDecision, } = args;
1456
+ const { enabledAccounts, accountStrategy, primaryAccountKey, quotaRoutingEnabled, sessionSoftLimit, sessionResetToleranceMs, requestedModel, setRoutingDecision, } = args;
1206
1457
  let orderedAccounts = [...enabledAccounts];
1207
1458
  const evaluatedAt = Date.now();
1208
1459
  let metricsByKey;
@@ -1215,7 +1466,7 @@ function selectClaudeProxyAccountOrder(args) {
1215
1466
  maybeResetPrimaryToHome(enabledAccounts, primaryAccountKey);
1216
1467
  }
1217
1468
  if (quotaOrdered) {
1218
- const quotaOrder = orderAccountsByQuotaWithMetrics(enabledAccounts, evaluatedAt, primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs);
1469
+ const quotaOrder = orderAccountsByQuotaWithMetrics(enabledAccounts, evaluatedAt, primaryAccountKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
1219
1470
  orderedAccounts = quotaOrder.orderedAccounts;
1220
1471
  metricsByKey = quotaOrder.metricsByKey;
1221
1472
  if (logger.shouldLog("debug")) {
@@ -1243,7 +1494,7 @@ function selectClaudeProxyAccountOrder(args) {
1243
1494
  }
1244
1495
  metricsByKey = new Map(enabledAccounts.map((account) => [
1245
1496
  account.key,
1246
- accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs),
1497
+ accountSortMetrics(account.key, evaluatedAt, sessionSoftLimit, sessionResetToleranceMs, requestedModel),
1247
1498
  ]));
1248
1499
  }
1249
1500
  const routingDecision = buildRoutingDecision({
@@ -1818,7 +2069,9 @@ async function handleClaudePassthroughRequest(args) {
1818
2069
  // to report — but the upstream quota headers are still the caller's real
1819
2070
  // limits. Published before the ok/non-ok split so a 429 carries them too.
1820
2071
  {
1821
- const passthroughQuota = parseQuotaHeaders(response.headers);
2072
+ const passthroughQuota = parseQuotaHeaders(response.headers, {
2073
+ model: body.model,
2074
+ });
1822
2075
  publishLimitHeaders(ctx, {
1823
2076
  upstreamHeaders: response.headers,
1824
2077
  quota: passthroughQuota,
@@ -2216,6 +2469,11 @@ async function loadClaudeProxyAccounts(args) {
2216
2469
  startupPruneDone = true;
2217
2470
  }
2218
2471
  const compoundKeys = await tokenStore.listByPrefix("anthropic:");
2472
+ // Tracked so an empty pool can name the real cause: "every account is
2473
+ // entitlement-blocked" is a different problem from "no credentials".
2474
+ const entitlementBlockedLabels = [];
2475
+ let skippedDisabledCount = 0;
2476
+ let skippedForOtherReasons = 0;
2219
2477
  for (const key of compoundKeys) {
2220
2478
  if (!isAccountAllowed(key, accountAllowlist)) {
2221
2479
  logger.debug(`[proxy] skipping account=${key} (not in account allowlist)`);
@@ -2237,11 +2495,16 @@ async function loadClaudeProxyAccounts(args) {
2237
2495
  else {
2238
2496
  logger.debug(`[proxy] skipping disabled account=${key.split(":")[1] ?? key}`);
2239
2497
  existingState.permanentlyDisabled = true;
2498
+ if (disabledReason === "entitlement_blocked") {
2499
+ entitlementBlockedLabels.push(key.split(":")[1] ?? key);
2500
+ }
2501
+ skippedDisabledCount += 1;
2240
2502
  continue;
2241
2503
  }
2242
2504
  }
2243
2505
  const tokens = await tokenStore.loadTokens(key);
2244
2506
  if (!tokens) {
2507
+ skippedForOtherReasons += 1;
2245
2508
  continue;
2246
2509
  }
2247
2510
  let accessToken = tokens.accessToken;
@@ -2349,6 +2612,24 @@ async function loadClaudeProxyAccounts(args) {
2349
2612
  });
2350
2613
  }
2351
2614
  if (accounts.length === 0) {
2615
+ // Once every account has been disabled on entitlement the loop never runs,
2616
+ // so this is the only place the client can learn why. A flat 401 here would
2617
+ // send the user to re-authenticate, which cannot fix an org policy.
2618
+ if (entitlementBlockedLabels.length > 0 &&
2619
+ entitlementBlockedLabels.length === skippedDisabledCount &&
2620
+ skippedForOtherReasons === 0) {
2621
+ const entitlementMessage = buildEntitlementErrorMessage({
2622
+ status: 403,
2623
+ accounts: entitlementBlockedLabels,
2624
+ message: "OAuth authentication is not allowed for this organization.",
2625
+ errorCode: "oauth_not_allowed_for_organization",
2626
+ });
2627
+ tracer?.setError("permission_error", entitlementMessage);
2628
+ tracer?.end(403, Date.now() - requestStartTime);
2629
+ return {
2630
+ response: buildLoggedClaudeError(403, entitlementMessage, "permission_error"),
2631
+ };
2632
+ }
2352
2633
  const noCredentialsMessage = accountAllowlist
2353
2634
  ? "No allowed Anthropic credentials are currently available"
2354
2635
  : compoundKeys.length > 0
@@ -2380,6 +2661,7 @@ async function loadClaudeProxyAccounts(args) {
2380
2661
  quotaRoutingEnabled,
2381
2662
  sessionSoftLimit,
2382
2663
  sessionResetToleranceMs,
2664
+ requestedModel: typeof body.model === "string" ? body.model : undefined,
2383
2665
  setRoutingDecision,
2384
2666
  });
2385
2667
  if (accountStrategy === "fill-first" &&
@@ -2730,8 +3012,100 @@ async function tryAutoClaudeFallback(args) {
2730
3012
  return { response: null, lastErrorMessage: errorMessage };
2731
3013
  }
2732
3014
  }
3015
+ /** Human-readable list that stays short when the pool is large. */
3016
+ function joinAccountLabels(labels, max = 5) {
3017
+ const unique = [...new Set(labels)];
3018
+ if (unique.length <= max) {
3019
+ return unique.join(", ");
3020
+ }
3021
+ return `${unique.slice(0, max).join(", ")} and ${unique.length - max} more`;
3022
+ }
3023
+ /**
3024
+ * The client-facing text for "every account is blocked by an organization
3025
+ * entitlement policy". Names the accounts and the remedy, because the upstream
3026
+ * message alone ("OAuth authentication is currently not allowed…") gives no
3027
+ * indication that a pool was involved or which credential to fix.
3028
+ */
3029
+ function buildEntitlementErrorMessage(failure) {
3030
+ const count = new Set(failure.accounts).size;
3031
+ const code = failure.errorCode ? ` (${failure.errorCode})` : "";
3032
+ return (`All ${count} Anthropic account${count === 1 ? "" : "s"} are blocked by an ` +
3033
+ `organization entitlement policy${code}. Accounts: ${joinAccountLabels(failure.accounts)}. Upstream: "${failure.message}". Ask an organization admin to re-enable ` +
3034
+ `Claude Code OAuth access, then run: neurolink auth enable anthropic:<account>.`);
3035
+ }
3036
+ /** Name the subscription window a set of cooldowns is waiting on. */
3037
+ function describeCoolingWindow(states) {
3038
+ const reasons = new Set(states.map((state) => state.coolingReason));
3039
+ if (reasons.size === 1) {
3040
+ switch ([...reasons][0]) {
3041
+ case "session":
3042
+ return "5-hour subscription window";
3043
+ case "weekly":
3044
+ return "7-day subscription window";
3045
+ case "unified":
3046
+ return "subscription limit";
3047
+ case "transient":
3048
+ return "upstream burst limit";
3049
+ default:
3050
+ break;
3051
+ }
3052
+ }
3053
+ return "upstream rate limits";
3054
+ }
3055
+ /**
3056
+ * The provider's stated reason that paid extra usage is unavailable, taken from
3057
+ * the first account that reports one. Anthropic sends this on every response
3058
+ * (e.g. "org_level_disabled"); without it an exhausted pool looks like a proxy
3059
+ * failure rather than a billing policy.
3060
+ */
3061
+ function findOverageDisabledReason(accounts) {
3062
+ for (const account of accounts) {
3063
+ const quota = accountRuntimeState.get(account.key)?.quota;
3064
+ if (quota?.overageDisabledReason &&
3065
+ quota.overageStatus?.trim().toLowerCase() !== "allowed") {
3066
+ return quota.overageDisabledReason;
3067
+ }
3068
+ }
3069
+ return undefined;
3070
+ }
3071
+ /** Suffix explaining why paid overage cannot absorb an exhausted window. */
3072
+ function buildOverageUnavailableSuffix(reason) {
3073
+ if (!reason) {
3074
+ return "";
3075
+ }
3076
+ return (` Paid extra usage is unavailable for this organization (${reason}), so ` +
3077
+ `there is no additional capacity before the window resets.`);
3078
+ }
3079
+ /**
3080
+ * The client-facing text for "every account has spent its model-scoped cap".
3081
+ * Distinct from a normal rate limit: the same accounts remain healthy for every
3082
+ * other model, so switching model is a real remedy and backing off is not.
3083
+ */
3084
+ function buildScopedExhaustionMessage(exhaustion) {
3085
+ const count = new Set(exhaustion.accounts).size;
3086
+ return (`All ${count} Anthropic account${count === 1 ? "" : "s"} have exhausted the ` +
3087
+ `model-scoped limit for ${exhaustion.model} (window "${exhaustion.scopeModel}"). ` +
3088
+ `Other models remain available on this pool — switch model, or add an account ` +
3089
+ `with remaining ${exhaustion.scopeModel} capacity. Earliest reset at ` +
3090
+ `${new Date(exhaustion.earliestResetMs).toISOString()}.` +
3091
+ buildOverageUnavailableSuffix(exhaustion.overageDisabledReason));
3092
+ }
2733
3093
  function buildClaudeAnthropicFailureResponse(args) {
2734
- const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, lastTransportErrorCode, lastTransportScope, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
3094
+ const { tracer, requestStartTime, authFailureMessage, authCooldownMessage, invalidRequestFailure, entitlementFailure, scopedExhaustion, sawNetworkError, sawTransientFailure, sawRateLimit, lastError, lastTransportErrorCode, lastTransportScope, fallbackFailureMessage, orderedAccounts, buildLoggedClaudeError, logProxyBody, logFinalRequest, } = args;
3095
+ // Ranked above the auth rung on purpose: an organization policy block is a
3096
+ // more specific diagnosis than "authentication failed". But only when the
3097
+ // policy explains the *whole* pool — otherwise a single blocked account would
3098
+ // mask four genuine 5xx failures behind a 403, telling the client not to retry
3099
+ // when retrying is exactly right. No retry-after: waiting cannot fix a policy.
3100
+ const entitlementExplainsPool = entitlementFailure !== null &&
3101
+ orderedAccounts.length > 0 &&
3102
+ new Set(entitlementFailure.accounts).size >= orderedAccounts.length;
3103
+ if (entitlementExplainsPool && entitlementFailure && !sawRateLimit) {
3104
+ const message = buildEntitlementErrorMessage(entitlementFailure);
3105
+ tracer?.setError("permission_error", message);
3106
+ tracer?.end(403, Date.now() - requestStartTime);
3107
+ return buildLoggedClaudeError(403, message, "permission_error");
3108
+ }
2735
3109
  if (authFailureMessage && !sawRateLimit) {
2736
3110
  tracer?.setError("authentication_error", authFailureMessage);
2737
3111
  tracer?.end(401, Date.now() - requestStartTime);
@@ -2786,6 +3160,37 @@ function buildClaudeAnthropicFailureResponse(args) {
2786
3160
  ...(lastTransportScope ? { transportScope: lastTransportScope } : {}),
2787
3161
  });
2788
3162
  }
3163
+ /** Emit the 429 the client sees, with an honest retry-after. */
3164
+ const respondRateLimited = (message, retryAfterSec) => {
3165
+ const errorBody = buildClaudeError(429, message, "overloaded_error");
3166
+ tracer?.setError("rate_limit_error", message);
3167
+ tracer?.end(429, Date.now() - requestStartTime);
3168
+ logFinalRequest(429, "", "final", "rate_limit_error", message);
3169
+ const errorBodyText = JSON.stringify(errorBody);
3170
+ const headers = {
3171
+ "content-type": "application/json",
3172
+ "retry-after": String(retryAfterSec),
3173
+ };
3174
+ logProxyBody({
3175
+ phase: "client_response",
3176
+ headers,
3177
+ body: errorBodyText,
3178
+ bodySize: Buffer.byteLength(errorBodyText, "utf8"),
3179
+ contentType: "application/json",
3180
+ responseStatus: 429,
3181
+ durationMs: Date.now() - requestStartTime,
3182
+ });
3183
+ return new Response(errorBodyText, { status: 429, headers });
3184
+ };
3185
+ // A model-scoped cap is spent on every account. No upstream call was made, so
3186
+ // sawRateLimit is false and this must be caught before the generic
3187
+ // "all accounts failed" 502 below, which would report the wrong cause and
3188
+ // invite an immediate, guaranteed-to-fail retry.
3189
+ if (scopedExhaustion) {
3190
+ const message = buildScopedExhaustionMessage(scopedExhaustion);
3191
+ logger.always(`[proxy] model-scoped limit exhausted for ${scopedExhaustion.model} on all accounts`);
3192
+ return respondRateLimited(message, Math.max(1, Math.ceil((scopedExhaustion.earliestResetMs - Date.now()) / 1000)));
3193
+ }
2789
3194
  if (!sawRateLimit) {
2790
3195
  const fallbackSuffix = fallbackFailureMessage
2791
3196
  ? ` Fallback also failed: ${fallbackFailureMessage}`
@@ -2809,45 +3214,27 @@ function buildClaudeAnthropicFailureResponse(args) {
2809
3214
  const retryAfterSec = allAccountsCooling
2810
3215
  ? Math.max(1, Math.ceil((earliestRetryAt - now) / 1000))
2811
3216
  : 1;
3217
+ // Name the window that actually ran out. "rate limits" alone cannot be acted
3218
+ // on: a 5-hour session window and a 7-day weekly window call for very
3219
+ // different responses from the caller.
3220
+ const windowLabel = describeCoolingWindow(activeRateLimitCooldowns);
3221
+ const overageSuffix = buildOverageUnavailableSuffix(findOverageDisabledReason(orderedAccounts));
2812
3222
  const errorMessage = allAccountsCooling
2813
- ? `All ${orderedAccounts.length} Anthropic accounts are cooling after upstream rate limits. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.`
2814
- : `All ${orderedAccounts.length} accounts rate-limited after per-account retries.`;
3223
+ ? `All ${orderedAccounts.length} Anthropic accounts are cooling after the ${windowLabel} was exhausted. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.${overageSuffix}`
3224
+ : `All ${orderedAccounts.length} accounts rate-limited after per-account retries.${overageSuffix}`;
2815
3225
  logger.always(`[proxy] all accounts rate-limited, retry in ${retryAfterSec}s`);
2816
- const errorBody = buildClaudeError(429, errorMessage, "overloaded_error");
2817
- tracer?.setError("rate_limit_error", errorMessage);
2818
- tracer?.end(429, Date.now() - requestStartTime);
2819
- logFinalRequest(429, "", "final", "rate_limit_error", errorMessage);
2820
- const errorBodyText = JSON.stringify(errorBody);
2821
- logProxyBody({
2822
- phase: "client_response",
2823
- headers: {
2824
- "content-type": "application/json",
2825
- "retry-after": String(retryAfterSec),
2826
- },
2827
- body: errorBodyText,
2828
- bodySize: Buffer.byteLength(errorBodyText, "utf8"),
2829
- contentType: "application/json",
2830
- responseStatus: 429,
2831
- durationMs: Date.now() - requestStartTime,
2832
- });
2833
- return new Response(errorBodyText, {
2834
- status: 429,
2835
- headers: {
2836
- "content-type": "application/json",
2837
- "retry-after": String(retryAfterSec),
2838
- },
2839
- });
3226
+ return respondRateLimited(errorMessage, retryAfterSec);
2840
3227
  }
2841
3228
  async function handleAnthropicSuccessfulResponse(args) {
2842
3229
  const { ctx, body, account, accountState, response, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, poolAccounts, logFinalRequest, } = args;
2843
3230
  accountState.consecutiveRefreshFailures = 0;
2844
3231
  logger.always(`[proxy] ← ${response.status} account=${account.label}`);
2845
- const quota = parseQuotaHeaders(response.headers);
3232
+ const quota = parseQuotaHeaders(response.headers, { model: body.model });
2846
3233
  if (quota) {
2847
3234
  // Stash the latest quota on runtime state so the next request can pick the
2848
3235
  // account whose window resets soonest (max-utilization) and proactively
2849
3236
  // skip rejected windows unless Anthropic explicitly permits overage.
2850
- accountState.quota = quota;
3237
+ accountState.quota = mergeQuotaSnapshot(accountState.quota, quota);
2851
3238
  const cooldownUpdate = reconcileCooldownFromQuota(accountState, quota, Date.now());
2852
3239
  if (cooldownUpdate?.kind === "cooled") {
2853
3240
  saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
@@ -2918,7 +3305,7 @@ async function handleAnthropicSuccessfulResponse(args) {
2918
3305
  });
2919
3306
  }
2920
3307
  async function handleAnthropicStreamingSuccessResponse(args) {
2921
- const { ctx, account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, } = args;
3308
+ const { ctx, body, account, accountState, response, responseHeaders, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, } = args;
2922
3309
  if (!response.body) {
2923
3310
  recordAttemptError(account.label, account.type, 502);
2924
3311
  logAttempt(502, "stream_error", "No response body from upstream");
@@ -3015,11 +3402,13 @@ async function handleAnthropicStreamingSuccessResponse(args) {
3015
3402
  const bodyText = Buffer.concat(preflight.chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
3016
3403
  const isRateLimit = preflight.errorType === "rate_limit_error";
3017
3404
  const logicalStatus = isRateLimit ? 429 : 502;
3018
- const quota = parseQuotaHeaders(responseHeaders);
3405
+ const quota = parseQuotaHeaders(responseHeaders, { model: body.model });
3019
3406
  const now = Date.now();
3020
3407
  if (isRateLimit) {
3021
3408
  const cooldownPlan = planCooldownFor429(quota, parseRetryAfterMs(responseHeaders["retry-after"] ?? null), now, getUnifiedRateLimitStatus(responseHeaders));
3022
- accountState.quota = quota ?? accountState.quota;
3409
+ accountState.quota = quota
3410
+ ? mergeQuotaSnapshot(accountState.quota, quota)
3411
+ : accountState.quota;
3023
3412
  const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
3024
3413
  if (!accountState.coolingUntil ||
3025
3414
  cooldownPlan.coolingUntil > accountState.coolingUntil) {
@@ -3480,12 +3869,14 @@ async function handleAnthropicJsonSuccessResponse(args) {
3480
3869
  return { response: responseJson };
3481
3870
  }
3482
3871
  async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
3483
- const { account, accountState, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
3484
- const retryQuota = parseQuotaHeaders(retryResp.headers);
3872
+ const { account, accountState, requestedModel, retryResp, tracer, requestStartTime, fetchStartMs, attemptNumber, finalBodyStr, upstreamSpan, logAttempt, logProxyBody, logFinalRequest, } = args;
3873
+ const retryQuota = parseQuotaHeaders(retryResp.headers, {
3874
+ model: requestedModel,
3875
+ });
3485
3876
  if (retryQuota) {
3486
3877
  // Keep the auth-retry success path in parity with the main success path:
3487
3878
  // stash quota for proactive selection and reconcile a rejected window.
3488
- accountState.quota = retryQuota;
3879
+ accountState.quota = mergeQuotaSnapshot(accountState.quota, retryQuota);
3489
3880
  const cooldownUpdate = reconcileCooldownFromQuota(accountState, retryQuota, Date.now());
3490
3881
  if (cooldownUpdate?.kind === "cooled") {
3491
3882
  saveAccountCooldown(account.key, cooldownUpdate.coolingUntil, cooldownUpdate.coolingReason).catch(() => {
@@ -3566,13 +3957,14 @@ async function handleAnthropicSuccessfulNonStreamRetryResponse(args) {
3566
3957
  return retryJson;
3567
3958
  }
3568
3959
  async function handleAnthropicAuthRetry(args) {
3569
- const { ctx, body, account, accountState, headers, buildUpstreamBody, url, enabledAccounts, orderedAccounts, tracer, requestStartTime, allocateAttemptNumber, upstreamSpan, logAttempt, logProxyBody, onStreamTerminal, logFinalRequest, lastError, authFailureMessage, sawRateLimit, sawTransientFailure, sawNetworkError, } = args;
3960
+ 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;
3570
3961
  recordAttemptError(account.label, account.type, 401);
3571
3962
  logAttempt(401, "authentication_error", "received 401 from Anthropic", {
3572
3963
  retryable: true,
3573
3964
  });
3574
3965
  let currentLastError = lastError;
3575
3966
  let currentAuthFailureMessage = authFailureMessage;
3967
+ let currentEntitlementFailure = entitlementFailure;
3576
3968
  let currentSawRateLimit = sawRateLimit;
3577
3969
  let currentSawTransientFailure = sawTransientFailure;
3578
3970
  let currentSawNetworkError = sawNetworkError;
@@ -3656,6 +4048,7 @@ async function handleAnthropicAuthRetry(args) {
3656
4048
  response: await handleAnthropicSuccessfulNonStreamRetryResponse({
3657
4049
  account,
3658
4050
  accountState,
4051
+ requestedModel: body.model,
3659
4052
  retryResp,
3660
4053
  tracer,
3661
4054
  requestStartTime,
@@ -3677,6 +4070,7 @@ async function handleAnthropicAuthRetry(args) {
3677
4070
  : {}),
3678
4071
  lastError: failure?.message ?? currentLastError,
3679
4072
  authFailureMessage: currentAuthFailureMessage,
4073
+ entitlementFailure: currentEntitlementFailure,
3680
4074
  sawRateLimit: currentSawRateLimit || Boolean(failure?.rateLimit),
3681
4075
  sawTransientFailure: currentSawTransientFailure ||
3682
4076
  Boolean(failure && !failure.rateLimit),
@@ -3690,6 +4084,7 @@ async function handleAnthropicAuthRetry(args) {
3690
4084
  continueLoop: false,
3691
4085
  lastError: currentLastError,
3692
4086
  authFailureMessage: currentAuthFailureMessage,
4087
+ entitlementFailure: currentEntitlementFailure,
3693
4088
  sawRateLimit: currentSawRateLimit,
3694
4089
  sawTransientFailure: currentSawTransientFailure,
3695
4090
  sawNetworkError: currentSawNetworkError,
@@ -3746,6 +4141,7 @@ async function handleAnthropicAuthRetry(args) {
3746
4141
  continueLoop: false,
3747
4142
  lastError: retryBody,
3748
4143
  authFailureMessage: currentAuthFailureMessage,
4144
+ entitlementFailure: currentEntitlementFailure,
3749
4145
  sawRateLimit: currentSawRateLimit,
3750
4146
  sawTransientFailure: currentSawTransientFailure,
3751
4147
  sawNetworkError: currentSawNetworkError,
@@ -3760,9 +4156,11 @@ async function handleAnthropicAuthRetry(args) {
3760
4156
  // Cool the account per its real reset window before rotating, so a
3761
4157
  // session/weekly-exhausted account isn't re-selected next request.
3762
4158
  const nowRetry = Date.now();
3763
- const retryQuota429 = parseQuotaHeaders(retryRespHeaders);
4159
+ const retryQuota429 = parseQuotaHeaders(retryRespHeaders, {
4160
+ model: body.model,
4161
+ });
3764
4162
  if (retryQuota429) {
3765
- accountState.quota = retryQuota429;
4163
+ accountState.quota = mergeQuotaSnapshot(accountState.quota, retryQuota429);
3766
4164
  }
3767
4165
  const retryPlan = planCooldownFor429(retryQuota429, parseRetryAfterMs(retryRespHeaders["retry-after"] ?? null), nowRetry, getUnifiedRateLimitStatus(retryRespHeaders));
3768
4166
  const rateLimitKind = retryPlan.reason === "transient" ? "transient" : "quota";
@@ -3789,6 +4187,36 @@ async function handleAnthropicAuthRetry(args) {
3789
4187
  break;
3790
4188
  }
3791
4189
  if (retryStatus === 401 || retryStatus === 402 || retryStatus === 403) {
4190
+ // An organization/plan entitlement refusal is not an authentication
4191
+ // problem: the token just minted is valid and refreshing again cannot
4192
+ // change the verdict. Rotate now instead of burning the remaining
4193
+ // refresh cycles and their one-second waits on a certain failure.
4194
+ if (isAccountEntitlementError(retryStatus, retryBody)) {
4195
+ const parsedRetry = parseClaudeErrorBody(retryBody);
4196
+ recordAttemptError(account.label, account.type, retryStatus);
4197
+ retryLogAttempt(retryStatus, "permission_error", summarizeErrorMessage(retryBody));
4198
+ currentEntitlementFailure = {
4199
+ status: retryStatus,
4200
+ accounts: [
4201
+ ...(currentEntitlementFailure?.accounts ?? []),
4202
+ account.label,
4203
+ ],
4204
+ message: currentEntitlementFailure?.message ??
4205
+ parsedRetry.message ??
4206
+ summarizeErrorMessage(retryBody),
4207
+ ...(parsedRetry.errorCode
4208
+ ? { errorCode: parsedRetry.errorCode }
4209
+ : {}),
4210
+ };
4211
+ if (account.type === "oauth" &&
4212
+ isDurableEntitlementBlock(retryStatus, retryBody)) {
4213
+ await disableAccountUntilReauth(account, accountState, "entitlement_blocked");
4214
+ }
4215
+ authRetryError = `entitlement blocked for account=${account.label}`;
4216
+ currentLastError = authRetryError;
4217
+ logger.always(`[proxy] ← ${retryStatus} account=${account.label} entitlement blocked after refresh; advancing to next account`);
4218
+ break;
4219
+ }
3792
4220
  recordAttemptError(account.label, account.type, retryStatus);
3793
4221
  retryLogAttempt(retryStatus, "authentication_error", summarizeErrorMessage(retryBody), { retryable: true });
3794
4222
  if (authRetry < MAX_AUTH_RETRIES - 1) {
@@ -3810,6 +4238,7 @@ async function handleAnthropicAuthRetry(args) {
3810
4238
  continueLoop: false,
3811
4239
  lastError: currentLastError,
3812
4240
  authFailureMessage: currentAuthFailureMessage,
4241
+ entitlementFailure: currentEntitlementFailure,
3813
4242
  sawRateLimit: currentSawRateLimit,
3814
4243
  sawTransientFailure: currentSawTransientFailure,
3815
4244
  sawNetworkError: currentSawNetworkError,
@@ -3823,6 +4252,7 @@ async function handleAnthropicAuthRetry(args) {
3823
4252
  continueLoop: false,
3824
4253
  lastError: currentLastError,
3825
4254
  authFailureMessage: currentAuthFailureMessage,
4255
+ entitlementFailure: currentEntitlementFailure,
3826
4256
  sawRateLimit: currentSawRateLimit,
3827
4257
  sawTransientFailure: currentSawTransientFailure,
3828
4258
  sawNetworkError: currentSawNetworkError,
@@ -3863,6 +4293,7 @@ async function handleAnthropicAuthRetry(args) {
3863
4293
  continueLoop: false,
3864
4294
  lastError: currentLastError,
3865
4295
  authFailureMessage: currentAuthFailureMessage,
4296
+ entitlementFailure: currentEntitlementFailure,
3866
4297
  sawRateLimit: currentSawRateLimit,
3867
4298
  sawTransientFailure: currentSawTransientFailure,
3868
4299
  sawNetworkError: currentSawNetworkError,
@@ -3886,6 +4317,7 @@ async function handleAnthropicAuthRetry(args) {
3886
4317
  continueLoop: true,
3887
4318
  lastError: currentLastError,
3888
4319
  authFailureMessage: currentAuthFailureMessage,
4320
+ entitlementFailure: currentEntitlementFailure,
3889
4321
  sawRateLimit: currentSawRateLimit,
3890
4322
  sawTransientFailure: currentSawTransientFailure,
3891
4323
  sawNetworkError: currentSawNetworkError,
@@ -3969,11 +4401,12 @@ function finalizeAnthropicTerminalTransportError(args) {
3969
4401
  return clientError;
3970
4402
  }
3971
4403
  async function handleAnthropicNonOkResponse(args) {
3972
- const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, } = args;
4404
+ const { response, account, accountState, enabledAccounts, orderedAccounts, tracer, requestStartTime, fetchStartMs, attemptNumber, logAttempt, logProxyBody, logFinalRequest, lastError, authFailureMessage, sawTransientFailure, invalidRequestFailure, entitlementFailure, } = args;
3973
4405
  let currentLastError = lastError;
3974
4406
  let currentAuthFailureMessage = authFailureMessage;
3975
4407
  let currentSawTransientFailure = sawTransientFailure;
3976
4408
  let currentInvalidRequestFailure = invalidRequestFailure;
4409
+ let currentEntitlementFailure = entitlementFailure;
3977
4410
  const errBody = await response.text();
3978
4411
  const errRespHeaders = {};
3979
4412
  response.headers.forEach((value, key) => {
@@ -4023,6 +4456,7 @@ async function handleAnthropicNonOkResponse(args) {
4023
4456
  authFailureMessage: currentAuthFailureMessage,
4024
4457
  sawTransientFailure: currentSawTransientFailure,
4025
4458
  invalidRequestFailure: currentInvalidRequestFailure,
4459
+ entitlementFailure: currentEntitlementFailure,
4026
4460
  upstreamSpan: undefined,
4027
4461
  };
4028
4462
  }
@@ -4041,6 +4475,53 @@ async function handleAnthropicNonOkResponse(args) {
4041
4475
  authFailureMessage: currentAuthFailureMessage,
4042
4476
  sawTransientFailure: currentSawTransientFailure,
4043
4477
  invalidRequestFailure: currentInvalidRequestFailure,
4478
+ entitlementFailure: currentEntitlementFailure,
4479
+ upstreamSpan: undefined,
4480
+ };
4481
+ }
4482
+ if (account.type === "oauth" &&
4483
+ isAccountEntitlementError(response.status, errBody)) {
4484
+ // Anthropic refuses this credential on organization/plan policy, e.g.
4485
+ // "OAuth authentication is currently not allowed for this organization."
4486
+ // Neither a retry nor a token refresh can fix it, but another account may
4487
+ // not be subject to the same policy — so rotate rather than fail the client.
4488
+ //
4489
+ // Must precede the no-refresh-token branch below: that branch matches the
4490
+ // same 401/402/403 statuses and would disable the account as
4491
+ // `missing_refresh_token`, telling the user to re-login — which cannot help
4492
+ // and burns a working credential.
4493
+ //
4494
+ // Like the beta-rejection branch above, deliberately sets neither
4495
+ // `currentInvalidRequestFailure` (would suppress provider fallback and
4496
+ // outrank a later account's real 429) nor `currentAuthFailureMessage`
4497
+ // (would surface a misleading "re-authenticate" 401).
4498
+ const parsed = parseClaudeErrorBody(errBody);
4499
+ recordAttemptError(account.label, account.type, response.status);
4500
+ currentEntitlementFailure = {
4501
+ status: response.status,
4502
+ accounts: [...(currentEntitlementFailure?.accounts ?? []), account.label],
4503
+ message: currentEntitlementFailure?.message ??
4504
+ parsed.message ??
4505
+ summarizeErrorMessage(errBody),
4506
+ ...(parsed.errorCode ? { errorCode: parsed.errorCode } : {}),
4507
+ };
4508
+ logger.always(`[proxy] ← ${response.status} account=${account.label} entitlement blocked${parsed.errorCode ? ` (${parsed.errorCode})` : ""}; advancing to next account`);
4509
+ logAttempt(response.status, "permission_error", summarizeErrorMessage(errBody));
4510
+ tracer?.setError("permission_error", summarizeErrorMessage(errBody));
4511
+ tracer?.recordRetry(account.label, "entitlement_blocked");
4512
+ if (account.type === "oauth" &&
4513
+ isDurableEntitlementBlock(response.status, errBody)) {
4514
+ await disableAccountUntilReauth(account, accountState, "entitlement_blocked");
4515
+ }
4516
+ advancePrimaryIfCurrent(account.key, enabledAccounts.length, orderedAccounts[0]?.key);
4517
+ currentLastError = summarizeErrorMessage(errBody);
4518
+ return {
4519
+ continueLoop: true,
4520
+ lastError: currentLastError,
4521
+ authFailureMessage: currentAuthFailureMessage,
4522
+ sawTransientFailure: currentSawTransientFailure,
4523
+ invalidRequestFailure: currentInvalidRequestFailure,
4524
+ entitlementFailure: currentEntitlementFailure,
4044
4525
  upstreamSpan: undefined,
4045
4526
  };
4046
4527
  }
@@ -4064,6 +4545,7 @@ async function handleAnthropicNonOkResponse(args) {
4064
4545
  authFailureMessage: currentAuthFailureMessage,
4065
4546
  sawTransientFailure: currentSawTransientFailure,
4066
4547
  invalidRequestFailure: currentInvalidRequestFailure,
4548
+ entitlementFailure: currentEntitlementFailure,
4067
4549
  upstreamSpan: undefined,
4068
4550
  };
4069
4551
  }
@@ -4086,6 +4568,7 @@ async function handleAnthropicNonOkResponse(args) {
4086
4568
  authFailureMessage: currentAuthFailureMessage,
4087
4569
  sawTransientFailure: currentSawTransientFailure,
4088
4570
  invalidRequestFailure: currentInvalidRequestFailure,
4571
+ entitlementFailure: currentEntitlementFailure,
4089
4572
  upstreamSpan: undefined,
4090
4573
  };
4091
4574
  }
@@ -4111,6 +4594,7 @@ async function handleAnthropicNonOkResponse(args) {
4111
4594
  authFailureMessage: currentAuthFailureMessage,
4112
4595
  sawTransientFailure: currentSawTransientFailure,
4113
4596
  invalidRequestFailure: currentInvalidRequestFailure,
4597
+ entitlementFailure: currentEntitlementFailure,
4114
4598
  upstreamSpan: undefined,
4115
4599
  };
4116
4600
  }
@@ -4139,6 +4623,7 @@ async function handleAnthropicNonOkResponse(args) {
4139
4623
  authFailureMessage: currentAuthFailureMessage,
4140
4624
  sawTransientFailure: currentSawTransientFailure,
4141
4625
  invalidRequestFailure: currentInvalidRequestFailure,
4626
+ entitlementFailure: currentEntitlementFailure,
4142
4627
  upstreamSpan: undefined,
4143
4628
  };
4144
4629
  }
@@ -4164,6 +4649,7 @@ async function handleAnthropicNonOkResponse(args) {
4164
4649
  authFailureMessage: currentAuthFailureMessage,
4165
4650
  sawTransientFailure: currentSawTransientFailure,
4166
4651
  invalidRequestFailure: currentInvalidRequestFailure,
4652
+ entitlementFailure: currentEntitlementFailure,
4167
4653
  upstreamSpan: undefined,
4168
4654
  };
4169
4655
  }
@@ -4545,7 +5031,7 @@ function buildAnthropicConstructionRejectionTerminalError() {
4545
5031
  };
4546
5032
  }
4547
5033
  async function fetchAnthropicAccountResponse(args) {
4548
- const { url, headers, finalBodyStr, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
5034
+ const { url, headers, finalBodyStr, requestedModel, account, accountState: _accountState2, enabledAccounts: _enabledAccounts, orderedAccounts: _orderedAccounts, tracer, logAttempt, logProxyBody, fetchStartMs, attemptNumber, currentLastError, currentSawRateLimit, currentSawNetworkError, upstreamSpan, } = args;
4549
5035
  let lastError = currentLastError;
4550
5036
  let sawRateLimit = currentSawRateLimit;
4551
5037
  let sawNetworkError = currentSawNetworkError;
@@ -4649,7 +5135,7 @@ async function fetchAnthropicAccountResponse(args) {
4649
5135
  // exhaustion 429 we rotate immediately and park the account until its
4650
5136
  // ACTUAL reset, not a 60s hardcap.
4651
5137
  const now = Date.now();
4652
- const quota = parseQuotaHeaders(errRespHeaders);
5138
+ const quota = parseQuotaHeaders(errRespHeaders, { model: requestedModel });
4653
5139
  const unifiedStatus = getUnifiedRateLimitStatus(errRespHeaders);
4654
5140
  const cooldownPlan = planCooldownFor429(quota, retryAfterMs, now, unifiedStatus);
4655
5141
  const rateLimitKind = cooldownPlan.reason === "transient" ? "transient" : "quota";
@@ -4712,6 +5198,10 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4712
5198
  return loadedAccounts.response;
4713
5199
  }
4714
5200
  const { accounts, enabledAccounts, orderedAccounts, bodyStr, requestStart, toolCount, url, clientHeaders, isClaudeClientRequest, } = loadedAccounts;
5201
+ // Snapshot the operator policy once. Reading the module value later would let
5202
+ // a concurrent /limits call or a hot config reload change this request's
5203
+ // answer partway through its own account loop.
5204
+ const requestOveragePolicy = currentOveragePolicy();
4715
5205
  const loopState = {
4716
5206
  lastError: undefined,
4717
5207
  sawRateLimit: false,
@@ -4720,6 +5210,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4720
5210
  invalidRequestFailure: null,
4721
5211
  authFailureMessage: null,
4722
5212
  authCooldownMessage: null,
5213
+ entitlementFailure: null,
5214
+ scopedExhaustion: null,
4723
5215
  attemptNumber: 0,
4724
5216
  };
4725
5217
  const acctSelectionSpan = tracer?.startAccountSelection();
@@ -4750,6 +5242,25 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4750
5242
  loopState.authCooldownMessage = `All ${orderedAccounts.length} Anthropic accounts are temporarily unavailable while OAuth refresh is cooling. Earliest retry at ${new Date(earliestRetryAt).toISOString()}.`;
4751
5243
  }
4752
5244
  }
5245
+ // Second eligibility gate, alongside cooldowns: an account whose model-scoped
5246
+ // cap for THIS model is spent will 429 with certainty. Unlike a cooldown the
5247
+ // condition is per-model, so it must not park the account — it stays fully
5248
+ // available for every other model.
5249
+ const scopedExhaustion = evaluateScopedExhaustion(effectiveAccounts, typeof body.model === "string" ? body.model : undefined, Date.now(), requestOveragePolicy);
5250
+ if (scopedExhaustion.eligible.length > 0) {
5251
+ effectiveAccounts = scopedExhaustion.eligible;
5252
+ }
5253
+ else if (scopedExhaustion.exhaustion) {
5254
+ // Every account is scoped out on evidence we trust. Record it and let the
5255
+ // loop fall through to the post-loop path, so a configured fallback chain
5256
+ // still runs and only the terminal message changes. Returning here instead
5257
+ // would silently drop that fallback.
5258
+ loopState.scopedExhaustion = scopedExhaustion.exhaustion;
5259
+ loopState.lastError = `Model-scoped limit exhausted for ${scopedExhaustion.exhaustion.model}`;
5260
+ effectiveAccounts = [];
5261
+ }
5262
+ // Otherwise the scoped evidence was stale or absent: attempt the request
5263
+ // anyway rather than let a mis-parsed window take the pool down.
4753
5264
  const accountAdmissionCapacity = modelRouter?.getMaxInflightPerAccount?.();
4754
5265
  // When every eligible account is busy, reserve the first account that frees
4755
5266
  // instead of arbitrarily waiting behind the last configured account.
@@ -4850,6 +5361,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4850
5361
  url,
4851
5362
  headers: preparedAttempt.headers,
4852
5363
  finalBodyStr: preparedAttempt.finalBodyStr,
5364
+ requestedModel: body.model,
4853
5365
  account,
4854
5366
  accountState,
4855
5367
  enabledAccounts,
@@ -4901,7 +5413,7 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4901
5413
  const plan = fetchResult.cooldownPlan;
4902
5414
  // Refresh the account's quota snapshot for proactive selection.
4903
5415
  if (fetchResult.quota) {
4904
- accountState.quota = fetchResult.quota;
5416
+ accountState.quota = mergeQuotaSnapshot(accountState.quota, fetchResult.quota);
4905
5417
  saveAccountQuota(account.label, fetchResult.quota).catch(() => {
4906
5418
  // Non-fatal: routing already has the in-memory snapshot.
4907
5419
  });
@@ -4995,12 +5507,14 @@ async function handleAnthropicRoutedClaudeRequest(args) {
4995
5507
  onStreamTerminal: admissionLease.release,
4996
5508
  lastError: loopState.lastError,
4997
5509
  authFailureMessage: loopState.authFailureMessage,
5510
+ entitlementFailure: loopState.entitlementFailure,
4998
5511
  sawRateLimit: loopState.sawRateLimit,
4999
5512
  sawTransientFailure: loopState.sawTransientFailure,
5000
5513
  sawNetworkError: loopState.sawNetworkError,
5001
5514
  });
5002
5515
  loopState.lastError = authRetryResult.lastError;
5003
5516
  loopState.authFailureMessage = authRetryResult.authFailureMessage;
5517
+ loopState.entitlementFailure = authRetryResult.entitlementFailure;
5004
5518
  loopState.sawRateLimit = authRetryResult.sawRateLimit;
5005
5519
  loopState.sawTransientFailure = authRetryResult.sawTransientFailure;
5006
5520
  loopState.sawNetworkError = authRetryResult.sawNetworkError;
@@ -5036,11 +5550,13 @@ async function handleAnthropicRoutedClaudeRequest(args) {
5036
5550
  authFailureMessage: loopState.authFailureMessage,
5037
5551
  sawTransientFailure: loopState.sawTransientFailure,
5038
5552
  invalidRequestFailure: loopState.invalidRequestFailure,
5553
+ entitlementFailure: loopState.entitlementFailure,
5039
5554
  });
5040
5555
  loopState.lastError = nonOkResult.lastError;
5041
5556
  loopState.authFailureMessage = nonOkResult.authFailureMessage;
5042
5557
  loopState.sawTransientFailure = nonOkResult.sawTransientFailure;
5043
5558
  loopState.invalidRequestFailure = nonOkResult.invalidRequestFailure;
5559
+ loopState.entitlementFailure = nonOkResult.entitlementFailure;
5044
5560
  if (nonOkResult.response !== undefined) {
5045
5561
  return nonOkResult.response;
5046
5562
  }
@@ -5185,6 +5701,8 @@ async function handleAnthropicRoutedClaudeRequest(args) {
5185
5701
  authFailureMessage: loopState.authFailureMessage,
5186
5702
  authCooldownMessage: loopState.authCooldownMessage,
5187
5703
  invalidRequestFailure: loopState.invalidRequestFailure,
5704
+ entitlementFailure: loopState.entitlementFailure,
5705
+ scopedExhaustion: loopState.scopedExhaustion,
5188
5706
  sawNetworkError: loopState.sawNetworkError,
5189
5707
  sawTransientFailure: loopState.sawTransientFailure,
5190
5708
  sawRateLimit: loopState.sawRateLimit,
@@ -5268,7 +5786,9 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
5268
5786
  quotaRoutingEnabled: isQuotaRoutingEnabled(),
5269
5787
  sessionSoftLimit: getSessionSoftLimit(),
5270
5788
  sessionResetToleranceMs: getSessionResetToleranceMs(),
5789
+ useOverage: "auto",
5271
5790
  };
5791
+ setOveragePolicy(requestRouting.useOverage);
5272
5792
  const requestModelRouter = requestRouting.modelRouter;
5273
5793
  const body = ctx.body;
5274
5794
  // 1. Validate
@@ -5436,9 +5956,12 @@ export function createClaudeProxyRoutes(modelRouter, basePath = "", accountStrat
5436
5956
  method: "GET",
5437
5957
  path: `${basePath}/limits`,
5438
5958
  handler: async (ctx) => {
5439
- const effectiveAllowlist = runtimeConfigProvider
5440
- ? runtimeConfigProvider().accountAllowlist
5441
- : accountAllowlist;
5959
+ const limitsRouting = runtimeConfigProvider?.();
5960
+ const effectiveAllowlist = limitsRouting?.accountAllowlist ?? accountAllowlist;
5961
+ // A refresh reconciles cooldowns from the fetched quota, so it makes
5962
+ // the same overage judgement the request path does and needs the same
5963
+ // operator policy in scope.
5964
+ setOveragePolicy(limitsRouting?.useOverage);
5442
5965
  const snapshotOnly = ctx.query?.snapshot === "true" || ctx.query?.snapshot === "1";
5443
5966
  const accountFilter = ctx.query?.account;
5444
5967
  return withSpan({
@@ -5529,7 +6052,9 @@ async function disableAccountUntilReauth(account, state, reason) {
5529
6052
  logger.debug(`[proxy] failed to persist disabled state for ${account.label}: ${e instanceof Error ? e.message : String(e)}`);
5530
6053
  }
5531
6054
  state.permanentlyDisabled = true;
5532
- logger.always(`[proxy] account=${account.label} disabled until re-authentication. Run: neurolink auth login anthropic --method oauth`);
6055
+ logger.always(reason === "entitlement_blocked"
6056
+ ? `[proxy] account=${account.label} disabled: the organization blocks Claude Code OAuth. Ask an admin to re-enable it, then run: neurolink auth enable ${account.key}`
6057
+ : `[proxy] account=${account.label} disabled until re-authentication. Run: neurolink auth login anthropic --method oauth`);
5533
6058
  return true;
5534
6059
  }
5535
6060
  async function coolAccountAfterTransientRefreshFailure(account, state) {
@@ -5667,11 +6192,13 @@ export function parseClaudeErrorBody(errBody) {
5667
6192
  parsed.type === "error" &&
5668
6193
  parsed.error &&
5669
6194
  typeof parsed.error === "object") {
6195
+ const errorCode = parsed.error.details?.error_code;
5670
6196
  return {
5671
6197
  errorType: typeof parsed.error.type === "string" ? parsed.error.type : undefined,
5672
6198
  message: typeof parsed.error.message === "string"
5673
6199
  ? parsed.error.message
5674
6200
  : undefined,
6201
+ ...(typeof errorCode === "string" ? { errorCode } : {}),
5675
6202
  };
5676
6203
  }
5677
6204
  }
@@ -5714,6 +6241,55 @@ export function isSubscriptionBetaRejection(status, errBody) {
5714
6241
  message.includes("subscription") &&
5715
6242
  message.includes("available"));
5716
6243
  }
6244
+ /**
6245
+ * Organization/plan entitlement codes Anthropic reports in `error.details`.
6246
+ * Membership here is what promotes a rejection from "rotate" to "remember".
6247
+ */
6248
+ const ENTITLEMENT_ERROR_CODES = new Set([
6249
+ "oauth_not_allowed_for_organization",
6250
+ "oauth_not_allowed",
6251
+ "organization_disabled",
6252
+ ]);
6253
+ /**
6254
+ * An entitlement rejection: Anthropic refuses THIS credential on organization
6255
+ * or plan policy, e.g. `403 permission_error` /
6256
+ * "OAuth authentication is currently not allowed for this organization."
6257
+ *
6258
+ * The taxonomy makes this safe to rotate on: `permission_error` is reserved for
6259
+ * credential/organization permission, distinct from `invalid_request_error`
6260
+ * (request shape) and `not_found_error`, both already terminal above. No
6261
+ * `permission_error` is caused by the request body, so the identical request
6262
+ * can succeed on a different account.
6263
+ *
6264
+ * Deliberately broad — rotation is cheap and reversible. Persisting the block
6265
+ * is gated on the narrower {@link isDurableEntitlementBlock}.
6266
+ */
6267
+ export function isAccountEntitlementError(status, errBody) {
6268
+ if (status !== 401 && status !== 402 && status !== 403) {
6269
+ return false;
6270
+ }
6271
+ const parsed = parseClaudeErrorBody(errBody);
6272
+ if (parsed.errorType === "permission_error") {
6273
+ return true;
6274
+ }
6275
+ return (parsed.errorCode !== undefined &&
6276
+ ENTITLEMENT_ERROR_CODES.has(parsed.errorCode));
6277
+ }
6278
+ /**
6279
+ * Whether an entitlement rejection is specific enough to disable the account
6280
+ * until someone re-enables it. Narrower than {@link isAccountEntitlementError}
6281
+ * because disabling is sticky and user-visible: an unrecognised
6282
+ * `permission_error` should rotate through the pool and surface a 403, never
6283
+ * durably remove a credential on a guess.
6284
+ */
6285
+ export function isDurableEntitlementBlock(status, errBody) {
6286
+ if (!isAccountEntitlementError(status, errBody)) {
6287
+ return false;
6288
+ }
6289
+ const parsed = parseClaudeErrorBody(errBody);
6290
+ return (parsed.errorCode !== undefined &&
6291
+ ENTITLEMENT_ERROR_CODES.has(parsed.errorCode));
6292
+ }
5717
6293
  function normalizeClaudeRequestForAnthropic(body) {
5718
6294
  return {
5719
6295
  ...body,
@@ -5797,8 +6373,8 @@ export const __testHooks = {
5797
6373
  scheduleAdaptiveQuotaRefreshes,
5798
6374
  scheduleHandoffQuotaRefresh,
5799
6375
  getQuotaRefreshState: (key) => accountQuotaRefreshCoordinator.getState(key),
5800
- buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs()) => {
5801
- const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs);
6376
+ buildQuotaRoutingDecision: (accounts, now, primaryKey, sessionSoftLimit = getSessionSoftLimit(), sessionResetToleranceMs = getSessionResetToleranceMs(), requestedModel) => {
6377
+ const order = orderAccountsByQuotaWithMetrics(accounts, now, primaryKey, sessionSoftLimit, sessionResetToleranceMs, requestedModel);
5802
6378
  return buildRoutingDecision({
5803
6379
  accounts,
5804
6380
  orderedAccounts: order.orderedAccounts,
@@ -5870,4 +6446,9 @@ export const __testHooks = {
5870
6446
  shouldAttemptClaudeFallback,
5871
6447
  executeClaudeFallbackWithRetry,
5872
6448
  buildClaudeAnthropicFailureResponse,
6449
+ isAccountEntitlementError,
6450
+ isDurableEntitlementBlock,
6451
+ evaluateScopedExhaustion,
6452
+ matchScopedQuotaWindow,
6453
+ setOveragePolicy,
5873
6454
  };