@goodandready/dsh-model-sync 0.3.13 → 0.4.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/README.md +27 -0
- package/README.ru.md +27 -0
- package/README.zh.md +27 -0
- package/lib/client.js +1104 -92
- package/lib/http.js +82 -5
- package/lib/index.js +180 -163
- package/lib/policy.js +24 -1
- package/lib/synchronizer-helpers.js +113 -0
- package/lib/synchronizer-prober.js +50 -0
- package/lib/synchronizer-transfer.js +118 -0
- package/lib/synchronizer.js +38 -128
- package/lib/updater.js +362 -0
- package/package.json +6 -2
package/lib/http.js
CHANGED
|
@@ -8,24 +8,50 @@ function json(res, status, body) {
|
|
|
8
8
|
res.end(JSON.stringify(body))
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
function
|
|
11
|
+
export function isLoopbackAddress(ip) {
|
|
12
|
+
if (!ip || typeof ip !== 'string') return false
|
|
13
|
+
const clean = ip.toLowerCase().replace(/^\[|\]$/g, '')
|
|
14
|
+
if (clean === '127.0.0.1' || clean === '::1' || clean === '::ffff:127.0.0.1' || clean === 'localhost') return true
|
|
15
|
+
if (clean.startsWith('127.') || clean.startsWith('::ffff:127.')) return true
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function trusted(req, { requireLoopback = false } = {}) {
|
|
20
|
+
const remoteAddress = req?.socket?.remoteAddress
|
|
21
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
22
|
+
if (!isLoopbackAddress(remoteAddress)) return false
|
|
23
|
+
}
|
|
12
24
|
const fetchSite = req.headers?.['sec-fetch-site']
|
|
13
25
|
if (fetchSite === 'cross-site') return false
|
|
14
26
|
const host = req.headers?.host
|
|
15
27
|
const origin = req.headers?.origin
|
|
16
28
|
if (origin) {
|
|
17
29
|
if (!host) return false
|
|
18
|
-
try {
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(origin)
|
|
32
|
+
if (url.host !== host) return false
|
|
33
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
34
|
+
if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
|
|
35
|
+
}
|
|
36
|
+
} catch { return false }
|
|
19
37
|
}
|
|
20
38
|
const referer = req.headers?.referer
|
|
21
39
|
if (referer) {
|
|
22
40
|
if (!host) return false
|
|
23
|
-
try {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(referer)
|
|
43
|
+
if (url.host !== host) return false
|
|
44
|
+
if (requireLoopback || req?.method === 'POST') {
|
|
45
|
+
if (!isLoopbackAddress(url.hostname) && url.hostname !== 'localhost') return false
|
|
46
|
+
}
|
|
47
|
+
} catch { return false }
|
|
24
48
|
}
|
|
25
49
|
// If sec-fetch-site is same-origin or same-site, allow
|
|
26
50
|
if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
|
|
27
51
|
// If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
|
|
28
52
|
if (req.headers?.['sec-fetch-mode'] && !host) return false
|
|
53
|
+
// For POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
|
|
54
|
+
if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
|
|
29
55
|
return true
|
|
30
56
|
}
|
|
31
57
|
|
|
@@ -97,6 +123,27 @@ export function normalizeTryRequest(body) {
|
|
|
97
123
|
return { provider: body.provider, model: body.model }
|
|
98
124
|
}
|
|
99
125
|
|
|
126
|
+
|
|
127
|
+
export function normalizeBatchTryRequest(body) {
|
|
128
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
129
|
+
if (typeof body.provider !== 'string' || !body.provider) throw new Error('provider is required')
|
|
130
|
+
if (!Array.isArray(body.models)) throw new Error('models must be an array')
|
|
131
|
+
return {
|
|
132
|
+
provider: body.provider.trim(),
|
|
133
|
+
models: body.models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function normalizeAliasRequest(body) {
|
|
138
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
139
|
+
if (typeof body.alias !== 'string' || !body.alias.trim()) throw new Error('alias is required')
|
|
140
|
+
if (!/^[a-zA-Z0-9_\-.]{1,64}$/.test(body.alias.trim())) throw new Error('alias must be 1-64 alphanumeric characters or _-.')
|
|
141
|
+
if (body.action === 'delete') return { action: 'delete', alias: body.alias.trim() }
|
|
142
|
+
if (typeof body.provider !== 'string' || !body.provider.trim() || body.provider.length > 128) throw new Error('provider must be a short string')
|
|
143
|
+
if (typeof body.model !== 'string' || !body.model.trim() || body.model.length > 256) throw new Error('model must be a short string')
|
|
144
|
+
return { action: 'set', alias: body.alias.trim(), provider: body.provider.trim(), model: body.model.trim() }
|
|
145
|
+
}
|
|
146
|
+
|
|
100
147
|
export function normalizeHealthRequest(body) {
|
|
101
148
|
if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
|
|
102
149
|
if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
|
|
@@ -122,6 +169,10 @@ export function normalizePolicyRequest(body) {
|
|
|
122
169
|
exclude: body.exclude,
|
|
123
170
|
requireCapabilities: body.requireCapabilities,
|
|
124
171
|
denyCapabilities: body.denyCapabilities,
|
|
172
|
+
enableCostFilter: body.enableCostFilter,
|
|
173
|
+
maxPricePerMillion: body.maxPricePerMillion,
|
|
174
|
+
enableContextFilter: body.enableContextFilter,
|
|
175
|
+
minContextTokens: body.minContextTokens,
|
|
125
176
|
}),
|
|
126
177
|
}
|
|
127
178
|
}
|
|
@@ -163,7 +214,7 @@ export function normalizeSelectionRequest(body) {
|
|
|
163
214
|
|
|
164
215
|
export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () => null } = {}) {
|
|
165
216
|
const makeHandler = (route) => async (req, res) => {
|
|
166
|
-
if (!trusted(req)) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin request rejected' } })
|
|
217
|
+
if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
167
218
|
if (route === 'status') {
|
|
168
219
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
169
220
|
const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
|
|
@@ -187,6 +238,24 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
187
238
|
try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
|
|
188
239
|
catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
|
|
189
240
|
}
|
|
241
|
+
if (route === 'export') {
|
|
242
|
+
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
243
|
+
return json(res, 200, synchronizer.exportConfig())
|
|
244
|
+
}
|
|
245
|
+
if (route === 'aliases' && req.method === 'GET') {
|
|
246
|
+
return json(res, 200, { aliases: synchronizer.getAliases() })
|
|
247
|
+
}
|
|
248
|
+
if (route === 'import') {
|
|
249
|
+
if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
|
|
250
|
+
try {
|
|
251
|
+
const body = await readJson(req)
|
|
252
|
+
const result = await synchronizer.importConfig(body)
|
|
253
|
+
return json(res, 200, result)
|
|
254
|
+
} catch (error) {
|
|
255
|
+
const code = error?.code === 'INVALID_CONFIG' || error?.code === 'INVALID_POLICY' ? 400 : 500
|
|
256
|
+
return json(res, code, { error: { code: error?.code ?? 'import-failed', message: error instanceof Error ? error.message : String(error) } })
|
|
257
|
+
}
|
|
258
|
+
}
|
|
190
259
|
if (route === 'report') {
|
|
191
260
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
192
261
|
return json(res, 200, { report: synchronizer.report() })
|
|
@@ -203,6 +272,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
203
272
|
? normalizeSelectionRequest(body)
|
|
204
273
|
: route === 'policy' ? normalizePolicyRequest(body)
|
|
205
274
|
: route === 'try' ? normalizeTryRequest(body)
|
|
275
|
+
: route === 'batch-try' ? normalizeBatchTryRequest(body)
|
|
276
|
+
: route === 'aliases' ? normalizeAliasRequest(body)
|
|
206
277
|
: route === 'health' ? normalizeHealthRequest(body)
|
|
207
278
|
: route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
|
|
208
279
|
: route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
|
|
@@ -215,6 +286,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
215
286
|
? await synchronizer.setModelSelection(options.provider, options.models)
|
|
216
287
|
: route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
|
|
217
288
|
: route === 'try' ? await synchronizer.tryModel(options)
|
|
289
|
+
: route === 'batch-try' ? await synchronizer.batchTryModels(options)
|
|
290
|
+
: route === 'aliases' ? (options.action === 'delete' ? await synchronizer.deleteAlias(options) : await synchronizer.setAlias(options))
|
|
218
291
|
: route === 'health' ? await synchronizer.health(options)
|
|
219
292
|
: route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
|
|
220
293
|
: route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
|
|
@@ -224,7 +297,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
224
297
|
return json(res, 200, { ...result, providers: synchronizer.listProviders() })
|
|
225
298
|
}
|
|
226
299
|
catch (error) {
|
|
227
|
-
const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' || error?.code === 'INVALID_POLICY' || error?.code === 'INVALID_HISTORY' || error?.code === 'HISTORY_NOT_FOUND' || error?.code === 'INVALID_NOTIFICATION' || error?.code === 'NOTIFICATION_NOT_FOUND' ? 400 : 500
|
|
300
|
+
const code = error?.code === 'SETTINGS_CONFLICT' ? 409 : error?.code === 'SETTINGS_UNAVAILABLE' || error?.code === 'CONFIG_UNAVAILABLE' ? 503 : error?.code === 'PROVIDER_NOT_CONFIGURED' || error?.code === 'UNKNOWN_MODEL' || error?.code === 'INVALID_POLICY' || error?.code === 'INVALID_HISTORY' || error?.code === 'HISTORY_NOT_FOUND' || error?.code === 'INVALID_NOTIFICATION' || error?.code === 'NOTIFICATION_NOT_FOUND' || error?.code === 'INVALID_ALIAS' || error?.code === 'INVALID_CONFIG' ? 400 : 500
|
|
228
301
|
return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
|
|
229
302
|
}
|
|
230
303
|
}
|
|
@@ -232,6 +305,10 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
232
305
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
|
|
233
306
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
|
|
234
307
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/try`, handler: makeHandler('try') }),
|
|
308
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/batch-try`, handler: makeHandler('batch-try') }),
|
|
309
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/export`, handler: makeHandler('export') }),
|
|
310
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/import`, handler: makeHandler('import') }),
|
|
311
|
+
ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases`, handler: makeHandler('aliases') }),
|
|
235
312
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
|
|
236
313
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
|
|
237
314
|
ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
|
package/lib/index.js
CHANGED
|
@@ -1,163 +1,180 @@
|
|
|
1
|
-
import Schema from '@deepseek-ai/schemastery'
|
|
2
|
-
import { createProviderService } from './provider-service.js'
|
|
3
|
-
import { createModelSynchronizer } from './synchronizer.js'
|
|
4
|
-
import { createSyncScheduler } from './scheduler.js'
|
|
5
|
-
import { registerHttpApi } from './http.js'
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}).loose()).default({}),
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
let
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
ctx
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
1
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
2
|
+
import { createProviderService } from './provider-service.js'
|
|
3
|
+
import { createModelSynchronizer } from './synchronizer.js'
|
|
4
|
+
import { createSyncScheduler } from './scheduler.js'
|
|
5
|
+
import { registerHttpApi } from './http.js'
|
|
6
|
+
import { registerPluginUpdater } from './updater.js'
|
|
7
|
+
|
|
8
|
+
export const name = '@goodandready/dsh-model-sync'
|
|
9
|
+
export const inject = ['llm', 'settings', 'webServer', 'credentials']
|
|
10
|
+
|
|
11
|
+
const NS = 'dsh-model-sync'
|
|
12
|
+
|
|
13
|
+
export const Config = Schema.object({
|
|
14
|
+
enabled: Schema.boolean().default(true),
|
|
15
|
+
intervalMinutes: Schema.number().default(60),
|
|
16
|
+
scheduleEnabled: Schema.boolean().default(false),
|
|
17
|
+
jitterMinutes: Schema.number().default(0),
|
|
18
|
+
ttlMinutes: Schema.number().default(0),
|
|
19
|
+
autoApply: Schema.boolean().default(false),
|
|
20
|
+
requestTimeoutMs: Schema.number().default(15000),
|
|
21
|
+
retryAttempts: Schema.number().default(3),
|
|
22
|
+
retryBaseDelayMs: Schema.number().default(250),
|
|
23
|
+
retryMaxDelayMs: Schema.number().default(4000),
|
|
24
|
+
concurrency: Schema.number().default(4),
|
|
25
|
+
circuitBreakerFailures: Schema.number().default(3),
|
|
26
|
+
circuitBreakerCooldownMinutes: Schema.number().default(5),
|
|
27
|
+
historyLimit: Schema.number().default(50),
|
|
28
|
+
notificationLimit: Schema.number().default(50),
|
|
29
|
+
notifications: Schema.array(Schema.object({
|
|
30
|
+
id: Schema.string().required(),
|
|
31
|
+
fingerprint: Schema.string().required(),
|
|
32
|
+
createdAt: Schema.number().required(),
|
|
33
|
+
updatedAt: Schema.number().required(),
|
|
34
|
+
occurrences: Schema.number().default(1),
|
|
35
|
+
readAt: Schema.any().default(null),
|
|
36
|
+
acknowledgedAt: Schema.any().default(null),
|
|
37
|
+
severity: Schema.string().required(),
|
|
38
|
+
title: Schema.string().required(),
|
|
39
|
+
message: Schema.string().required(),
|
|
40
|
+
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
41
|
+
}).loose()).default([]),
|
|
42
|
+
staleGraceRuns: Schema.number().default(2),
|
|
43
|
+
lifecycleRetentionRuns: Schema.number().default(20),
|
|
44
|
+
lifecycleRevision: Schema.number().default(0),
|
|
45
|
+
modelLifecycle: Schema.dict(Schema.dict(Schema.object({
|
|
46
|
+
status: Schema.string().required(),
|
|
47
|
+
consecutiveMissing: Schema.number().default(0),
|
|
48
|
+
firstMissingAt: Schema.number().default(0),
|
|
49
|
+
lastSeenAt: Schema.number().default(0),
|
|
50
|
+
lastObservedRun: Schema.number().default(0),
|
|
51
|
+
}).loose())).default({}),
|
|
52
|
+
history: Schema.array(Schema.object({
|
|
53
|
+
id: Schema.string().required(),
|
|
54
|
+
version: Schema.number().required(),
|
|
55
|
+
startedAt: Schema.number().required(),
|
|
56
|
+
finishedAt: Schema.number().required(),
|
|
57
|
+
providers: Schema.array(Schema.object({}).loose()).default([]),
|
|
58
|
+
}).loose()).default([]),
|
|
59
|
+
providers: Schema.array(Schema.object({
|
|
60
|
+
provider: Schema.string().required(),
|
|
61
|
+
enabled: Schema.boolean().default(true),
|
|
62
|
+
intervalMinutes: Schema.number().default(0),
|
|
63
|
+
jitterMinutes: Schema.number().default(0),
|
|
64
|
+
ttlMinutes: Schema.number().default(0),
|
|
65
|
+
autoApply: Schema.boolean().default(false),
|
|
66
|
+
})).default([]),
|
|
67
|
+
aliases: Schema.dict(Schema.object({
|
|
68
|
+
provider: Schema.string().required(),
|
|
69
|
+
model: Schema.string().required(),
|
|
70
|
+
updatedAt: Schema.number().default(0),
|
|
71
|
+
}).loose()).default({}),
|
|
72
|
+
modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
|
|
73
|
+
adapterRegistry: Schema.dict(Schema.object({
|
|
74
|
+
enabled: Schema.boolean().default(true),
|
|
75
|
+
endpoint: Schema.string().default(''),
|
|
76
|
+
auth: Schema.string().default('bearer'),
|
|
77
|
+
parser: Schema.string().default('openai'),
|
|
78
|
+
modelsPath: Schema.string().default('data'),
|
|
79
|
+
fields: Schema.any().default({}),
|
|
80
|
+
capabilityMap: Schema.any().default({}),
|
|
81
|
+
headers: Schema.dict(Schema.string()).default({}),
|
|
82
|
+
}).loose()).default({}),
|
|
83
|
+
modelPolicies: Schema.dict(Schema.object({
|
|
84
|
+
include: Schema.array(Schema.string()).default([]),
|
|
85
|
+
exclude: Schema.array(Schema.string()).default([]),
|
|
86
|
+
requireCapabilities: Schema.dict(Schema.boolean()).default({}),
|
|
87
|
+
denyCapabilities: Schema.dict(Schema.boolean()).default({}),
|
|
88
|
+
enableCostFilter: Schema.boolean().default(false),
|
|
89
|
+
maxPricePerMillion: Schema.number(),
|
|
90
|
+
enableContextFilter: Schema.boolean().default(false),
|
|
91
|
+
minContextTokens: Schema.number(),
|
|
92
|
+
}).loose()).default({}),
|
|
93
|
+
// Full catalogs are cached separately from the allowlist applied to DSH.
|
|
94
|
+
// This keeps the model picker useful after a restart without widening the
|
|
95
|
+
// active llm-pi-ai catalog.
|
|
96
|
+
modelCatalogs: Schema.dict(Schema.array(Schema.object({
|
|
97
|
+
provider: Schema.string().required(),
|
|
98
|
+
id: Schema.string().required(),
|
|
99
|
+
name: Schema.string().required(),
|
|
100
|
+
}).loose())).default({}),
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
export function apply(ctx, config) {
|
|
104
|
+
const baseConfig = structuredClone(config || {})
|
|
105
|
+
const resolveConfig = (value) => Config(structuredClone(value || {}))
|
|
106
|
+
let liveConfig = resolveConfig(baseConfig)
|
|
107
|
+
let reconfigure = () => {}
|
|
108
|
+
let saveConfigImpl
|
|
109
|
+
// ctx.inject starts a child fiber; keep a stable function reference for the
|
|
110
|
+
// synchronizer while that fiber connects to the Settings service.
|
|
111
|
+
const saveConfig = async (patch) => {
|
|
112
|
+
if (!saveConfigImpl) {
|
|
113
|
+
throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
114
|
+
}
|
|
115
|
+
return saveConfigImpl(patch)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let settingsScope
|
|
119
|
+
const bindSettings = (settings, ownerCtx) => {
|
|
120
|
+
if (settingsScope || !settings?.register) return false
|
|
121
|
+
const scope = settings.register(NS, Config, { base: baseConfig })
|
|
122
|
+
settingsScope = scope
|
|
123
|
+
liveConfig = resolveConfig(scope.get() || baseConfig)
|
|
124
|
+
const stop = scope.watch((next) => {
|
|
125
|
+
liveConfig = resolveConfig(next || baseConfig)
|
|
126
|
+
reconfigure()
|
|
127
|
+
})
|
|
128
|
+
let saveQueue = Promise.resolve()
|
|
129
|
+
saveConfigImpl = (patch) => {
|
|
130
|
+
const nextTask = saveQueue.then(async () => {
|
|
131
|
+
const result = await scope.update(patch)
|
|
132
|
+
liveConfig = resolveConfig(scope.get() || baseConfig)
|
|
133
|
+
return result
|
|
134
|
+
})
|
|
135
|
+
saveQueue = nextTask.catch(() => {})
|
|
136
|
+
return nextTask
|
|
137
|
+
}
|
|
138
|
+
ownerCtx.effect(() => () => {
|
|
139
|
+
settingsScope = undefined
|
|
140
|
+
saveConfigImpl = undefined
|
|
141
|
+
stop()
|
|
142
|
+
}, 'dsh-model-sync: settings')
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let settings
|
|
147
|
+
try { settings = ctx.get('settings') } catch (e) {
|
|
148
|
+
if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('[dsh-model-sync] settings service not immediately available:', e)
|
|
149
|
+
}
|
|
150
|
+
if (!bindSettings(settings, ctx)) {
|
|
151
|
+
ctx.inject(['settings'], (sctx) => {
|
|
152
|
+
bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
const service = createProviderService(ctx)
|
|
158
|
+
const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig, saveConfig })
|
|
159
|
+
const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
|
|
160
|
+
reconfigure = () => scheduler.reconfigure()
|
|
161
|
+
scheduler.start()
|
|
162
|
+
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
163
|
+
ctx.effect(() => {
|
|
164
|
+
const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
|
|
165
|
+
return () => {
|
|
166
|
+
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
|
167
|
+
}
|
|
168
|
+
}, 'dsh-model-sync: http api')
|
|
169
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
170
|
+
packageName: name,
|
|
171
|
+
endpoint: '/api/dsh-model-sync/update',
|
|
172
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
173
|
+
}), 'dsh-model-sync: plugin updater')
|
|
174
|
+
ctx.effect(() => {
|
|
175
|
+
if (!liveConfig.enabled) return () => {}
|
|
176
|
+
ctx.provide('modelSync', service)
|
|
177
|
+
ctx.provide('modelSyncRunner', synchronizer)
|
|
178
|
+
return () => {}
|
|
179
|
+
}, 'dsh-model-sync: service')
|
|
180
|
+
}
|
package/lib/policy.js
CHANGED
|
@@ -16,12 +16,21 @@ function capabilities(value) {
|
|
|
16
16
|
|
|
17
17
|
export function normalizePolicy(policy) {
|
|
18
18
|
const value = policy && typeof policy === 'object' && !Array.isArray(policy) ? policy : {}
|
|
19
|
-
|
|
19
|
+
const res = {
|
|
20
20
|
include: list(value.include),
|
|
21
21
|
exclude: list(value.exclude),
|
|
22
22
|
requireCapabilities: capabilities(value.requireCapabilities),
|
|
23
23
|
denyCapabilities: capabilities(value.denyCapabilities),
|
|
24
24
|
}
|
|
25
|
+
if (value.enableCostFilter) res.enableCostFilter = true
|
|
26
|
+
if (typeof value.maxPricePerMillion === 'number' && Number.isFinite(value.maxPricePerMillion) && value.maxPricePerMillion >= 0) {
|
|
27
|
+
res.maxPricePerMillion = value.maxPricePerMillion
|
|
28
|
+
}
|
|
29
|
+
if (value.enableContextFilter) res.enableContextFilter = true
|
|
30
|
+
if (typeof value.minContextTokens === 'number' && Number.isFinite(value.minContextTokens) && value.minContextTokens >= 0) {
|
|
31
|
+
res.minContextTokens = Math.floor(value.minContextTokens)
|
|
32
|
+
}
|
|
33
|
+
return res
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function compilePatterns(patterns, field) {
|
|
@@ -48,6 +57,8 @@ export function hasPolicy(policy) {
|
|
|
48
57
|
|| normalized.exclude.length > 0
|
|
49
58
|
|| Object.keys(normalized.requireCapabilities).length > 0
|
|
50
59
|
|| Object.keys(normalized.denyCapabilities).length > 0
|
|
60
|
+
|| (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined)
|
|
61
|
+
|| (normalized.enableContextFilter && normalized.minContextTokens !== undefined)
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export function filterModels(models, policy) {
|
|
@@ -66,6 +77,18 @@ export function filterModels(models, policy) {
|
|
|
66
77
|
for (const [key, denied] of Object.entries(normalized.denyCapabilities)) {
|
|
67
78
|
if (modelCapabilities[key] === denied) return false
|
|
68
79
|
}
|
|
80
|
+
if (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined) {
|
|
81
|
+
const inputPrice = model?.pricing?.inputPerToken
|
|
82
|
+
if (typeof inputPrice === 'number' && Number.isFinite(inputPrice)) {
|
|
83
|
+
if (inputPrice * 1000000 > normalized.maxPricePerMillion) return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (normalized.enableContextFilter && normalized.minContextTokens !== undefined) {
|
|
87
|
+
const context = model?.contextWindow
|
|
88
|
+
if (typeof context === 'number' && Number.isFinite(context)) {
|
|
89
|
+
if (context < normalized.minContextTokens) return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
69
92
|
return true
|
|
70
93
|
})
|
|
71
94
|
}
|