@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.
@@ -1,4 +1,4 @@
1
- import type { ProxyAccountRoutingReason, ProxyAccountSortMetrics, ProxyPassthroughAccount } from "../types/index.js";
1
+ import type { ProxyAccountPrecedenceReason, ProxyAccountRankingPolicy, ProxyAccountRoutingReason, ProxyAccountSortMetrics, ProxyAccountSpillMove, ProxyAffinityPrecedenceSkipReason, ProxyPassthroughAccount } from "../types/index.js";
2
2
  /**
3
3
  * The pre-existing "spend the soonest-expiring weekly allowance first" order.
4
4
  * Moved verbatim from claudeProxyRoutes.ts — availability → quota-evidence
@@ -8,15 +8,78 @@ import type { ProxyAccountRoutingReason, ProxyAccountSortMetrics, ProxyPassthrou
8
8
  */
9
9
  export declare function compareExpiryFirst(a: ProxyPassthroughAccount, b: ProxyPassthroughAccount, metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>, primaryKey: string | undefined): [number, ProxyAccountRoutingReason];
10
10
  /**
11
- * Orders accounts by the configured ranking and reports the reason that
12
- * placed rank 0. PR1 ships only `expiry-first`; PR2 (Task 11) adds
13
- * `headroom-first` plus the affinity/prefer-primary precedence layer.
11
+ * Availability → quota-evidence quality → session saturation → model-scoped
12
+ * saturation → highest headroom (min(1 - sessionUsed, 1 - weeklyUsed), reset-
13
+ * freshened via the same accountSortMetrics values compareExpiryFirst uses)
14
+ * → soonest weekly reset → configured primary → insertion order. An account
15
+ * whose headroom cannot be computed sorts after every account whose can.
16
+ */
17
+ export declare function compareHeadroomFirst(a: ProxyPassthroughAccount, b: ProxyPassthroughAccount, metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>, primaryKey: string | undefined): [number, ProxyAccountRoutingReason];
18
+ /**
19
+ * Whether an account may hold a session binding or take the primary's place:
20
+ * usable and not session-saturated.
21
+ */
22
+ export declare function isPrecedenceEligible(metrics: ProxyAccountSortMetrics | undefined): boolean;
23
+ /**
24
+ * Applies the precedence order a fill-first request tries: (1) the bound
25
+ * (session-affinity) account, (2) the configured primary, (3) the rest in
26
+ * ranking order. Shared by rankAccounts (for the quota-ordered path) and the
27
+ * route directly (for quota-off fill-first, where the base order is never
28
+ * comparator-sorted but affinity/prefer-primary must still apply per spec).
29
+ * `strategy: round-robin` ignores all five policy keys and never calls this.
30
+ *
31
+ * When affinity applies and prefer-primary is also on, and the configured
32
+ * primary is itself eligible (usable, not session-saturated) and is not the
33
+ * bound account, the primary is placed second — [bound, primary, ...rest] —
34
+ * so a request that cannot use its bound account's session still prefers
35
+ * the primary over the rest of the ranking. The reported reason stays
36
+ * "session_affinity" since the bound account still won rank 0.
37
+ */
38
+ export declare function applyAffinityAndPrimary(args: {
39
+ orderedAccounts: ProxyPassthroughAccount[];
40
+ metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
41
+ affinityKey?: string;
42
+ primaryKey?: string;
43
+ preferPrimary?: boolean;
44
+ }): {
45
+ orderedAccounts: ProxyPassthroughAccount[];
46
+ reason: ProxyAccountPrecedenceReason | null;
47
+ affinitySkippedReason: ProxyAffinityPrecedenceSkipReason | null;
48
+ };
49
+ /**
50
+ * Orders accounts by the configured ranking (`expiry-first` or
51
+ * `headroom-first`), applies the affinity/prefer-primary precedence, and
52
+ * reports the reason that placed rank 0.
14
53
  */
15
54
  export declare function rankAccounts(args: {
16
55
  accounts: ProxyPassthroughAccount[];
17
56
  metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
18
57
  primaryKey: string | undefined;
58
+ ranking?: ProxyAccountRankingPolicy;
59
+ affinityKey?: string;
60
+ preferPrimary?: boolean;
19
61
  }): {
20
62
  orderedAccounts: ProxyPassthroughAccount[];
21
63
  reason: ProxyAccountRoutingReason;
64
+ affinitySkippedReason: ProxyAffinityPrecedenceSkipReason | null;
65
+ };
66
+ /**
67
+ * For a request without an active binding: if the current first choice is
68
+ * already at spillInflight in-flight requests, try the next account below
69
+ * that threshold that is usable and not session-saturated — the same
70
+ * eligibility as every precedence step. An account with no metrics, an
71
+ * unusable one or a saturated one is never a spill target even if its
72
+ * in-flight count is low. If there is no eligible account under the
73
+ * threshold, spill does nothing. `spill.inflight` records the SOURCE
74
+ * account's count — the number that triggered the move, not the
75
+ * destination's.
76
+ */
77
+ export declare function applySpill(args: {
78
+ orderedAccounts: ProxyPassthroughAccount[];
79
+ inflightByKey: ReadonlyMap<string, number>;
80
+ metricsByKey: ReadonlyMap<string, ProxyAccountSortMetrics>;
81
+ spillInflight: number;
82
+ }): {
83
+ orderedAccounts: ProxyPassthroughAccount[];
84
+ spill: ProxyAccountSpillMove | null;
22
85
  };
@@ -71,16 +71,196 @@ export function compareExpiryFirst(a, b, metricsByKey, primaryKey) {
71
71
  }
72
72
  return [0, "insertion_order"];
73
73
  }
74
+ function headroomFor(m) {
75
+ if (m.sessionUsed === null || m.weeklyUsed === null) {
76
+ return null;
77
+ }
78
+ return Math.min(1 - m.sessionUsed, 1 - m.weeklyUsed);
79
+ }
74
80
  /**
75
- * Orders accounts by the configured ranking and reports the reason that
76
- * placed rank 0. PR1 ships only `expiry-first`; PR2 (Task 11) adds
77
- * `headroom-first` plus the affinity/prefer-primary precedence layer.
81
+ * Availability → quota-evidence quality → session saturation → model-scoped
82
+ * saturation → highest headroom (min(1 - sessionUsed, 1 - weeklyUsed), reset-
83
+ * freshened via the same accountSortMetrics values compareExpiryFirst uses)
84
+ * → soonest weekly reset → configured primary → insertion order. An account
85
+ * whose headroom cannot be computed sorts after every account whose can.
86
+ */
87
+ export function compareHeadroomFirst(a, b, metricsByKey, primaryKey) {
88
+ const ma = metricsByKey.get(a.key);
89
+ const mb = metricsByKey.get(b.key);
90
+ if (!ma || !mb) {
91
+ return [0, "insertion_order"];
92
+ }
93
+ if (ma.usable !== mb.usable) {
94
+ return [ma.usable ? -1 : 1, "availability"];
95
+ }
96
+ if (!ma.usable && !mb.usable) {
97
+ const au = ma.coolingUntil || Number.POSITIVE_INFINITY;
98
+ const bu = mb.coolingUntil || Number.POSITIVE_INFINITY;
99
+ return [
100
+ au === bu ? 0 : au - bu,
101
+ au === bu ? "insertion_order" : "cooldown_recovery",
102
+ ];
103
+ }
104
+ if (ma.quotaEvidenceRank !== mb.quotaEvidenceRank) {
105
+ return [ma.quotaEvidenceRank - mb.quotaEvidenceRank, "quota_evidence"];
106
+ }
107
+ if (ma.saturated !== mb.saturated) {
108
+ return [ma.saturated ? 1 : -1, "session_headroom"];
109
+ }
110
+ if (ma.scopedSaturated !== mb.scopedSaturated) {
111
+ return [ma.scopedSaturated ? 1 : -1, "scoped_headroom"];
112
+ }
113
+ const ha = headroomFor(ma);
114
+ const hb = headroomFor(mb);
115
+ if (ha !== hb) {
116
+ if (ha === null) {
117
+ return [1, "headroom"];
118
+ }
119
+ if (hb === null) {
120
+ return [-1, "headroom"];
121
+ }
122
+ return [hb - ha, "headroom"];
123
+ }
124
+ if (ma.weeklyReset !== mb.weeklyReset) {
125
+ return [ma.weeklyReset - mb.weeklyReset, "weekly_reset"];
126
+ }
127
+ if (primaryKey && (a.key === primaryKey) !== (b.key === primaryKey)) {
128
+ return [a.key === primaryKey ? -1 : 1, "configured_primary"];
129
+ }
130
+ return [0, "insertion_order"];
131
+ }
132
+ /**
133
+ * Whether an account may hold a session binding or take the primary's place:
134
+ * usable and not session-saturated.
135
+ */
136
+ export function isPrecedenceEligible(metrics) {
137
+ return !!metrics && metrics.usable && !metrics.saturated;
138
+ }
139
+ /**
140
+ * Applies the precedence order a fill-first request tries: (1) the bound
141
+ * (session-affinity) account, (2) the configured primary, (3) the rest in
142
+ * ranking order. Shared by rankAccounts (for the quota-ordered path) and the
143
+ * route directly (for quota-off fill-first, where the base order is never
144
+ * comparator-sorted but affinity/prefer-primary must still apply per spec).
145
+ * `strategy: round-robin` ignores all five policy keys and never calls this.
146
+ *
147
+ * When affinity applies and prefer-primary is also on, and the configured
148
+ * primary is itself eligible (usable, not session-saturated) and is not the
149
+ * bound account, the primary is placed second — [bound, primary, ...rest] —
150
+ * so a request that cannot use its bound account's session still prefers
151
+ * the primary over the rest of the ranking. The reported reason stays
152
+ * "session_affinity" since the bound account still won rank 0.
153
+ */
154
+ export function applyAffinityAndPrimary(args) {
155
+ const { orderedAccounts, metricsByKey, affinityKey, primaryKey, preferPrimary = false, } = args;
156
+ const isEligible = (key) => isPrecedenceEligible(metricsByKey.get(key));
157
+ let affinitySkippedReason = null;
158
+ if (affinityKey) {
159
+ const bound = orderedAccounts.find((a) => a.key === affinityKey);
160
+ if (bound && isEligible(affinityKey)) {
161
+ const primaryGoesSecond = preferPrimary &&
162
+ !!primaryKey &&
163
+ primaryKey !== affinityKey &&
164
+ isEligible(primaryKey);
165
+ const primary = primaryGoesSecond
166
+ ? orderedAccounts.find((a) => a.key === primaryKey)
167
+ : undefined;
168
+ const remainder = orderedAccounts.filter((a) => a.key !== affinityKey && a.key !== primary?.key);
169
+ return {
170
+ orderedAccounts: primary
171
+ ? [bound, primary, ...remainder]
172
+ : [bound, ...remainder],
173
+ reason: "session_affinity",
174
+ affinitySkippedReason: null,
175
+ };
176
+ }
177
+ const boundMetrics = metricsByKey.get(affinityKey);
178
+ affinitySkippedReason =
179
+ !bound || !boundMetrics?.usable ? "unusable" : "session_saturated";
180
+ }
181
+ if (preferPrimary && primaryKey) {
182
+ const primary = orderedAccounts.find((a) => a.key === primaryKey);
183
+ if (primary && isEligible(primaryKey)) {
184
+ return {
185
+ orderedAccounts: [
186
+ primary,
187
+ ...orderedAccounts.filter((a) => a.key !== primaryKey),
188
+ ],
189
+ reason: "preferred_primary",
190
+ affinitySkippedReason,
191
+ };
192
+ }
193
+ }
194
+ return { orderedAccounts, reason: null, affinitySkippedReason };
195
+ }
196
+ /**
197
+ * Orders accounts by the configured ranking (`expiry-first` or
198
+ * `headroom-first`), applies the affinity/prefer-primary precedence, and
199
+ * reports the reason that placed rank 0.
78
200
  */
79
201
  export function rankAccounts(args) {
80
- const { accounts, metricsByKey, primaryKey } = args;
81
- const orderedAccounts = [...accounts].sort((a, b) => compareExpiryFirst(a, b, metricsByKey, primaryKey)[0]);
82
- const reason = orderedAccounts.length < 2
202
+ const { accounts, metricsByKey, primaryKey, ranking = "expiry-first", affinityKey, preferPrimary, } = args;
203
+ const compare = ranking === "headroom-first" ? compareHeadroomFirst : compareExpiryFirst;
204
+ const baseOrder = [...accounts].sort((a, b) => compare(a, b, metricsByKey, primaryKey)[0]);
205
+ const precedence = applyAffinityAndPrimary({
206
+ orderedAccounts: baseOrder,
207
+ metricsByKey,
208
+ affinityKey,
209
+ primaryKey,
210
+ preferPrimary,
211
+ });
212
+ if (precedence.reason) {
213
+ return {
214
+ orderedAccounts: precedence.orderedAccounts,
215
+ reason: precedence.reason,
216
+ affinitySkippedReason: precedence.affinitySkippedReason,
217
+ };
218
+ }
219
+ const reason = baseOrder.length < 2
83
220
  ? "single_account"
84
- : compareExpiryFirst(orderedAccounts[0], orderedAccounts[1], metricsByKey, primaryKey)[1];
85
- return { orderedAccounts, reason };
221
+ : compare(baseOrder[0], baseOrder[1], metricsByKey, primaryKey)[1];
222
+ return {
223
+ orderedAccounts: baseOrder,
224
+ reason,
225
+ affinitySkippedReason: precedence.affinitySkippedReason,
226
+ };
227
+ }
228
+ /**
229
+ * For a request without an active binding: if the current first choice is
230
+ * already at spillInflight in-flight requests, try the next account below
231
+ * that threshold that is usable and not session-saturated — the same
232
+ * eligibility as every precedence step. An account with no metrics, an
233
+ * unusable one or a saturated one is never a spill target even if its
234
+ * in-flight count is low. If there is no eligible account under the
235
+ * threshold, spill does nothing. `spill.inflight` records the SOURCE
236
+ * account's count — the number that triggered the move, not the
237
+ * destination's.
238
+ */
239
+ export function applySpill(args) {
240
+ const { orderedAccounts, inflightByKey, metricsByKey, spillInflight } = args;
241
+ const first = orderedAccounts[0];
242
+ if (spillInflight <= 0 || !first) {
243
+ return { orderedAccounts, spill: null };
244
+ }
245
+ const firstInflight = inflightByKey.get(first.key) ?? 0;
246
+ if (firstInflight < spillInflight) {
247
+ return { orderedAccounts, spill: null };
248
+ }
249
+ const rest = orderedAccounts.slice(1);
250
+ const targetIndex = rest.findIndex((account) => isPrecedenceEligible(metricsByKey.get(account.key)) &&
251
+ (inflightByKey.get(account.key) ?? 0) < spillInflight);
252
+ if (targetIndex === -1) {
253
+ return { orderedAccounts, spill: null };
254
+ }
255
+ const target = rest[targetIndex];
256
+ const reordered = [
257
+ target,
258
+ first,
259
+ ...rest.slice(0, targetIndex),
260
+ ...rest.slice(targetIndex + 1),
261
+ ];
262
+ return {
263
+ orderedAccounts: reordered,
264
+ spill: { from: first.key, to: target.key, inflight: firstInflight },
265
+ };
86
266
  }
@@ -25,6 +25,17 @@ const ROUTING_STRATEGIES = new Set(PROXY_ACCOUNT_ROUTING_STRATEGIES);
25
25
  const ROUTING_MODES = new Set(PROXY_ACCOUNT_ROUTING_MODES);
26
26
  const ROUTING_REASONS = new Set(PROXY_ACCOUNT_ROUTING_REASONS);
27
27
  const ROUTING_ACCOUNT_TYPES = new Set(PROXY_ACCOUNT_TYPES);
28
+ const ROUTING_RANKINGS = new Set([
29
+ "expiry-first",
30
+ "headroom-first",
31
+ ]);
32
+ const AFFINITY_SKIP_REASONS = new Set([
33
+ "unusable",
34
+ "session_saturated",
35
+ "expired",
36
+ "no_session",
37
+ "disabled",
38
+ ]);
28
39
  const COOLING_REASONS = new Set(ACCOUNT_COOLING_REASONS);
29
40
  const QUOTA_FRESHNESS_VALUES = new Set([
30
41
  "unknown",
@@ -211,6 +222,61 @@ function routingCandidateValue(value) {
211
222
  scopedResetAt: candidate.scopedResetAt,
212
223
  };
213
224
  }
225
+ function plainObject(value) {
226
+ return value && typeof value === "object" && !Array.isArray(value)
227
+ ? value
228
+ : null;
229
+ }
230
+ function routingPolicyValue(value) {
231
+ const policy = plainObject(value);
232
+ if (!policy ||
233
+ typeof policy.ranking !== "string" ||
234
+ !ROUTING_RANKINGS.has(policy.ranking) ||
235
+ typeof policy.preferPrimary !== "boolean" ||
236
+ typeof policy.sessionAffinity !== "boolean" ||
237
+ !Number.isInteger(policy.sessionAffinityIdleTtlMs) ||
238
+ !Number.isInteger(policy.spillInflight)) {
239
+ return undefined;
240
+ }
241
+ return {
242
+ ranking: policy.ranking,
243
+ preferPrimary: policy.preferPrimary,
244
+ sessionAffinity: policy.sessionAffinity,
245
+ sessionAffinityIdleTtlMs: policy.sessionAffinityIdleTtlMs,
246
+ spillInflight: policy.spillInflight,
247
+ };
248
+ }
249
+ function routingAffinityValue(value) {
250
+ const affinity = plainObject(value);
251
+ if (!affinity ||
252
+ typeof affinity.sessionBound !== "boolean" ||
253
+ !isNullableString(affinity.boundAccount) ||
254
+ typeof affinity.applied !== "boolean" ||
255
+ (affinity.skippedReason !== null &&
256
+ (typeof affinity.skippedReason !== "string" ||
257
+ !AFFINITY_SKIP_REASONS.has(affinity.skippedReason)))) {
258
+ return undefined;
259
+ }
260
+ return {
261
+ sessionBound: affinity.sessionBound,
262
+ boundAccount: affinity.boundAccount,
263
+ applied: affinity.applied,
264
+ skippedReason: affinity.skippedReason,
265
+ };
266
+ }
267
+ function routingSpillValue(value) {
268
+ const spill = plainObject(value);
269
+ const from = stringValue(spill?.from);
270
+ const to = stringValue(spill?.to);
271
+ if (!spill ||
272
+ !from ||
273
+ !to ||
274
+ !Number.isInteger(spill.inflight) ||
275
+ spill.inflight < 0) {
276
+ return undefined;
277
+ }
278
+ return { from, to, inflight: spill.inflight };
279
+ }
214
280
  function routingDecisionValue(value) {
215
281
  if (!value || typeof value !== "object" || Array.isArray(value)) {
216
282
  return null;
@@ -259,8 +325,18 @@ function routingDecisionValue(value) {
259
325
  rotationOffset: decision.rotationOffset,
260
326
  initialAccount: decision.initialAccount,
261
327
  candidates: candidates,
328
+ ...optionalEvidence("policy", routingPolicyValue(decision.policy)),
329
+ ...optionalEvidence("affinity", routingAffinityValue(decision.affinity)),
330
+ ...optionalEvidence("spill", routingSpillValue(decision.spill)),
262
331
  };
263
332
  }
333
+ // Evidence is optional per decision; a field that is absent or malformed is
334
+ // left out rather than rejecting a decision that is otherwise valid.
335
+ function optionalEvidence(key, value) {
336
+ return value === undefined
337
+ ? {}
338
+ : { [key]: value };
339
+ }
264
340
  function percentile(sorted, fraction) {
265
341
  if (sorted.length === 0) {
266
342
  return null;
@@ -10,7 +10,7 @@
10
10
  * YAML parsing uses `js-yaml` when available (dynamic import), otherwise
11
11
  * falls back to JSON.parse.
12
12
  */
13
- import type { LoadProxyConfigOptions, ProxyConfigFile } from "../types/index.js";
13
+ import type { LoadProxyConfigOptions, ProxyConfigFile, ProxyRoutingConfig } from "../types/index.js";
14
14
  /** Default on-disk path used when `proxy start` is not given `--config`. */
15
15
  export declare function defaultProxyConfigPath(): string;
16
16
  /** Resolve `--config` the same way `proxy start` / `proxy install` do. */
@@ -30,6 +30,29 @@ export declare function resolveEnvVars(value: string, env?: Record<string, strin
30
30
  * Returns an array of human-readable error strings (empty = valid).
31
31
  */
32
32
  export declare function validateProxyConfig(config: unknown): string[];
33
+ /**
34
+ * Parse the optional `routing` section from a raw proxy config object.
35
+ *
36
+ * Extracts:
37
+ * - `strategy` ("round-robin" | "fill-first")
38
+ * - `model-mappings` / `modelMappings` — array of {from, to, provider}
39
+ * - `fallback-chain` / `fallbackChain` — array of {provider, model, reasoningEffort?}
40
+ * - `auto-fallback` / `autoFallback` — opt in to an unspecified provider
41
+ * - `max-inflight-per-account` / `maxInflightPerAccount` — concurrency cap
42
+ * - `passthroughModels` / `passthrough-models` — array of model IDs
43
+ * - `quota-routing` / `quotaRouting` — quota-aware fill-first ordering
44
+ * - `session-soft-limit` / `sessionSoftLimit` — proactive handoff threshold
45
+ * - `session-reset-tolerance-ms` / `sessionResetToleranceMs` — reset bucket
46
+ * - `account-allowlist` / `accountAllowlist` — allowed Anthropic account IDs
47
+ * - `account-ranking` / `accountRanking` — expiry-first | headroom-first
48
+ * - `prefer-primary` / `preferPrimary` — bias ranking toward primaryAccount
49
+ * - `session-affinity` / `sessionAffinity` — sticky-session binding
50
+ * - `session-affinity-idle-ttl-ms` / `sessionAffinityIdleTtlMs` — idle unbind
51
+ * - `spill-inflight` / `spillInflight` — overflow threshold for sticky sessions
52
+ *
53
+ * Accepts both camelCase and kebab-case keys for YAML-friendliness.
54
+ */
55
+ export declare function parseRoutingConfig(raw: Record<string, unknown> | undefined): Partial<ProxyRoutingConfig> | undefined;
33
56
  /**
34
57
  * Load and parse a proxy configuration file (YAML or JSON).
35
58
  *
@@ -213,6 +213,10 @@ const CODEX_REASONING_EFFORTS = [
213
213
  function isCodexReasoningEffort(value) {
214
214
  return CODEX_REASONING_EFFORTS.some((effort) => effort === value);
215
215
  }
216
+ const MIN_SESSION_AFFINITY_IDLE_TTL_MS = 60_000;
217
+ const MAX_SESSION_AFFINITY_IDLE_TTL_MS = 86_400_000;
218
+ const MIN_SPILL_INFLIGHT = 0;
219
+ const MAX_SPILL_INFLIGHT = 100;
216
220
  function kebabToCamelRoutingKey(kebabKey) {
217
221
  return kebabKey.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
218
222
  }
@@ -243,6 +247,20 @@ function readLegacyRoutingKey(routing, kebabKey) {
243
247
  const wasNull = value === undefined && (rawKebab === null || rawCamel === null);
244
248
  return { value, wasNull };
245
249
  }
250
+ /**
251
+ * Reads a routing policy key under its kebab-case or camelCase spelling,
252
+ * choosing by presence rather than with `??`: `??` treats an explicit
253
+ * kebab-case `null` as absent and falls through to an unset camelCase key,
254
+ * so the same null would be silently defaulted under one spelling and
255
+ * rejected under the other. Unlike `readLegacyRoutingKey`, a `null` is
256
+ * returned as-is so validation rejects it: these five keys never accepted it.
257
+ */
258
+ function readRoutingPolicyKey(routing, kebabKey) {
259
+ const camelKey = kebabToCamelRoutingKey(kebabKey);
260
+ return routing[kebabKey] !== undefined
261
+ ? routing[kebabKey]
262
+ : routing[camelKey];
263
+ }
246
264
  /**
247
265
  * Validate the shape of a parsed proxy config.
248
266
  * Returns an array of human-readable error strings (empty = valid).
@@ -359,6 +377,48 @@ export function validateProxyConfig(config) {
359
377
  errors.push("routing.session-reset-tolerance-ms must be a positive integer");
360
378
  }
361
379
  }
380
+ const rawAccountRanking = readRoutingPolicyKey(routing, "account-ranking");
381
+ if (rawAccountRanking !== undefined &&
382
+ rawAccountRanking !== "expiry-first" &&
383
+ rawAccountRanking !== "headroom-first") {
384
+ errors.push("routing.account-ranking must be expiry-first or headroom-first");
385
+ }
386
+ const rawPreferPrimary = readRoutingPolicyKey(routing, "prefer-primary");
387
+ const normalizedPreferPrimary = typeof rawPreferPrimary === "string"
388
+ ? rawPreferPrimary.trim().toLowerCase()
389
+ : undefined;
390
+ if (rawPreferPrimary !== undefined &&
391
+ typeof rawPreferPrimary !== "boolean" &&
392
+ normalizedPreferPrimary !== "true" &&
393
+ normalizedPreferPrimary !== "false") {
394
+ errors.push("routing.prefer-primary must be a boolean");
395
+ }
396
+ const rawSessionAffinity = readRoutingPolicyKey(routing, "session-affinity");
397
+ const normalizedSessionAffinity = typeof rawSessionAffinity === "string"
398
+ ? rawSessionAffinity.trim().toLowerCase()
399
+ : undefined;
400
+ if (rawSessionAffinity !== undefined &&
401
+ typeof rawSessionAffinity !== "boolean" &&
402
+ normalizedSessionAffinity !== "true" &&
403
+ normalizedSessionAffinity !== "false") {
404
+ errors.push("routing.session-affinity must be a boolean");
405
+ }
406
+ const rawAffinityTtl = readRoutingPolicyKey(routing, "session-affinity-idle-ttl-ms");
407
+ if (rawAffinityTtl !== undefined &&
408
+ (typeof rawAffinityTtl !== "number" ||
409
+ !Number.isInteger(rawAffinityTtl) ||
410
+ rawAffinityTtl < MIN_SESSION_AFFINITY_IDLE_TTL_MS ||
411
+ rawAffinityTtl > MAX_SESSION_AFFINITY_IDLE_TTL_MS)) {
412
+ errors.push("routing.session-affinity-idle-ttl-ms must be an integer between 60000 and 86400000");
413
+ }
414
+ const rawSpillInflight = readRoutingPolicyKey(routing, "spill-inflight");
415
+ if (rawSpillInflight !== undefined &&
416
+ (typeof rawSpillInflight !== "number" ||
417
+ !Number.isInteger(rawSpillInflight) ||
418
+ rawSpillInflight < MIN_SPILL_INFLIGHT ||
419
+ rawSpillInflight > MAX_SPILL_INFLIGHT)) {
420
+ errors.push("routing.spill-inflight must be an integer between 0 and 100");
421
+ }
362
422
  }
363
423
  if (!hasAccounts && !hasRouting) {
364
424
  errors.push('Config must contain at least one of "accounts" or "routing"');
@@ -434,10 +494,15 @@ function warnPlaintextApiKeys(accounts) {
434
494
  * - `session-soft-limit` / `sessionSoftLimit` — proactive handoff threshold
435
495
  * - `session-reset-tolerance-ms` / `sessionResetToleranceMs` — reset bucket
436
496
  * - `account-allowlist` / `accountAllowlist` — allowed Anthropic account IDs
497
+ * - `account-ranking` / `accountRanking` — expiry-first | headroom-first
498
+ * - `prefer-primary` / `preferPrimary` — bias ranking toward primaryAccount
499
+ * - `session-affinity` / `sessionAffinity` — sticky-session binding
500
+ * - `session-affinity-idle-ttl-ms` / `sessionAffinityIdleTtlMs` — idle unbind
501
+ * - `spill-inflight` / `spillInflight` — overflow threshold for sticky sessions
437
502
  *
438
503
  * Accepts both camelCase and kebab-case keys for YAML-friendliness.
439
504
  */
440
- function parseRoutingConfig(raw) {
505
+ export function parseRoutingConfig(raw) {
441
506
  if (!raw || typeof raw !== "object") {
442
507
  return undefined;
443
508
  }
@@ -603,8 +668,10 @@ function parseRoutingConfig(raw) {
603
668
  }
604
669
  }
605
670
  // Primary account (accept kebab-case or camelCase). Email or label of the
606
- // Anthropic account that should be tried first ("home"). Resolved to a
607
- // stable key (anthropic:<email>) at proxy boot; absence preserves the
671
+ // Anthropic account used as "home": under quota routing it is the
672
+ // ranking's final tiebreaker unless prefer-primary is set, and it is
673
+ // tried first only when quota routing is disabled. Resolved to a stable
674
+ // key (anthropic:<email>) at proxy boot; absence preserves the
608
675
  // pre-existing insertion-order behavior.
609
676
  const rawPrimary = (raw["primary-account"] ?? raw.primaryAccount);
610
677
  if (rawPrimary !== undefined) {
@@ -626,6 +693,67 @@ function parseRoutingConfig(raw) {
626
693
  ...new Set(rawAccountAllowlist.map((entry) => String(entry).trim())),
627
694
  ];
628
695
  }
696
+ const rawAccountRanking = readRoutingPolicyKey(raw, "account-ranking");
697
+ if (rawAccountRanking !== undefined) {
698
+ if (rawAccountRanking === "expiry-first" ||
699
+ rawAccountRanking === "headroom-first") {
700
+ result.accountRanking = rawAccountRanking;
701
+ }
702
+ else {
703
+ logger.warn(`[proxy-config] Ignoring routing.accountRanking: expected expiry-first|headroom-first, got ${String(rawAccountRanking)}`);
704
+ }
705
+ }
706
+ const rawPreferPrimary = readRoutingPolicyKey(raw, "prefer-primary");
707
+ if (rawPreferPrimary !== undefined) {
708
+ if (typeof rawPreferPrimary === "boolean") {
709
+ result.preferPrimary = rawPreferPrimary;
710
+ }
711
+ else if (typeof rawPreferPrimary === "string" &&
712
+ ["true", "false"].includes(rawPreferPrimary.trim().toLowerCase())) {
713
+ result.preferPrimary = rawPreferPrimary.trim().toLowerCase() === "true";
714
+ }
715
+ else {
716
+ logger.warn(`[proxy-config] Ignoring routing.preferPrimary: expected boolean, got ${typeof rawPreferPrimary}`);
717
+ }
718
+ }
719
+ const rawSessionAffinity = readRoutingPolicyKey(raw, "session-affinity");
720
+ if (rawSessionAffinity !== undefined) {
721
+ if (typeof rawSessionAffinity === "boolean") {
722
+ result.sessionAffinity = rawSessionAffinity;
723
+ }
724
+ else if (typeof rawSessionAffinity === "string" &&
725
+ ["true", "false"].includes(rawSessionAffinity.trim().toLowerCase())) {
726
+ result.sessionAffinity =
727
+ rawSessionAffinity.trim().toLowerCase() === "true";
728
+ }
729
+ else {
730
+ logger.warn(`[proxy-config] Ignoring routing.sessionAffinity: expected boolean, got ${typeof rawSessionAffinity}`);
731
+ }
732
+ }
733
+ const rawAffinityTtl = readRoutingPolicyKey(raw, "session-affinity-idle-ttl-ms");
734
+ if (rawAffinityTtl !== undefined) {
735
+ if (typeof rawAffinityTtl === "number" &&
736
+ Number.isInteger(rawAffinityTtl) &&
737
+ rawAffinityTtl >= MIN_SESSION_AFFINITY_IDLE_TTL_MS &&
738
+ rawAffinityTtl <= MAX_SESSION_AFFINITY_IDLE_TTL_MS) {
739
+ result.sessionAffinityIdleTtlMs = rawAffinityTtl;
740
+ }
741
+ else {
742
+ logger.warn(`[proxy-config] Ignoring routing.sessionAffinityIdleTtlMs: expected integer between 60000 and 86400000, got ${String(rawAffinityTtl)}`);
743
+ }
744
+ }
745
+ const rawSpillInflight = readRoutingPolicyKey(raw, "spill-inflight");
746
+ if (rawSpillInflight !== undefined) {
747
+ if (typeof rawSpillInflight === "number" &&
748
+ Number.isInteger(rawSpillInflight) &&
749
+ rawSpillInflight >= MIN_SPILL_INFLIGHT &&
750
+ rawSpillInflight <= MAX_SPILL_INFLIGHT) {
751
+ result.spillInflight = rawSpillInflight;
752
+ }
753
+ else {
754
+ logger.warn(`[proxy-config] Ignoring routing.spillInflight: expected integer between 0 and 100, got ${String(rawSpillInflight)}`);
755
+ }
756
+ }
629
757
  return result;
630
758
  }
631
759
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  /** Schema-v1 routing evidence values shared by emitters and offline readers. */
2
2
  export declare const PROXY_ACCOUNT_ROUTING_STRATEGIES: readonly ["round-robin", "fill-first"];
3
3
  export declare const PROXY_ACCOUNT_ROUTING_MODES: readonly ["quota", "primary", "round_robin", "single_account"];
4
- export declare const PROXY_ACCOUNT_ROUTING_REASONS: readonly ["single_account", "round_robin", "configured_primary", "insertion_order", "availability", "cooldown_recovery", "quota_evidence", "quota_probe", "session_headroom", "scoped_headroom", "session_reset", "weekly_reset", "weekly_utilization", "scoped_utilization"];
4
+ export declare const PROXY_ACCOUNT_ROUTING_REASONS: readonly ["single_account", "round_robin", "configured_primary", "insertion_order", "availability", "cooldown_recovery", "quota_evidence", "quota_probe", "session_headroom", "scoped_headroom", "session_reset", "weekly_reset", "weekly_utilization", "scoped_utilization", "headroom", "session_affinity", "preferred_primary", "spill_inflight", "routing_policy_error"];
5
5
  export declare const PROXY_ACCOUNT_TYPES: readonly ["oauth", "api_key"];
6
6
  export declare const ACCOUNT_COOLING_REASONS: readonly ["weekly", "session", "unified", "transient", "auth"];
7
7
  /**
@@ -26,6 +26,11 @@ export const PROXY_ACCOUNT_ROUTING_REASONS = [
26
26
  "weekly_reset",
27
27
  "weekly_utilization",
28
28
  "scoped_utilization",
29
+ "headroom",
30
+ "session_affinity",
31
+ "preferred_primary",
32
+ "spill_inflight",
33
+ "routing_policy_error",
29
34
  ];
30
35
  export const PROXY_ACCOUNT_TYPES = ["oauth", "api_key"];
31
36
  export const ACCOUNT_COOLING_REASONS = [
@@ -229,6 +229,11 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
229
229
  .digest("hex")
230
230
  .slice(0, 16);
231
231
  const useOverage = routing?.useOverage ?? "auto";
232
+ const accountRanking = routing?.accountRanking ?? "expiry-first";
233
+ const preferPrimary = routing?.preferPrimary ?? false;
234
+ const sessionAffinity = routing?.sessionAffinity ?? false;
235
+ const sessionAffinityIdleTtlMs = routing?.sessionAffinityIdleTtlMs ?? 3_600_000;
236
+ const spillInflight = routing?.spillInflight ?? 0;
232
237
  const fingerprintSource = JSON.stringify({
233
238
  strategy,
234
239
  passthrough: options.passthrough,
@@ -239,6 +244,11 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
239
244
  sessionSoftLimit,
240
245
  sessionResetToleranceMs,
241
246
  useOverage,
247
+ accountRanking,
248
+ preferPrimary,
249
+ sessionAffinity,
250
+ sessionAffinityIdleTtlMs,
251
+ spillInflight,
242
252
  });
243
253
  const configHash = createHash("sha256")
244
254
  .update(fingerprintSource)
@@ -259,6 +269,11 @@ async function buildCandidate(options, generation, allowMissingConfig, allowMiss
259
269
  sessionSoftLimit,
260
270
  sessionResetToleranceMs,
261
271
  useOverage,
272
+ accountRanking,
273
+ preferPrimary,
274
+ sessionAffinity,
275
+ sessionAffinityIdleTtlMs,
276
+ spillInflight,
262
277
  }),
263
278
  configFilePresent,
264
279
  envFilePresent,