dsh-model-pool 0.0.0-stage → 0.1.0
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 +75 -0
- package/LICENSE +21 -0
- package/README.md +508 -2
- package/README.zh-CN.md +331 -0
- package/client.js +2847 -0
- package/cordis.patch.yml +15 -0
- package/docs/installation.md +115 -0
- package/docs/installation.zh-CN.md +107 -0
- package/icon.svg +17 -0
- package/index.js +718 -0
- package/lib/adapter.js +151 -0
- package/lib/capabilities.js +198 -0
- package/lib/catalog.js +175 -0
- package/lib/effort.js +151 -0
- package/lib/grouping.js +404 -0
- package/lib/health.js +175 -0
- package/lib/holiday-calendar.js +595 -0
- package/lib/peak.js +495 -0
- package/lib/router.js +584 -0
- package/lib/strategies.js +215 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +77 -4
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Candidate ordering per routing strategy.
|
|
3
|
+
* Pure: ordering only — the health gate lives in the router, so no candidate is
|
|
4
|
+
* ever dropped and the input array is never mutated.
|
|
5
|
+
* @module dsh-model-pool/lib/strategies
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Supported strategy ids, in picker order. */
|
|
9
|
+
export const STRATEGIES = [
|
|
10
|
+
"priority",
|
|
11
|
+
"round-robin",
|
|
12
|
+
"weighted-random",
|
|
13
|
+
"least-used",
|
|
14
|
+
"fastest",
|
|
15
|
+
"cheapest",
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
/** Picker metadata for each strategy. */
|
|
19
|
+
export const STRATEGY_LABELS = {
|
|
20
|
+
priority: {
|
|
21
|
+
id: "priority",
|
|
22
|
+
label: "Priority",
|
|
23
|
+
description: "Try providers in the configured priority order.",
|
|
24
|
+
},
|
|
25
|
+
"round-robin": {
|
|
26
|
+
id: "round-robin",
|
|
27
|
+
label: "Round-robin",
|
|
28
|
+
description: "Rotate the starting provider on every call.",
|
|
29
|
+
},
|
|
30
|
+
"weighted-random": {
|
|
31
|
+
id: "weighted-random",
|
|
32
|
+
label: "Weighted random",
|
|
33
|
+
description: "Draw providers at random, honouring each route's weight.",
|
|
34
|
+
},
|
|
35
|
+
"least-used": {
|
|
36
|
+
id: "least-used",
|
|
37
|
+
label: "Least used",
|
|
38
|
+
description: "Prefer the provider that has served the fewest calls.",
|
|
39
|
+
},
|
|
40
|
+
fastest: {
|
|
41
|
+
id: "fastest",
|
|
42
|
+
label: "Fastest",
|
|
43
|
+
description: "Prefer the provider with the lowest recent time-to-first-chunk.",
|
|
44
|
+
},
|
|
45
|
+
cheapest: {
|
|
46
|
+
id: "cheapest",
|
|
47
|
+
label: "Cheapest",
|
|
48
|
+
description: "Prefer the provider with the lowest declared cost hint.",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Strategy used for unknown or missing ids. */
|
|
53
|
+
const DEFAULT_STRATEGY = "priority";
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Key identifying one concrete route in health/stats lookups.
|
|
57
|
+
* @param route - pool route.
|
|
58
|
+
* @returns `provider\0model`.
|
|
59
|
+
*/
|
|
60
|
+
function routeKey(route) {
|
|
61
|
+
return `${route.provider}\u0000${route.model}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Stable sort helper: `compare` returns a number, ties keep input order.
|
|
66
|
+
* @param items - items to sort.
|
|
67
|
+
* @param compare - comparator over items.
|
|
68
|
+
* @returns a new sorted array.
|
|
69
|
+
*/
|
|
70
|
+
function stableSort(items, compare) {
|
|
71
|
+
return items
|
|
72
|
+
.map((item, index) => ({ item, index }))
|
|
73
|
+
.sort((left, right) => compare(left.item, right.item) || left.index - right.index)
|
|
74
|
+
.map((wrapped) => wrapped.item);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read one route's health record.
|
|
79
|
+
* @param ctx - ordering context.
|
|
80
|
+
* @param route - pool route.
|
|
81
|
+
* @returns the health entry, or undefined when unknown.
|
|
82
|
+
*/
|
|
83
|
+
function healthOf(ctx, route) {
|
|
84
|
+
const health = ctx?.health;
|
|
85
|
+
if (health === undefined || health === null) return undefined;
|
|
86
|
+
if (typeof health.entryOf === "function") return health.entryOf(route.provider, route.model);
|
|
87
|
+
if (typeof health.get === "function") return health.get(route.provider, route.model);
|
|
88
|
+
if (typeof health === "object") return health[routeKey(route)];
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Read one route's usage count from ctx.stats, falling back to health.
|
|
94
|
+
* @param ctx - ordering context.
|
|
95
|
+
* @param route - pool route.
|
|
96
|
+
* @returns the recorded use count, defaulting to 0.
|
|
97
|
+
*/
|
|
98
|
+
function usesOf(ctx, route) {
|
|
99
|
+
const stats = ctx?.stats;
|
|
100
|
+
let uses;
|
|
101
|
+
if (stats !== undefined && stats !== null) {
|
|
102
|
+
if (typeof stats.get === "function") uses = stats.get(route.provider, route.model)?.uses ?? stats.get(routeKey(route))?.uses;
|
|
103
|
+
else if (typeof stats === "object") uses = stats[routeKey(route)]?.uses;
|
|
104
|
+
}
|
|
105
|
+
if (uses === undefined) uses = healthOf(ctx, route)?.uses;
|
|
106
|
+
return Number.isFinite(uses) ? uses : 0;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Read one route's EWMA time-to-first-chunk.
|
|
111
|
+
* @param ctx - ordering context.
|
|
112
|
+
* @param route - pool route.
|
|
113
|
+
* @returns the latency in milliseconds, or undefined without a sample.
|
|
114
|
+
*/
|
|
115
|
+
function latencyOf(ctx, route) {
|
|
116
|
+
const value = healthOf(ctx, route)?.latencyMs;
|
|
117
|
+
return Number.isFinite(value) ? value : undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolve a route's configured weight.
|
|
122
|
+
* @param ctx - ordering context.
|
|
123
|
+
* @param route - pool route.
|
|
124
|
+
* @returns the weight, defaulting to 1 and never below 0.
|
|
125
|
+
*/
|
|
126
|
+
function weightOf(ctx, route) {
|
|
127
|
+
const configured = ctx?.weights?.[route.provider] ?? ctx?.weights?.[routeKey(route)];
|
|
128
|
+
const weight = Number.isFinite(configured) ? configured : route.weight;
|
|
129
|
+
return Number.isFinite(weight) ? Math.max(0, weight) : 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve a route's cost hint.
|
|
134
|
+
* @param ctx - ordering context.
|
|
135
|
+
* @param route - pool route.
|
|
136
|
+
* @returns the cost, defaulting to 0.
|
|
137
|
+
*/
|
|
138
|
+
function costOf(ctx, route) {
|
|
139
|
+
const configured = ctx?.costs?.[route.provider] ?? ctx?.costs?.[routeKey(route)];
|
|
140
|
+
const cost = Number.isFinite(configured) ? configured : route.cost;
|
|
141
|
+
return Number.isFinite(cost) ? cost : 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Weighted sampling without replacement.
|
|
146
|
+
* @param routes - candidates in priority order.
|
|
147
|
+
* Zero-weight routes are never dropped: they follow the weighted ones in priority
|
|
148
|
+
* order once no positive weight remains.
|
|
149
|
+
* @param ctx - ordering context.
|
|
150
|
+
* @returns a new array holding every candidate exactly once.
|
|
151
|
+
*/
|
|
152
|
+
function weightedSample(routes, ctx) {
|
|
153
|
+
const random = typeof ctx?.random === "function" ? ctx.random : Math.random;
|
|
154
|
+
const pool = routes.map((route) => ({ route, weight: weightOf(ctx, route) }));
|
|
155
|
+
const out = [];
|
|
156
|
+
while (pool.length > 0) {
|
|
157
|
+
const total = pool.reduce((sum, item) => sum + item.weight, 0);
|
|
158
|
+
let index = pool.findLastIndex((item) => item.weight > 0);
|
|
159
|
+
if (index < 0) index = 0;
|
|
160
|
+
else {
|
|
161
|
+
let target = random() * total;
|
|
162
|
+
if (!(target >= 0)) target = 0;
|
|
163
|
+
for (let cursor = 0; cursor < pool.length; cursor += 1) {
|
|
164
|
+
target -= pool[cursor].weight;
|
|
165
|
+
// Floating-point residue must not skip the final eligible slot.
|
|
166
|
+
if (target < 0 || (cursor === index && target === 0)) {
|
|
167
|
+
index = cursor;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
out.push(pool.splice(index, 1)[0].route);
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Order candidates for one call.
|
|
179
|
+
* `round-robin` advances `ctx.roundRobin.cursor`; no other strategy mutates `ctx`.
|
|
180
|
+
* @param strategy - strategy id; unknown ids fall back to priority order.
|
|
181
|
+
* @param candidates - pool routes, already priority-sorted.
|
|
182
|
+
* @param ctx - `{ now, health, roundRobin, stats, weights, costs, random }`.
|
|
183
|
+
* @returns a new ordered array containing every candidate.
|
|
184
|
+
*/
|
|
185
|
+
export function order(strategy, candidates, ctx = {}) {
|
|
186
|
+
const routes = [...(Array.isArray(candidates) ? candidates : [])];
|
|
187
|
+
if (routes.length <= 1) return routes;
|
|
188
|
+
switch (strategy) {
|
|
189
|
+
case "round-robin": {
|
|
190
|
+
const state = ctx?.roundRobin;
|
|
191
|
+
const raw = Math.trunc(Number.isFinite(state?.cursor) ? state.cursor : 0);
|
|
192
|
+
if (state !== undefined && state !== null) state.cursor = raw + 1;
|
|
193
|
+
const start = ((raw % routes.length) + routes.length) % routes.length;
|
|
194
|
+
return [...routes.slice(start), ...routes.slice(0, start)];
|
|
195
|
+
}
|
|
196
|
+
case "weighted-random":
|
|
197
|
+
return weightedSample(routes, ctx);
|
|
198
|
+
case "least-used":
|
|
199
|
+
return stableSort(routes, (left, right) => usesOf(ctx, left) - usesOf(ctx, right));
|
|
200
|
+
case "fastest":
|
|
201
|
+
return stableSort(routes, (left, right) => {
|
|
202
|
+
const leftLatency = latencyOf(ctx, left);
|
|
203
|
+
const rightLatency = latencyOf(ctx, right);
|
|
204
|
+
if (leftLatency === undefined && rightLatency === undefined) return 0;
|
|
205
|
+
if (leftLatency === undefined) return 1;
|
|
206
|
+
if (rightLatency === undefined) return -1;
|
|
207
|
+
return leftLatency - rightLatency;
|
|
208
|
+
});
|
|
209
|
+
case "cheapest":
|
|
210
|
+
return stableSort(routes, (left, right) => costOf(ctx, left) - costOf(ctx, right));
|
|
211
|
+
case DEFAULT_STRATEGY:
|
|
212
|
+
default:
|
|
213
|
+
return routes;
|
|
214
|
+
}
|
|
215
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Model Pool",
|
|
4
|
+
"description": "Aggregates every registered provider's models into one model pool: pick a pooled model once and a configurable strategy (priority, round-robin, weighted random, least used, fastest, cheapest) routes each call to a concrete provider, with thinking-depth selection, failover and unavailable-route markers. A peak/idle policy can also rank providers differently inside and outside working hours. The Chinese statutory holiday calendar is fetched from a maintained public dataset and cached automatically — no dates are ever entered by hand."
|
|
5
|
+
},
|
|
6
|
+
"nav": "Model Pool"
|
|
7
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,79 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-model-pool",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Aggregate every registered provider's models into one model pool: pick a pooled model once and let a configurable strategy route each call to a concrete provider, with thinking-depth selection and unavailable-route markers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Bruce Zhang",
|
|
9
|
+
"email": "zttt183525594@gmail.com"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/BruceZhang1993/dsh-model-pool.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/BruceZhang1993/dsh-model-pool/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/BruceZhang1993/dsh-model-pool#readme",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"main": "index.js",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./index.js",
|
|
25
|
+
"./client": "./client.js",
|
|
26
|
+
"./locale/*.json": "./locale/*.json",
|
|
27
|
+
"./icon": "./icon.svg",
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.js",
|
|
32
|
+
"client.js",
|
|
33
|
+
"lib/*.js",
|
|
34
|
+
"cordis.patch.yml",
|
|
35
|
+
"locale/*.json",
|
|
36
|
+
"icon.svg",
|
|
37
|
+
"README.md",
|
|
38
|
+
"README.zh-CN.md",
|
|
39
|
+
"CHANGELOG.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"docs/installation*.md"
|
|
42
|
+
],
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --test test/*.test.mjs",
|
|
45
|
+
"check": "for f in index.js client.js lib/*.js; do node --check \"$f\" || exit 1; done"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"deepseek-harness",
|
|
49
|
+
"dsh",
|
|
50
|
+
"dsh-plugin",
|
|
51
|
+
"model-pool",
|
|
52
|
+
"llm-router",
|
|
53
|
+
"provider-routing",
|
|
54
|
+
"failover",
|
|
55
|
+
"load-balancing",
|
|
56
|
+
"thinking-depth",
|
|
57
|
+
"holiday-calendar"
|
|
58
|
+
],
|
|
59
|
+
"publishConfig": {
|
|
60
|
+
"registry": "https://registry.npmjs.org",
|
|
61
|
+
"access": "public"
|
|
62
|
+
},
|
|
63
|
+
"peerDependencies": {
|
|
64
|
+
"@deepseek-ai/dsh-llm": ">=0.2.0-rc.2 <0.3.0-0",
|
|
65
|
+
"@deepseek-ai/schemastery": "~3.18.5"
|
|
66
|
+
},
|
|
67
|
+
"dsh": {
|
|
68
|
+
"bundle": {
|
|
69
|
+
"patch": "./cordis.patch.yml"
|
|
70
|
+
},
|
|
71
|
+
"client": {
|
|
72
|
+
"platform": "web",
|
|
73
|
+
"inject": [
|
|
74
|
+
"@deepseek-ai/dsh-client-locale",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|