@goodandready/dsh-model-sync 0.3.13 → 0.3.14

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
@@ -97,6 +97,27 @@ export function normalizeTryRequest(body) {
97
97
  return { provider: body.provider, model: body.model }
98
98
  }
99
99
 
100
+
101
+ export function normalizeBatchTryRequest(body) {
102
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
103
+ if (typeof body.provider !== 'string' || !body.provider) throw new Error('provider is required')
104
+ if (!Array.isArray(body.models)) throw new Error('models must be an array')
105
+ return {
106
+ provider: body.provider.trim(),
107
+ models: body.models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()),
108
+ }
109
+ }
110
+
111
+ export function normalizeAliasRequest(body) {
112
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
113
+ if (typeof body.alias !== 'string' || !body.alias.trim()) throw new Error('alias is required')
114
+ if (!/^[a-zA-Z0-9_\-.]{1,64}$/.test(body.alias.trim())) throw new Error('alias must be 1-64 alphanumeric characters or _-.')
115
+ if (body.action === 'delete') return { action: 'delete', alias: body.alias.trim() }
116
+ if (typeof body.provider !== 'string' || !body.provider.trim() || body.provider.length > 128) throw new Error('provider must be a short string')
117
+ if (typeof body.model !== 'string' || !body.model.trim() || body.model.length > 256) throw new Error('model must be a short string')
118
+ return { action: 'set', alias: body.alias.trim(), provider: body.provider.trim(), model: body.model.trim() }
119
+ }
120
+
100
121
  export function normalizeHealthRequest(body) {
101
122
  if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
102
123
  if (body.provider !== undefined && (typeof body.provider !== 'string' || body.provider.length > 128)) throw new Error('provider must be a short string')
@@ -122,6 +143,10 @@ export function normalizePolicyRequest(body) {
122
143
  exclude: body.exclude,
123
144
  requireCapabilities: body.requireCapabilities,
124
145
  denyCapabilities: body.denyCapabilities,
146
+ enableCostFilter: body.enableCostFilter,
147
+ maxPricePerMillion: body.maxPricePerMillion,
148
+ enableContextFilter: body.enableContextFilter,
149
+ minContextTokens: body.minContextTokens,
125
150
  }),
126
151
  }
127
152
  }
@@ -187,6 +212,24 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
187
212
  try { return json(res, 200, await synchronizer.credentialDiagnostics(normalizeCredentialRequest(req.url))) }
188
213
  catch (error) { return json(res, 400, { error: { code: 'bad-request', message: error instanceof Error ? error.message : String(error) } }) }
189
214
  }
215
+ if (route === 'export') {
216
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
217
+ return json(res, 200, synchronizer.exportConfig())
218
+ }
219
+ if (route === 'aliases' && req.method === 'GET') {
220
+ return json(res, 200, { aliases: synchronizer.getAliases() })
221
+ }
222
+ if (route === 'import') {
223
+ if (req.method !== 'POST') return json(res, 405, { error: { code: 'method', message: 'POST only' } })
224
+ try {
225
+ const body = await readJson(req)
226
+ const result = await synchronizer.importConfig(body)
227
+ return json(res, 200, result)
228
+ } catch (error) {
229
+ const code = error?.code === 'INVALID_CONFIG' || error?.code === 'INVALID_POLICY' ? 400 : 500
230
+ return json(res, code, { error: { code: error?.code ?? 'import-failed', message: error instanceof Error ? error.message : String(error) } })
231
+ }
232
+ }
190
233
  if (route === 'report') {
191
234
  if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
192
235
  return json(res, 200, { report: synchronizer.report() })
@@ -203,6 +246,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
203
246
  ? normalizeSelectionRequest(body)
204
247
  : route === 'policy' ? normalizePolicyRequest(body)
205
248
  : route === 'try' ? normalizeTryRequest(body)
249
+ : route === 'batch-try' ? normalizeBatchTryRequest(body)
250
+ : route === 'aliases' ? normalizeAliasRequest(body)
206
251
  : route === 'health' ? normalizeHealthRequest(body)
207
252
  : route === 'credentials-check' ? normalizeCredentialCheckRequest(body)
208
253
  : route === 'notification-read' || route === 'notification-acknowledge' ? normalizeNotificationAction(body)
@@ -215,6 +260,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
215
260
  ? await synchronizer.setModelSelection(options.provider, options.models)
216
261
  : route === 'policy' ? await synchronizer.setModelPolicy(options.provider, options.policy)
217
262
  : route === 'try' ? await synchronizer.tryModel(options)
263
+ : route === 'batch-try' ? await synchronizer.batchTryModels(options)
264
+ : route === 'aliases' ? (options.action === 'delete' ? await synchronizer.deleteAlias(options) : await synchronizer.setAlias(options))
218
265
  : route === 'health' ? await synchronizer.health(options)
219
266
  : route === 'credentials-check' ? { health: await synchronizer.health(options), credentials: await synchronizer.credentialDiagnostics(options) }
220
267
  : route === 'notification-read' ? await synchronizer.updateNotification(options.id, 'read')
@@ -224,7 +271,7 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
224
271
  return json(res, 200, { ...result, providers: synchronizer.listProviders() })
225
272
  }
226
273
  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
274
+ 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
275
  return json(res, code, { error: { code: error?.code ?? 'run-failed', message: error instanceof Error ? error.message : String(error) } })
229
276
  }
230
277
  }
@@ -232,6 +279,10 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
232
279
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/status`, handler: makeHandler('status') }),
233
280
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/run`, handler: makeHandler('run') }),
234
281
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/try`, handler: makeHandler('try') }),
282
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/batch-try`, handler: makeHandler('batch-try') }),
283
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/export`, handler: makeHandler('export') }),
284
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/import`, handler: makeHandler('import') }),
285
+ ctx.webServer.register({ kind: 'exact', path: `${BASE}/aliases`, handler: makeHandler('aliases') }),
235
286
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/health`, handler: makeHandler('health') }),
236
287
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/selection`, handler: makeHandler('selection') }),
237
288
  ctx.webServer.register({ kind: 'exact', path: `${BASE}/policy`, handler: makeHandler('policy') }),
package/lib/index.js CHANGED
@@ -1,163 +1,172 @@
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
+
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
+ aliases: Schema.dict(Schema.object({
67
+ provider: Schema.string().required(),
68
+ model: Schema.string().required(),
69
+ updatedAt: Schema.number().default(0),
70
+ }).loose()).default({}),
71
+ modelSelections: Schema.dict(Schema.array(Schema.string())).default({}),
72
+ adapterRegistry: Schema.dict(Schema.object({
73
+ enabled: Schema.boolean().default(true),
74
+ endpoint: Schema.string().default(''),
75
+ auth: Schema.string().default('bearer'),
76
+ parser: Schema.string().default('openai'),
77
+ modelsPath: Schema.string().default('data'),
78
+ fields: Schema.any().default({}),
79
+ capabilityMap: Schema.any().default({}),
80
+ headers: Schema.dict(Schema.string()).default({}),
81
+ }).loose()).default({}),
82
+ modelPolicies: Schema.dict(Schema.object({
83
+ include: Schema.array(Schema.string()).default([]),
84
+ exclude: Schema.array(Schema.string()).default([]),
85
+ requireCapabilities: Schema.dict(Schema.boolean()).default({}),
86
+ denyCapabilities: Schema.dict(Schema.boolean()).default({}),
87
+ enableCostFilter: Schema.boolean().default(false),
88
+ maxPricePerMillion: Schema.number(),
89
+ enableContextFilter: Schema.boolean().default(false),
90
+ minContextTokens: Schema.number(),
91
+ }).loose()).default({}),
92
+ // Full catalogs are cached separately from the allowlist applied to DSH.
93
+ // This keeps the model picker useful after a restart without widening the
94
+ // active llm-pi-ai catalog.
95
+ modelCatalogs: Schema.dict(Schema.array(Schema.object({
96
+ provider: Schema.string().required(),
97
+ id: Schema.string().required(),
98
+ name: Schema.string().required(),
99
+ }).loose())).default({}),
100
+ })
101
+
102
+ export function apply(ctx, config) {
103
+ const baseConfig = structuredClone(config || {})
104
+ const resolveConfig = (value) => Config(structuredClone(value || {}))
105
+ let liveConfig = resolveConfig(baseConfig)
106
+ let reconfigure = () => {}
107
+ let saveConfigImpl
108
+ // ctx.inject starts a child fiber; keep a stable function reference for the
109
+ // synchronizer while that fiber connects to the Settings service.
110
+ const saveConfig = async (patch) => {
111
+ if (!saveConfigImpl) {
112
+ throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
113
+ }
114
+ return saveConfigImpl(patch)
115
+ }
116
+
117
+ let settingsScope
118
+ const bindSettings = (settings, ownerCtx) => {
119
+ if (settingsScope || !settings?.register) return false
120
+ const scope = settings.register(NS, Config, { base: baseConfig })
121
+ settingsScope = scope
122
+ liveConfig = resolveConfig(scope.get() || baseConfig)
123
+ const stop = scope.watch((next) => {
124
+ liveConfig = resolveConfig(next || baseConfig)
125
+ reconfigure()
126
+ })
127
+ let saveQueue = Promise.resolve()
128
+ saveConfigImpl = (patch) => {
129
+ const nextTask = saveQueue.then(async () => {
130
+ const result = await scope.update(patch)
131
+ liveConfig = resolveConfig(scope.get() || baseConfig)
132
+ return result
133
+ })
134
+ saveQueue = nextTask.catch(() => {})
135
+ return nextTask
136
+ }
137
+ ownerCtx.effect(() => () => {
138
+ settingsScope = undefined
139
+ saveConfigImpl = undefined
140
+ stop()
141
+ }, 'dsh-model-sync: settings')
142
+ return true
143
+ }
144
+
145
+ let settings
146
+ try { settings = ctx.get('settings') } catch {}
147
+ if (!bindSettings(settings, ctx)) {
148
+ ctx.inject(['settings'], (sctx) => {
149
+ bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
150
+ })
151
+ }
152
+
153
+
154
+ const service = createProviderService(ctx)
155
+ const synchronizer = createModelSynchronizer(ctx, { getConfig: () => liveConfig, saveConfig })
156
+ const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
157
+ reconfigure = () => scheduler.reconfigure()
158
+ scheduler.start()
159
+ ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
160
+ ctx.effect(() => {
161
+ const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
162
+ return () => {
163
+ for (const dispose of registrations) if (typeof dispose === 'function') dispose()
164
+ }
165
+ }, 'dsh-model-sync: http api')
166
+ ctx.effect(() => {
167
+ if (!liveConfig.enabled) return () => {}
168
+ ctx.provide('modelSync', service)
169
+ ctx.provide('modelSyncRunner', synchronizer)
170
+ return () => {}
171
+ }, 'dsh-model-sync: service')
172
+ }
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
  }