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
package/index.js
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-model-pool — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Registers one virtual provider route whose models are the union of every
|
|
5
|
+
* registered provider's models, then intercepts the `llm/stream` waterfall for
|
|
6
|
+
* that route and dispatches each call to a concrete provider chosen by the
|
|
7
|
+
* configured strategy.
|
|
8
|
+
*
|
|
9
|
+
* Mechanism evidence and the full design live in `docs/CONTRACT.md`.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-model-pool
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import z from '@deepseek-ai/schemastery';
|
|
17
|
+
import { PoolAdapter } from './lib/adapter.js';
|
|
18
|
+
import { readPoolSnapshot } from './lib/catalog.js';
|
|
19
|
+
import { HealthRegistry } from './lib/health.js';
|
|
20
|
+
import { HolidayCalendar, mergeOverride } from './lib/holiday-calendar.js';
|
|
21
|
+
import { Router } from './lib/router.js';
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_PEAK_TIME_ZONE,
|
|
24
|
+
DEFAULT_PEAK_WEEKDAYS,
|
|
25
|
+
DEFAULT_PEAK_WINDOWS,
|
|
26
|
+
describePeriod,
|
|
27
|
+
wallClock,
|
|
28
|
+
} from './lib/peak.js';
|
|
29
|
+
import { STRATEGIES } from './lib/strategies.js';
|
|
30
|
+
|
|
31
|
+
/** HTTP prefix the browser half reads its state from. */
|
|
32
|
+
const API_PREFIX = '/api/model-pool';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pool configuration. Every editable field is volatile: Settings writes reach
|
|
36
|
+
* the running refs through the Loader without remounting the plugin
|
|
37
|
+
* (`cordis-plugin-loader/lib/index.js:391-425`).
|
|
38
|
+
*/
|
|
39
|
+
export const Config = z.object({
|
|
40
|
+
providerId: z
|
|
41
|
+
.string()
|
|
42
|
+
.default('model-pool')
|
|
43
|
+
.description('Route id of the virtual pool provider. Changing it needs a restart to re-register the route.'),
|
|
44
|
+
providerName: z
|
|
45
|
+
.string()
|
|
46
|
+
.default('Model Pool')
|
|
47
|
+
.description('Display name of the virtual pool provider in the model picker.'),
|
|
48
|
+
strategy: z
|
|
49
|
+
.union([...STRATEGIES])
|
|
50
|
+
.default('priority')
|
|
51
|
+
.volatile()
|
|
52
|
+
.description('How one pooled call picks its concrete provider.'),
|
|
53
|
+
fallback: z
|
|
54
|
+
.boolean()
|
|
55
|
+
.default(true)
|
|
56
|
+
.volatile()
|
|
57
|
+
.description('Try the next candidate when an attempt fails before producing any output.'),
|
|
58
|
+
skipUnavailable: z
|
|
59
|
+
.boolean()
|
|
60
|
+
.default(true)
|
|
61
|
+
.volatile()
|
|
62
|
+
.description('Skip routes that are cooling down after a failure.'),
|
|
63
|
+
cooldownMs: z
|
|
64
|
+
.natural()
|
|
65
|
+
.default(60000)
|
|
66
|
+
.volatile()
|
|
67
|
+
.description('How long a failed route stays out of rotation, in milliseconds.'),
|
|
68
|
+
maxAttempts: z
|
|
69
|
+
.natural()
|
|
70
|
+
.default(3)
|
|
71
|
+
.volatile()
|
|
72
|
+
.description('Upper bound on concrete providers tried for one call.'),
|
|
73
|
+
sticky: z
|
|
74
|
+
.boolean()
|
|
75
|
+
.default(true)
|
|
76
|
+
.volatile()
|
|
77
|
+
.description(
|
|
78
|
+
'Keep one session on the route it already used instead of re-deciding every '
|
|
79
|
+
+ 'turn. A new session (or a re-selected model) picks again with the strategy. '
|
|
80
|
+
+ 'Keeps the provider prompt cache warm; failover still applies when the route fails.',
|
|
81
|
+
),
|
|
82
|
+
include: z
|
|
83
|
+
.array(z.string())
|
|
84
|
+
.default([])
|
|
85
|
+
.volatile()
|
|
86
|
+
.description('Provider allowlist. Empty means every registered provider.'),
|
|
87
|
+
exclude: z
|
|
88
|
+
.array(z.string())
|
|
89
|
+
.default([])
|
|
90
|
+
.volatile()
|
|
91
|
+
.description('Provider denylist, applied after the allowlist.'),
|
|
92
|
+
excludeModels: z
|
|
93
|
+
.array(z.string())
|
|
94
|
+
.default([])
|
|
95
|
+
.volatile()
|
|
96
|
+
.description('Model ids or id substrings to keep out of the pool.'),
|
|
97
|
+
providerOrder: z
|
|
98
|
+
.array(z.string())
|
|
99
|
+
.default([])
|
|
100
|
+
.volatile()
|
|
101
|
+
.description('Provider ids from highest to lowest priority, used by the priority strategy.'),
|
|
102
|
+
peakEnabled: z
|
|
103
|
+
.boolean()
|
|
104
|
+
.default(true)
|
|
105
|
+
.volatile()
|
|
106
|
+
.description('Rank providers differently during peak and idle periods.'),
|
|
107
|
+
peakTimeZone: z
|
|
108
|
+
.string()
|
|
109
|
+
.default(DEFAULT_PEAK_TIME_ZONE)
|
|
110
|
+
.volatile()
|
|
111
|
+
.description('Time zone the peak windows are expressed in. Defaults to Beijing time.'),
|
|
112
|
+
peakWindows: z
|
|
113
|
+
.array(z.string())
|
|
114
|
+
.default([...DEFAULT_PEAK_WINDOWS])
|
|
115
|
+
.volatile()
|
|
116
|
+
.description(
|
|
117
|
+
'Peak windows as "HH:MM-HH:MM" in the configured time zone. Everything outside them '
|
|
118
|
+
+ 'is idle. An empty list means the peak period never occurs.',
|
|
119
|
+
),
|
|
120
|
+
peakWeekdays: z
|
|
121
|
+
.array(z.number())
|
|
122
|
+
.default([...DEFAULT_PEAK_WEEKDAYS])
|
|
123
|
+
.volatile()
|
|
124
|
+
.description('ISO weekdays (1 = Monday … 7 = Sunday) whose windows are peak. An empty list means never.'),
|
|
125
|
+
holidaySync: z
|
|
126
|
+
.boolean()
|
|
127
|
+
.default(true)
|
|
128
|
+
.volatile()
|
|
129
|
+
.description(
|
|
130
|
+
'Fetch the statutory holiday calendar automatically. The dates are data, not a setting: '
|
|
131
|
+
+ 'a maintained dataset is downloaded and cached, so nobody types holiday dates by hand.',
|
|
132
|
+
),
|
|
133
|
+
holidaySource: z
|
|
134
|
+
.string()
|
|
135
|
+
.default('holiday-cn')
|
|
136
|
+
.volatile()
|
|
137
|
+
.description(
|
|
138
|
+
'Calendar source id, or "off" to keep the fetched dates out of routing entirely. '
|
|
139
|
+
+ 'The default (holiday-cn) is NateScarlet\'s dataset generated from the State Council notices.',
|
|
140
|
+
),
|
|
141
|
+
holidayUrlTemplate: z
|
|
142
|
+
.string()
|
|
143
|
+
.default('')
|
|
144
|
+
.volatile()
|
|
145
|
+
.description(
|
|
146
|
+
'Custom URL template containing "{year}" for the per-year JSON document. Empty uses the '
|
|
147
|
+
+ 'source default, which tries jsDelivr first and raw.githubusercontent second.',
|
|
148
|
+
),
|
|
149
|
+
holidayCacheDays: z
|
|
150
|
+
.natural()
|
|
151
|
+
.default(30)
|
|
152
|
+
.volatile()
|
|
153
|
+
.description('Age in days after which the cached calendar is refetched. Routing always reads the cache.'),
|
|
154
|
+
holidayUpdateDays: z
|
|
155
|
+
.natural()
|
|
156
|
+
.default(7)
|
|
157
|
+
.volatile()
|
|
158
|
+
.description('Background refresh cadence in days. Zero or a failed refresh never affects routing.'),
|
|
159
|
+
holidayOverride: z
|
|
160
|
+
.array(z.string())
|
|
161
|
+
.default([])
|
|
162
|
+
.volatile()
|
|
163
|
+
.description(
|
|
164
|
+
'Manual dates ("YYYY-MM-DD") layered on top of the fetched calendar for one-off corrections. '
|
|
165
|
+
+ 'A date listed here survives every background refresh.',
|
|
166
|
+
),
|
|
167
|
+
peakMakeupWorkdays: z
|
|
168
|
+
.array(z.string())
|
|
169
|
+
.default([])
|
|
170
|
+
.volatile()
|
|
171
|
+
.description(
|
|
172
|
+
'Extra dates ("YYYY-MM-DD") that count as working days even on a weekend, on top of the '
|
|
173
|
+
+ '调休上班日 the fetched calendar already reports. Normally empty: use it only for a day '
|
|
174
|
+
+ 'the State Council notice does not cover.',
|
|
175
|
+
),
|
|
176
|
+
peakProviderOrder: z
|
|
177
|
+
.array(z.string())
|
|
178
|
+
.default([])
|
|
179
|
+
.volatile()
|
|
180
|
+
.description('Provider ranking used during the peak period. Empty inherits providerOrder.'),
|
|
181
|
+
offPeakProviderOrder: z
|
|
182
|
+
.array(z.string())
|
|
183
|
+
.default([])
|
|
184
|
+
.volatile()
|
|
185
|
+
.description('Provider ranking used during the idle period. Empty inherits providerOrder.'),
|
|
186
|
+
weights: z
|
|
187
|
+
.dict(z.natural())
|
|
188
|
+
.default({})
|
|
189
|
+
.volatile()
|
|
190
|
+
.description('Per-provider weights for the weighted-random strategy. Missing providers count as 1.'),
|
|
191
|
+
costs: z
|
|
192
|
+
.dict(z.number())
|
|
193
|
+
.default({})
|
|
194
|
+
.volatile()
|
|
195
|
+
.description('Per-provider relative cost hints for the cheapest strategy. Missing providers count as 0.'),
|
|
196
|
+
modelGroups: z
|
|
197
|
+
.array(
|
|
198
|
+
z.object({
|
|
199
|
+
id: z.string().default(''),
|
|
200
|
+
name: z.string(),
|
|
201
|
+
models: z.array(z.string()).default([]),
|
|
202
|
+
merge: z.boolean().default(true),
|
|
203
|
+
strategy: z.string().default(''),
|
|
204
|
+
}),
|
|
205
|
+
)
|
|
206
|
+
.default([
|
|
207
|
+
{
|
|
208
|
+
id: 'deepseek-v4.1-flash',
|
|
209
|
+
name: 'DeepSeek V4.1 Flash',
|
|
210
|
+
models: ['deepseek-v4.1-flash*', 'deepseek-flash'],
|
|
211
|
+
merge: true,
|
|
212
|
+
strategy: '',
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: 'glm-5.3-flash',
|
|
216
|
+
name: 'GLM 5.3 Flash',
|
|
217
|
+
models: ['glm-5.3-flash*'],
|
|
218
|
+
merge: true,
|
|
219
|
+
strategy: '',
|
|
220
|
+
},
|
|
221
|
+
])
|
|
222
|
+
.volatile()
|
|
223
|
+
.description(
|
|
224
|
+
'Named groups that merge the same model served under different provider ids into one pool row. '
|
|
225
|
+
+ 'Each entry lists selectors matched against the concrete id, the "provider/model" pair, or the '
|
|
226
|
+
+ 'normalized id; "*" and "?" wildcards are allowed. merge: true (the default) removes the claimed '
|
|
227
|
+
+ 'routes from their single-id pools so only one row remains; merge: false adds an extra curated pool '
|
|
228
|
+
+ 'and leaves the natural rows in place.',
|
|
229
|
+
),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
/** The pool needs the model registry; every other dependency is optional. */
|
|
233
|
+
export const inject = ['llm'];
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Read a volatile ref, falling back to a plain value.
|
|
237
|
+
* @param {object|unknown} field - a schemastery field value.
|
|
238
|
+
* @param {unknown} fallback - value when the field is absent.
|
|
239
|
+
* @returns {unknown} the current value.
|
|
240
|
+
*/
|
|
241
|
+
function read(field, fallback) {
|
|
242
|
+
if (field !== null && typeof field === 'object' && typeof field.get === 'function') {
|
|
243
|
+
const value = field.get();
|
|
244
|
+
return value === undefined ? fallback : value;
|
|
245
|
+
}
|
|
246
|
+
return field === undefined ? fallback : field;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The directory the plugin keeps its own data in.
|
|
251
|
+
*
|
|
252
|
+
* `DSH_HOME` is honoured so a portable install keeps the cache beside the rest of its
|
|
253
|
+
* state; without it the documented default applies. The calendar cache lives here.
|
|
254
|
+
*
|
|
255
|
+
* @returns {string} absolute data directory.
|
|
256
|
+
*/
|
|
257
|
+
function dataDir() {
|
|
258
|
+
const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
|
|
259
|
+
? process.env.DSH_HOME.trim()
|
|
260
|
+
: join(homedir(), '.dsh');
|
|
261
|
+
return join(home, 'model-pool');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Read the holiday source settings as the pair the calendar understands.
|
|
266
|
+
*
|
|
267
|
+
* `holidaySource` is documented as a source id, the literal `off`, or a full URL. A URL
|
|
268
|
+
* is handed to the calendar as its template: an arbitrary host has no source id, and
|
|
269
|
+
* silently fetching nothing would look like an empty calendar instead of a misconfiguration.
|
|
270
|
+
*
|
|
271
|
+
* @param {object} config - resolved Config.
|
|
272
|
+
* @returns {{ enabled: boolean, source: string, urlTemplate: string }} the source settings.
|
|
273
|
+
*/
|
|
274
|
+
function calendarSource(config) {
|
|
275
|
+
const raw = String(read(config.holidaySource, 'holiday-cn')).trim();
|
|
276
|
+
const template = String(read(config.holidayUrlTemplate, '') ?? '').trim();
|
|
277
|
+
const enabled = read(config.holidaySync, true) !== false;
|
|
278
|
+
if (!enabled || raw === 'off' || raw === '') {
|
|
279
|
+
return { enabled: false, source: 'holiday-cn', urlTemplate: template };
|
|
280
|
+
}
|
|
281
|
+
// A URL is both the source id and its own template: keeping it as the id makes
|
|
282
|
+
// `/state.calendar.source` name the host the dates actually came from.
|
|
283
|
+
if (/^https?:\/\//i.test(raw)) return { enabled: true, source: raw, urlTemplate: raw };
|
|
284
|
+
return { enabled: true, source: raw, urlTemplate: template };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The date sets routing should use, resolved once per config read.
|
|
289
|
+
*
|
|
290
|
+
* The fetched calendar is merged with `holidayOverride`, which is applied through
|
|
291
|
+
* `mergeOverride` so a manual correction can move a date between the two sets instead of
|
|
292
|
+
* leaving it in both (where the holiday-wins precedence rule would quietly beat the
|
|
293
|
+
* operator). The manual `peakMakeupWorkdays` list is unioned on top as the documented
|
|
294
|
+
* opt-in escape hatch. Reading the sets here — not per candidate — keeps a routing
|
|
295
|
+
* decision to one lookup and never touches the network.
|
|
296
|
+
*
|
|
297
|
+
* @param {object} config - resolved Config.
|
|
298
|
+
* @param {{ enabled: boolean, source: string, urlTemplate: string }} source - source settings.
|
|
299
|
+
* @param {import('./lib/holiday-calendar.js').HolidayCalendar} calendar - the live calendar.
|
|
300
|
+
* @returns {{ holidays: string[], makeupWorkdays: string[] }} resolved date sets.
|
|
301
|
+
*/
|
|
302
|
+
function holidaySets(config, source, calendar) {
|
|
303
|
+
const override = [...(read(config.holidayOverride, []) ?? [])];
|
|
304
|
+
const manual = [...(read(config.peakMakeupWorkdays, []) ?? [])];
|
|
305
|
+
if (!source.enabled) return { holidays: override, makeupWorkdays: manual };
|
|
306
|
+
const merged = mergeOverride(
|
|
307
|
+
{ holidays: [...calendar.holidays()], makeupWorkdays: [...calendar.makeupWorkdays()] },
|
|
308
|
+
{ holidays: override },
|
|
309
|
+
);
|
|
310
|
+
const makeup = new Set(merged.makeupWorkdays);
|
|
311
|
+
for (const date of manual) makeup.add(date);
|
|
312
|
+
return { holidays: merged.holidays, makeupWorkdays: [...makeup].sort() };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Snapshot the volatile config into a plain object the pure modules accept.
|
|
317
|
+
* @param {object} config - resolved Config.
|
|
318
|
+
* @param {{ holidays: string[], makeupWorkdays: string[] }} sets - resolved holiday date sets.
|
|
319
|
+
* @returns {object} plain pool configuration.
|
|
320
|
+
*/
|
|
321
|
+
function plainConfig(config, sets = { holidays: [], makeupWorkdays: [] }) {
|
|
322
|
+
return {
|
|
323
|
+
providerId: String(read(config.providerId, 'model-pool')),
|
|
324
|
+
providerName: String(read(config.providerName, 'Model Pool')),
|
|
325
|
+
strategy: String(read(config.strategy, 'priority')),
|
|
326
|
+
fallback: read(config.fallback, true) !== false,
|
|
327
|
+
skipUnavailable: read(config.skipUnavailable, true) !== false,
|
|
328
|
+
cooldownMs: Number(read(config.cooldownMs, 60000)) || 60000,
|
|
329
|
+
maxAttempts: Math.max(1, Number(read(config.maxAttempts, 3)) || 3),
|
|
330
|
+
sticky: read(config.sticky, true) !== false,
|
|
331
|
+
include: [...(read(config.include, []) ?? [])],
|
|
332
|
+
exclude: [...(read(config.exclude, []) ?? [])],
|
|
333
|
+
excludeModels: [...(read(config.excludeModels, []) ?? [])],
|
|
334
|
+
providerOrder: [...(read(config.providerOrder, []) ?? [])],
|
|
335
|
+
peakEnabled: read(config.peakEnabled, true) !== false,
|
|
336
|
+
peakTimeZone: String(read(config.peakTimeZone, DEFAULT_PEAK_TIME_ZONE)),
|
|
337
|
+
peakWindows: [...(read(config.peakWindows, DEFAULT_PEAK_WINDOWS) ?? [])],
|
|
338
|
+
peakWeekdays: [...(read(config.peakWeekdays, DEFAULT_PEAK_WEEKDAYS) ?? [])],
|
|
339
|
+
// Resolved date sets, not raw config: `peak.js` has no built-in holiday table.
|
|
340
|
+
holidays: sets.holidays,
|
|
341
|
+
makeupWorkdays: sets.makeupWorkdays,
|
|
342
|
+
peakMakeupWorkdays: [...(read(config.peakMakeupWorkdays, []) ?? [])],
|
|
343
|
+
peakProviderOrder: [...(read(config.peakProviderOrder, []) ?? [])],
|
|
344
|
+
offPeakProviderOrder: [...(read(config.offPeakProviderOrder, []) ?? [])],
|
|
345
|
+
holidaySync: read(config.holidaySync, true) !== false,
|
|
346
|
+
holidaySource: String(read(config.holidaySource, 'holiday-cn')),
|
|
347
|
+
holidayUrlTemplate: String(read(config.holidayUrlTemplate, '') ?? ''),
|
|
348
|
+
holidayCacheDays: Math.max(0, Number(read(config.holidayCacheDays, 30)) || 0),
|
|
349
|
+
holidayUpdateDays: Math.max(0, Number(read(config.holidayUpdateDays, 7)) || 0),
|
|
350
|
+
holidayOverride: [...(read(config.holidayOverride, []) ?? [])],
|
|
351
|
+
weights: { ...(read(config.weights, {}) ?? {}) },
|
|
352
|
+
costs: { ...(read(config.costs, {}) ?? {}) },
|
|
353
|
+
modelGroups: [...(read(config.modelGroups, []) ?? [])],
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Whether a request is a pooled call this plugin must handle.
|
|
359
|
+
* @param {object} options - a `GenerateOptions` at the waterfall.
|
|
360
|
+
* @param {string} providerId - the virtual route.
|
|
361
|
+
* @returns {boolean} true when the request names the pool route.
|
|
362
|
+
*/
|
|
363
|
+
function isPooled(options, providerId) {
|
|
364
|
+
return options !== null
|
|
365
|
+
&& typeof options === 'object'
|
|
366
|
+
&& options.provider === providerId
|
|
367
|
+
&& typeof options.model === 'string'
|
|
368
|
+
&& options.model.length > 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Write one JSON response.
|
|
373
|
+
* @param {import('node:http').ServerResponse} res - the response.
|
|
374
|
+
* @param {number} status - HTTP status code.
|
|
375
|
+
* @param {unknown} body - JSON-serializable body.
|
|
376
|
+
*/
|
|
377
|
+
function sendJson(res, status, body) {
|
|
378
|
+
const payload = JSON.stringify(body);
|
|
379
|
+
res.writeHead(status, {
|
|
380
|
+
'content-type': 'application/json; charset=utf-8',
|
|
381
|
+
'cache-control': 'no-store',
|
|
382
|
+
'content-length': Buffer.byteLength(payload),
|
|
383
|
+
});
|
|
384
|
+
res.end(payload);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Read and discard a request body so the socket can be reused.
|
|
389
|
+
* @param {import('node:http').IncomingMessage} req - the request.
|
|
390
|
+
* @returns {Promise<void>} settles once the body is drained.
|
|
391
|
+
*/
|
|
392
|
+
async function drain(req) {
|
|
393
|
+
if (req.readableEnded === true) return;
|
|
394
|
+
for await (const _chunk of req) {
|
|
395
|
+
// Drained only; the API takes no request bodies.
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Mount the pool route, its routing listener, and the settings API.
|
|
401
|
+
* @param {object} ctx - plugin context.
|
|
402
|
+
* @param {object} config - resolved Config.
|
|
403
|
+
*/
|
|
404
|
+
export function apply(ctx, config) {
|
|
405
|
+
const health = new HealthRegistry();
|
|
406
|
+
/** Latest successful pool snapshot; `null` until the first successful read. */
|
|
407
|
+
let snapshot = { providerId: '', providerName: '', models: [], failures: [], takenAt: 0 };
|
|
408
|
+
/** Serializes refreshes so concurrent reads cannot interleave. */
|
|
409
|
+
let refreshing;
|
|
410
|
+
|
|
411
|
+
// One calendar for the process. Routing reads its in-memory sets, so a routing
|
|
412
|
+
// decision never waits on the network; `refresh()` is the only code that does I/O.
|
|
413
|
+
const initialSource = calendarSource(config);
|
|
414
|
+
const calendar = new HolidayCalendar({
|
|
415
|
+
dir: dataDir(),
|
|
416
|
+
source: initialSource.source,
|
|
417
|
+
urlTemplate: initialSource.urlTemplate,
|
|
418
|
+
logger: ctx.logger,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Re-resolve the calendar against the live config, then refresh it.
|
|
423
|
+
*
|
|
424
|
+
* `reconfigure` runs first on every cycle so a Settings edit to `holidaySource` or
|
|
425
|
+
* `holidayUrlTemplate` takes effect without a restart. `refresh()` never throws and
|
|
426
|
+
* never rejects: a failure leaves the previous cache in place.
|
|
427
|
+
*
|
|
428
|
+
* @param {{ force?: boolean, reason?: string }} options - refresh controls.
|
|
429
|
+
* @returns {Promise<object>} the calendar status after the attempt.
|
|
430
|
+
*/
|
|
431
|
+
function syncCalendar({ force = false, reason = 'scheduled' } = {}) {
|
|
432
|
+
const cfg = currentConfig();
|
|
433
|
+
const source = calendarSource(config);
|
|
434
|
+
if (!source.enabled) {
|
|
435
|
+
// Syncing is off: nothing is fetched, and `holidaySets` already refuses to read
|
|
436
|
+
// the calendar. The loaded years are deliberately left alone so a later
|
|
437
|
+
// re-enable starts warm instead of cold.
|
|
438
|
+
return Promise.resolve(calendarStatus());
|
|
439
|
+
}
|
|
440
|
+
calendar.reconfigure({ source: source.source, urlTemplate: source.urlTemplate });
|
|
441
|
+
return calendar.refresh({ force, cacheDays: cfg.holidayCacheDays }).then((status) => {
|
|
442
|
+
ctx.logger?.debug?.(
|
|
443
|
+
'model-pool: holiday calendar %s (%s): years=%s holidays=%s error=%s',
|
|
444
|
+
reason,
|
|
445
|
+
status.source,
|
|
446
|
+
status.years.join(','),
|
|
447
|
+
status.holidayCount,
|
|
448
|
+
status.lastError ?? 'none',
|
|
449
|
+
);
|
|
450
|
+
return status;
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* The calendar year the peak policy is currently in.
|
|
456
|
+
*
|
|
457
|
+
* Read from the CONFIGURED zone, not the host's: the policy judges days in
|
|
458
|
+
* `peakTimeZone`, so a host west of UTC on 1 January would otherwise report the
|
|
459
|
+
* previous year and show last year's holiday figures for a day the policy already
|
|
460
|
+
* treats as the new one.
|
|
461
|
+
*
|
|
462
|
+
* @param {object} cfg - plain pool configuration.
|
|
463
|
+
* @returns {number} the four-digit calendar year.
|
|
464
|
+
*/
|
|
465
|
+
function policyYear(cfg) {
|
|
466
|
+
return Number(wallClock(Date.now(), cfg.peakTimeZone).date.slice(0, 4));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The calendar facts the Settings page renders.
|
|
471
|
+
*
|
|
472
|
+
* When syncing is off the loaded years are irrelevant to routing, so reporting them
|
|
473
|
+
* as a live calendar would be a lie the card then repeats. The inert status says
|
|
474
|
+
* plainly that nothing is in play — but it keeps the same shape, so the page never
|
|
475
|
+
* has to branch on which state it received.
|
|
476
|
+
*
|
|
477
|
+
* @returns {object} the calendar status for `/state`.
|
|
478
|
+
*/
|
|
479
|
+
function calendarStatus() {
|
|
480
|
+
const cfg = currentConfig();
|
|
481
|
+
const year = policyYear(cfg);
|
|
482
|
+
if (!calendarSource(config).enabled) {
|
|
483
|
+
return {
|
|
484
|
+
years: [],
|
|
485
|
+
pendingYears: [],
|
|
486
|
+
fetchedAt: null,
|
|
487
|
+
source: 'off',
|
|
488
|
+
stale: true,
|
|
489
|
+
lastError: null,
|
|
490
|
+
holidayCount: 0,
|
|
491
|
+
makeupCount: 0,
|
|
492
|
+
papers: [],
|
|
493
|
+
currentYear: year,
|
|
494
|
+
currentHolidayCount: 0,
|
|
495
|
+
currentMakeupCount: 0,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
return calendar.status({ cacheDays: cfg.holidayCacheDays, year });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const currentConfig = () => {
|
|
502
|
+
const source = calendarSource(config);
|
|
503
|
+
return plainConfig(config, holidaySets(config, source, calendar));
|
|
504
|
+
};
|
|
505
|
+
const currentSnapshot = () => snapshot;
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Re-read the live registry into the pool snapshot.
|
|
509
|
+
* @param {string} reason - why the refresh ran, for the log line.
|
|
510
|
+
* @returns {Promise<object>} the snapshot in use.
|
|
511
|
+
*/
|
|
512
|
+
async function refresh(reason) {
|
|
513
|
+
if (refreshing !== undefined) return refreshing;
|
|
514
|
+
const cfg = currentConfig();
|
|
515
|
+
refreshing = (async () => {
|
|
516
|
+
try {
|
|
517
|
+
const { snapshot: next } = await readPoolSnapshot(ctx.llm, cfg, cfg.providerId);
|
|
518
|
+
snapshot = next;
|
|
519
|
+
return snapshot;
|
|
520
|
+
} catch (error) {
|
|
521
|
+
ctx.logger?.warn?.('model-pool: catalog refresh failed (%s): %s', reason, error?.message ?? String(error));
|
|
522
|
+
return snapshot;
|
|
523
|
+
} finally {
|
|
524
|
+
refreshing = undefined;
|
|
525
|
+
}
|
|
526
|
+
})();
|
|
527
|
+
return refreshing;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const router = new Router({
|
|
531
|
+
llm: ctx.llm,
|
|
532
|
+
config: currentConfig,
|
|
533
|
+
snapshot: currentSnapshot,
|
|
534
|
+
health,
|
|
535
|
+
logger: ctx.logger,
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
// ── the virtual provider route ────────────────────────────────────────────
|
|
539
|
+
const initial = currentConfig();
|
|
540
|
+
const adapter = new PoolAdapter(currentSnapshot, initial.providerId, initial.providerName);
|
|
541
|
+
|
|
542
|
+
ctx.effect(() => {
|
|
543
|
+
const registration = ctx.llm.registerAdapter([initial.providerId], adapter);
|
|
544
|
+
// Fill the catalog before the picker can ask for it; a failure here only
|
|
545
|
+
// means the first menu open shows an empty pool until the refresh lands.
|
|
546
|
+
void refresh('registration');
|
|
547
|
+
// The provider topology changed: pools may have gained or lost models.
|
|
548
|
+
const off = ctx.on('llm/adapters-updated', () => {
|
|
549
|
+
void refresh('adapters-updated');
|
|
550
|
+
});
|
|
551
|
+
return () => {
|
|
552
|
+
off();
|
|
553
|
+
registration();
|
|
554
|
+
};
|
|
555
|
+
}, 'model-pool: virtual provider route');
|
|
556
|
+
|
|
557
|
+
// ── holiday calendar ──────────────────────────────────────────────────────
|
|
558
|
+
ctx.effect(() => {
|
|
559
|
+
// Warm the calendar before the first routing decision can need it. The request
|
|
560
|
+
// runs in the background: a cold start must not delay the plugin, and a failure
|
|
561
|
+
// only means the weekday rule applies until the cache or the network answers.
|
|
562
|
+
void syncCalendar({ reason: 'mount' });
|
|
563
|
+
// The cadence is re-read every cycle rather than captured, because every config
|
|
564
|
+
// field is volatile: settings edited in the page must take effect without a
|
|
565
|
+
// remount. The floor of one day keeps a `0` from becoming a hot loop, and `unref`
|
|
566
|
+
// keeps a pending timer from holding the process open on shutdown.
|
|
567
|
+
let timer;
|
|
568
|
+
let disposed = false;
|
|
569
|
+
const schedule = () => {
|
|
570
|
+
const days = Math.max(currentConfig().holidayUpdateDays, 1);
|
|
571
|
+
timer = setTimeout(() => {
|
|
572
|
+
void syncCalendar({ reason: 'interval' });
|
|
573
|
+
if (!disposed) schedule();
|
|
574
|
+
}, days * 86_400_000);
|
|
575
|
+
timer.unref?.();
|
|
576
|
+
};
|
|
577
|
+
schedule();
|
|
578
|
+
return () => {
|
|
579
|
+
disposed = true;
|
|
580
|
+
clearTimeout(timer);
|
|
581
|
+
};
|
|
582
|
+
}, 'model-pool: holiday calendar');
|
|
583
|
+
|
|
584
|
+
// ── routing ───────────────────────────────────────────────────────────────
|
|
585
|
+
ctx.on('llm/stream', (options, next) => {
|
|
586
|
+
const cfg = currentConfig();
|
|
587
|
+
if (!isPooled(options, cfg.providerId)) return next();
|
|
588
|
+
return router.route(options);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// ── settings API ──────────────────────────────────────────────────────────
|
|
592
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
593
|
+
/**
|
|
594
|
+
* Current state payload for the Settings page.
|
|
595
|
+
* @returns {object} JSON-serializable state.
|
|
596
|
+
*/
|
|
597
|
+
const state = () => {
|
|
598
|
+
const cfg = currentConfig();
|
|
599
|
+
return {
|
|
600
|
+
snapshot,
|
|
601
|
+
health: { entries: health.entries() },
|
|
602
|
+
config: {
|
|
603
|
+
providerId: cfg.providerId,
|
|
604
|
+
providerName: cfg.providerName,
|
|
605
|
+
strategy: cfg.strategy,
|
|
606
|
+
fallback: cfg.fallback,
|
|
607
|
+
skipUnavailable: cfg.skipUnavailable,
|
|
608
|
+
cooldownMs: cfg.cooldownMs,
|
|
609
|
+
maxAttempts: cfg.maxAttempts,
|
|
610
|
+
sticky: cfg.sticky,
|
|
611
|
+
include: cfg.include,
|
|
612
|
+
exclude: cfg.exclude,
|
|
613
|
+
excludeModels: cfg.excludeModels,
|
|
614
|
+
providerOrder: cfg.providerOrder,
|
|
615
|
+
peakEnabled: cfg.peakEnabled,
|
|
616
|
+
peakTimeZone: cfg.peakTimeZone,
|
|
617
|
+
peakWindows: cfg.peakWindows,
|
|
618
|
+
peakWeekdays: cfg.peakWeekdays,
|
|
619
|
+
peakMakeupWorkdays: cfg.peakMakeupWorkdays,
|
|
620
|
+
peakProviderOrder: cfg.peakProviderOrder,
|
|
621
|
+
offPeakProviderOrder: cfg.offPeakProviderOrder,
|
|
622
|
+
holidaySync: cfg.holidaySync,
|
|
623
|
+
holidaySource: cfg.holidaySource,
|
|
624
|
+
holidayUrlTemplate: cfg.holidayUrlTemplate,
|
|
625
|
+
holidayCacheDays: cfg.holidayCacheDays,
|
|
626
|
+
holidayUpdateDays: cfg.holidayUpdateDays,
|
|
627
|
+
holidayOverride: cfg.holidayOverride,
|
|
628
|
+
weights: cfg.weights,
|
|
629
|
+
costs: cfg.costs,
|
|
630
|
+
modelGroups: cfg.modelGroups,
|
|
631
|
+
},
|
|
632
|
+
// The live peak/idle reading, including when it next flips. The Settings
|
|
633
|
+
// card renders this so the policy is visible instead of implied.
|
|
634
|
+
period: describePeriod(Date.now(), cfg),
|
|
635
|
+
// What the holiday calendar currently knows. The dates are fetched, so the
|
|
636
|
+
// page reports the state of the fetch (years, freshness, last error) instead
|
|
637
|
+
// of offering a list to edit — nobody maintains holiday dates by hand.
|
|
638
|
+
calendar: calendarStatus(),
|
|
639
|
+
recent: router.recent,
|
|
640
|
+
// Diagnostic: the raw `reasoningEffort` the runtime handed the pool for
|
|
641
|
+
// the latest call. It makes a mapped tier explainable instead of a guess.
|
|
642
|
+
lastInbound: router.lastInbound ?? null,
|
|
643
|
+
sessions: router.sessionEntries(),
|
|
644
|
+
version: VERSION,
|
|
645
|
+
};
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* One prefix route serving the whole API.
|
|
650
|
+
* @param {import('node:http').IncomingMessage} req - request.
|
|
651
|
+
* @param {import('node:http').ServerResponse} res - response.
|
|
652
|
+
* @returns {Promise<void>} settles after the response is written.
|
|
653
|
+
*/
|
|
654
|
+
const handler = async (req, res) => {
|
|
655
|
+
try {
|
|
656
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
657
|
+
const path = url.pathname.slice(API_PREFIX.length).replace(/\/+$/, '');
|
|
658
|
+
const method = req.method ?? 'GET';
|
|
659
|
+
|
|
660
|
+
if (method === 'GET' && (path === '/state' || path === '')) {
|
|
661
|
+
await drain(req);
|
|
662
|
+
sendJson(res, 200, state());
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
// One session's last route. The composer dock polls this: it is a few
|
|
666
|
+
// hundred bytes, while `/state` carries the whole catalog.
|
|
667
|
+
if (method === 'GET' && path === '/session') {
|
|
668
|
+
await drain(req);
|
|
669
|
+
const sessionId = url.searchParams.get('id') ?? '';
|
|
670
|
+
const entry = router.sessionOf(sessionId);
|
|
671
|
+
if (entry === undefined) {
|
|
672
|
+
sendJson(res, 200, { sessionId, route: null });
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
sendJson(res, 200, { sessionId, route: entry });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (method === 'POST' && path === '/refresh') {
|
|
679
|
+
await drain(req);
|
|
680
|
+
await refresh('settings');
|
|
681
|
+
sendJson(res, 200, state());
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
if (method === 'POST' && path === '/reset-health') {
|
|
685
|
+
await drain(req);
|
|
686
|
+
health.reset();
|
|
687
|
+
sendJson(res, 200, state());
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
// The Settings card's "sync now". `force` is deliberate: the operator asked,
|
|
691
|
+
// so a fresh cache must not swallow the request. The response is written after
|
|
692
|
+
// the attempt either way, because `syncCalendar` cannot reject.
|
|
693
|
+
if (method === 'POST' && path === '/refresh-calendar') {
|
|
694
|
+
await drain(req);
|
|
695
|
+
await syncCalendar({ force: true, reason: 'settings' });
|
|
696
|
+
sendJson(res, 200, state());
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
await drain(req);
|
|
700
|
+
sendJson(res, 404, { error: { message: `unknown model-pool endpoint ${method} ${path}` } });
|
|
701
|
+
} catch (error) {
|
|
702
|
+
if (res.headersSent !== true) {
|
|
703
|
+
sendJson(res, 500, { error: { message: error instanceof Error ? error.message : String(error) } });
|
|
704
|
+
} else {
|
|
705
|
+
res.end();
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
scoped.effect(
|
|
711
|
+
() => scoped.webServer.register({ kind: 'prefix', path: API_PREFIX, handler }),
|
|
712
|
+
'model-pool: settings API',
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/** Package version, reported by the settings API. */
|
|
718
|
+
const VERSION = '0.1.0';
|