@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.
- package/CHANGELOG.md +2 -2
- package/dist/adapters/providerImageAdapter.js +2 -4
- package/dist/browser/neurolink.min.js +410 -410
- package/dist/cli/commands/proxy.js +30 -0
- package/dist/cli/utils/interactiveSetup.js +1 -1
- package/dist/constants/contextWindows.js +0 -5
- package/dist/constants/enums.d.ts +3 -1
- package/dist/constants/enums.js +2 -0
- package/dist/models/manifests/deepseek.js +3 -3
- package/dist/providers/catalog/deepseek.json +28 -14
- package/dist/providers/catalog/huggingface.json +85 -85
- package/dist/proxy/accountRanking.d.ts +67 -4
- package/dist/proxy/accountRanking.js +188 -8
- package/dist/proxy/proxyAnalysis.js +76 -0
- package/dist/proxy/proxyConfig.d.ts +24 -1
- package/dist/proxy/proxyConfig.js +131 -3
- package/dist/proxy/routingEvidence.d.ts +1 -1
- package/dist/proxy/routingEvidence.js +5 -0
- package/dist/proxy/runtimeConfig.js +15 -0
- package/dist/proxy/sessionAffinity.d.ts +23 -0
- package/dist/proxy/sessionAffinity.js +62 -0
- package/dist/server/routes/claudeProxyRoutes.d.ts +48 -3
- package/dist/server/routes/claudeProxyRoutes.js +324 -29
- package/dist/types/proxy.d.ts +48 -0
- package/dist/types/subscription.d.ts +16 -4
- package/docs-site/static/search-index.json +25 -24
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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
|
|
82
|
-
const
|
|
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
|
-
:
|
|
85
|
-
return {
|
|
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
|
|
607
|
-
//
|
|
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,
|