@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/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 trusted(req) {
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 { return new URL(origin).host === host } catch { return false }
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 { return new URL(referer).host === host } catch { return false }
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
- export const name = '@goodandready/dsh-model-sync'
8
- export const inject = ['llm', 'settings', 'webServer', 'credentials']
9
-
10
- const NS = 'dsh-model-sync'
11
-
12
- export const Config = Schema.object({
13
- enabled: Schema.boolean().default(true),
14
- intervalMinutes: Schema.number().default(60),
15
- scheduleEnabled: Schema.boolean().default(false),
16
- jitterMinutes: Schema.number().default(0),
17
- ttlMinutes: Schema.number().default(0),
18
- autoApply: Schema.boolean().default(false),
19
- requestTimeoutMs: Schema.number().default(15000),
20
- retryAttempts: Schema.number().default(3),
21
- retryBaseDelayMs: Schema.number().default(250),
22
- retryMaxDelayMs: Schema.number().default(4000),
23
- concurrency: Schema.number().default(4),
24
- circuitBreakerFailures: Schema.number().default(3),
25
- circuitBreakerCooldownMinutes: Schema.number().default(5),
26
- historyLimit: Schema.number().default(50),
27
- notificationLimit: Schema.number().default(50),
28
- notifications: Schema.array(Schema.object({
29
- id: Schema.string().required(),
30
- fingerprint: Schema.string().required(),
31
- createdAt: Schema.number().required(),
32
- updatedAt: Schema.number().required(),
33
- occurrences: Schema.number().default(1),
34
- readAt: Schema.any().default(null),
35
- acknowledgedAt: Schema.any().default(null),
36
- severity: Schema.string().required(),
37
- title: Schema.string().required(),
38
- message: Schema.string().required(),
39
- providers: Schema.array(Schema.object({}).loose()).default([]),
40
- }).loose()).default([]),
41
- staleGraceRuns: Schema.number().default(2),
42
- lifecycleRetentionRuns: Schema.number().default(20),
43
- lifecycleRevision: Schema.number().default(0),
44
- modelLifecycle: Schema.dict(Schema.dict(Schema.object({
45
- status: Schema.string().required(),
46
- consecutiveMissing: Schema.number().default(0),
47
- firstMissingAt: Schema.number().default(0),
48
- lastSeenAt: Schema.number().default(0),
49
- lastObservedRun: Schema.number().default(0),
50
- }).loose())).default({}),
51
- history: Schema.array(Schema.object({
52
- id: Schema.string().required(),
53
- version: Schema.number().required(),
54
- startedAt: Schema.number().required(),
55
- finishedAt: Schema.number().required(),
56
- providers: Schema.array(Schema.object({}).loose()).default([]),
57
- }).loose()).default([]),
58
- providers: Schema.array(Schema.object({
59
- provider: Schema.string().required(),
60
- enabled: Schema.boolean().default(true),
61
- intervalMinutes: Schema.number().default(0),
62
- jitterMinutes: Schema.number().default(0),
63
- ttlMinutes: Schema.number().default(0),
64
- autoApply: Schema.boolean().default(false),
65
- })).default([]),
66
- modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
67
- adapterRegistry: Schema.dict(Schema.object({
68
- enabled: Schema.boolean().default(true),
69
- endpoint: Schema.string().default(''),
70
- auth: Schema.string().default('bearer'),
71
- parser: Schema.string().default('openai'),
72
- modelsPath: Schema.string().default('data'),
73
- fields: Schema.any().default({}),
74
- capabilityMap: Schema.any().default({}),
75
- headers: Schema.dict(Schema.string()).default({}),
76
- }).loose()).default({}),
77
- modelPolicies: Schema.dict(Schema.object({
78
- include: Schema.array(Schema.string()).default([]),
79
- exclude: Schema.array(Schema.string()).default([]),
80
- requireCapabilities: Schema.dict(Schema.boolean()).default({}),
81
- denyCapabilities: Schema.dict(Schema.boolean()).default({}),
82
- }).loose()).default({}),
83
- // Full catalogs are cached separately from the allowlist applied to DSH.
84
- // This keeps the model picker useful after a restart without widening the
85
- // active llm-pi-ai catalog.
86
- modelCatalogs: Schema.dict(Schema.array(Schema.object({
87
- provider: Schema.string().required(),
88
- id: Schema.string().required(),
89
- name: Schema.string().required(),
90
- }).loose())).default({}),
91
- })
92
-
93
- export function apply(ctx, config) {
94
- const baseConfig = structuredClone(config || {})
95
- const resolveConfig = (value) => Config(structuredClone(value || {}))
96
- let liveConfig = resolveConfig(baseConfig)
97
- let reconfigure = () => {}
98
- let saveConfigImpl
99
- // ctx.inject starts a child fiber; keep a stable function reference for the
100
- // synchronizer while that fiber connects to the Settings service.
101
- const saveConfig = async (patch) => {
102
- if (!saveConfigImpl) {
103
- throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
104
- }
105
- return saveConfigImpl(patch)
106
- }
107
-
108
- let settingsScope
109
- const bindSettings = (settings, ownerCtx) => {
110
- if (settingsScope || !settings?.register) return false
111
- const scope = settings.register(NS, Config, { base: baseConfig })
112
- settingsScope = scope
113
- liveConfig = resolveConfig(scope.get() || baseConfig)
114
- const stop = scope.watch((next) => {
115
- liveConfig = resolveConfig(next || baseConfig)
116
- reconfigure()
117
- })
118
- let saveQueue = Promise.resolve()
119
- saveConfigImpl = (patch) => {
120
- const nextTask = saveQueue.then(async () => {
121
- const result = await scope.update(patch)
122
- liveConfig = resolveConfig(scope.get() || baseConfig)
123
- return result
124
- })
125
- saveQueue = nextTask.catch(() => {})
126
- return nextTask
127
- }
128
- ownerCtx.effect(() => () => {
129
- settingsScope = undefined
130
- saveConfigImpl = undefined
131
- stop()
132
- }, 'dsh-model-sync: settings')
133
- return true
134
- }
135
-
136
- let settings
137
- try { settings = ctx.get('settings') } catch {}
138
- if (!bindSettings(settings, ctx)) {
139
- ctx.inject(['settings'], (sctx) => {
140
- bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
141
- })
142
- }
143
-
144
-
145
- const service = createProviderService(ctx)
146
- const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig, saveConfig })
147
- const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
148
- reconfigure = () => scheduler.reconfigure()
149
- scheduler.start()
150
- ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
151
- ctx.effect(() => {
152
- const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
153
- return () => {
154
- for (const dispose of registrations) if (typeof dispose === 'function') dispose()
155
- }
156
- }, 'dsh-model-sync: http api')
157
- ctx.effect(() => {
158
- if (!liveConfig.enabled) return () => {}
159
- ctx.provide('modelSync', service)
160
- ctx.provide('modelSyncRunner', synchronizer)
161
- return () => {}
162
- }, 'dsh-model-sync: service')
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
- return {
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
  }