@goodandready/dsh-model-sync 0.3.0 → 0.3.1

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.
@@ -1,594 +1,608 @@
1
- import { listApiKeyProviders } from './inventory.js'
2
- import { createProviderAdapterRegistry } from './adapter-registry.js'
3
- import { createCredentialResolver } from './credentials.js'
4
- import { reconcileModels, catalogPatch } from './reconcile.js'
5
- import { normalizeModels } from './models.js'
6
- import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
7
- import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
8
- import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
9
- import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
10
- import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
11
-
12
- const PIAI_NS = 'llm-pi-ai'
13
- const DEFAULT_RELIABILITY = Object.freeze({
14
- timeoutMs: 15000,
15
- retryAttempts: 3,
16
- retryBaseDelayMs: 250,
17
- retryMaxDelayMs: 4000,
18
- concurrency: 4,
19
- circuitBreakerFailures: 3,
20
- circuitBreakerCooldownMs: 300000,
21
- })
22
-
23
- function boundedInteger(value, fallback, minimum, maximum) {
24
- const number = Number(value)
25
- if (!Number.isFinite(number)) return fallback
26
- return Math.min(maximum, Math.max(minimum, Math.floor(number)))
27
- }
28
-
29
- function reliabilityOptions(config, options) {
30
- const source = { ...config, ...options }
31
- const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
32
- const cooldownMs = source.circuitBreakerCooldownMs !== undefined
33
- ? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
34
- : boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
35
- return {
36
- timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
37
- retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
38
- retryBaseDelayMs: baseDelay,
39
- retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
40
- concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
41
- circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
42
- circuitBreakerCooldownMs: cooldownMs,
43
- }
44
- }
45
-
46
- function settingsOf(ctx) {
47
- try { return ctx.get('settings') } catch { return undefined }
48
- }
49
-
50
- function descriptorOf(settings, ns) {
51
- try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
52
- catch { return undefined }
53
- }
54
-
55
- function profileOf(settings, provider) {
56
- try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
57
- catch { return {} }
58
- }
59
-
60
- function errorMessage(error) {
61
- return error instanceof Error ? error.message : String(error)
62
- }
63
-
64
- function diagnosticMessage(error) {
65
- return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
66
- }
67
-
68
- function adapterErrorType(error) {
69
- if (error?.adapterCode === 'auth') return 'auth'
70
- if (error?.adapterCode === 'schema') return 'schema'
71
- if (error?.adapterCode === 'endpoint') return 'endpoint'
72
- if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
73
- const status = statusOfError(error)
74
- if (status === 401 || status === 403) return 'auth'
75
- if (status) return 'http'
76
- return 'endpoint'
77
- }
78
-
79
- function rotationRefs(settings, provider) {
80
- try {
81
- const rows = settings?.get?.('dsh-key-rotation')?.providers
82
- if (!Array.isArray(rows)) return []
83
- return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
84
- } catch {
85
- return []
86
- }
87
- }
88
-
89
- export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
90
- const resolveCredential = createCredentialResolver(ctx)
91
- const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl, adapters: adapterImplementations })
92
- let running = null
93
- let lastRun = null
94
- let lastHealth = null
95
- let healthRunning = null
96
- const circuits = new Map()
97
- const credentialOutcomes = new Map()
98
- let lastReport = null
99
- let transientNotifications = []
100
-
101
- function recordCredentialOutcome(provider, outcome) {
102
- credentialOutcomes.set(provider, { ...outcome, at: Date.now() })
103
- }
104
-
105
- function selectAdapter(profile) {
106
- const config = getConfig() ?? {}
107
- return registry.select(profile, config.adapterRegistry?.[profile.provider])
108
- }
109
-
110
- function refsFor(row, settings) {
111
- const profile = profileOf(settings, row.provider)
112
- const refs = [
113
- row.apiKeyEnv,
114
- profile.credentialRef,
115
- ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
116
- ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
117
- ...rotationRefs(settings, row.provider),
118
- ]
119
- return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
120
- }
121
-
122
- function circuitOpen(provider, reliability) {
123
- if (reliability.circuitBreakerFailures <= 0) return null
124
- const state = circuits.get(provider)
125
- if (!state) return null
126
- if (state.blockedUntil > Date.now()) {
127
- return { retryAfterMs: state.blockedUntil - Date.now() }
128
- }
129
- if (state.blockedUntil) circuits.delete(provider)
130
- return null
131
- }
132
-
133
- function recordFailure(provider, reliability, error) {
134
- if (reliability.circuitBreakerFailures <= 0 || !isRetryableError(error)) return
135
- const state = circuits.get(provider) ?? { failures: 0, blockedUntil: 0 }
136
- state.failures += 1
137
- if (state.failures >= reliability.circuitBreakerFailures) {
138
- state.blockedUntil = Date.now() + reliability.circuitBreakerCooldownMs
139
- }
140
- circuits.set(provider, state)
141
- }
142
-
143
- function policyFor(config, provider) {
144
- return normalizePolicy(config?.modelPolicies?.[provider])
145
- }
146
-
147
- function providers() {
148
- const config = getConfig() ?? {}
149
- const lastResults = new Map((lastRun?.results ?? []).map((row) => [row.provider, row]))
150
- return listApiKeyProviders(ctx.llm, settingsOf(ctx)).map((row) => {
151
- const selection = Array.isArray(config.modelSelections?.[row.provider])
152
- ? [...new Set(config.modelSelections[row.provider].filter((id) => typeof id === 'string' && id))]
153
- : []
154
- const latest = lastResults.get(row.provider)
155
- const policy = policyFor(config, row.provider)
156
- const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider])
157
- const available = latest?.status === 'ok'
158
- ? latest.availableModels ?? latest.models ?? row.models
159
- : cached.length > 0 ? cached : row.models
160
- const availableModels = filterModels(available, policy)
161
- const lifecycleModels = lifecycleStatus(config.modelLifecycle, row.provider, availableModels)
162
- return {
163
- ...row,
164
- selectedModels: selection,
165
- policy,
166
- availableModels,
167
- lifecycleModels,
168
- selectableModels: lifecycleModels.filter((model) => model.lifecycleStatus === 'active'),
169
- }
170
- })
171
- }
172
-
173
- function listPolicies() {
174
- const config = getConfig() ?? {}
175
- return Object.fromEntries(providers()
176
- .filter((row) => row.configured)
177
- .map((row) => [row.provider, policyFor(config, row.provider)]))
178
- }
179
-
180
- function selectionFor(config, provider) {
181
- const selection = config?.modelSelections?.[provider]
182
- return Array.isArray(selection) ? [...new Set(selection.filter((id) => typeof id === 'string' && id))] : []
183
- }
184
-
185
- async function discover(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
186
- const settings = settingsOf(ctx)
187
- const profile = { ...profileOf(settings, row.provider), provider: row.provider }
188
- let adapter
189
- try { adapter = selectAdapter(profile) } catch (error) {
190
- return { provider: row.provider, status: 'invalid-config', models: [], message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
191
- }
192
- if (!adapter) return { provider: row.provider, status: 'unsupported', models: [], message: 'no supported API-key model adapter' }
193
- const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
194
- if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
195
- return { provider: row.provider, status: 'dormant', models: [], message: 'provider has no API key route configured' }
196
- }
197
- const open = circuitOpen(row.provider, reliability)
198
- if (open) {
199
- return {
200
- provider: row.provider,
201
- status: 'circuit-open',
202
- models: [],
203
- message: 'provider circuit is open after repeated transient failures',
204
- retryAfterMs: open.retryAfterMs,
205
- }
206
- }
207
- let retries = 0
208
- try {
209
- const models = await retryWithBackoff(async () => {
210
- const timed = createTimeoutSignal(signal, reliability.timeoutMs)
211
- try {
212
- return await adapter.discover(profile, { signal: timed.signal })
213
- } finally {
214
- timed.cleanup()
215
- }
216
- }, {
217
- attempts: reliability.retryAttempts,
218
- baseDelayMs: reliability.retryBaseDelayMs,
219
- maxDelayMs: reliability.retryMaxDelayMs,
220
- signal,
221
- onRetry: () => { retries += 1 },
222
- })
223
- circuits.delete(row.provider)
224
- recordCredentialOutcome(row.provider, { status: 'ok', retries })
225
- return { provider: row.provider, status: 'ok', models, retries }
226
- } catch (error) {
227
- if (!signal?.aborted) recordFailure(row.provider, reliability, error)
228
- const state = circuits.get(row.provider)
229
- recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
230
- return {
231
- provider: row.provider,
232
- status: 'error',
233
- models: [],
234
- message: diagnosticMessage(error),
235
- errorType: adapterErrorType(error),
236
- retries,
237
- retryable: isRetryableError(error),
238
- circuitOpen: Boolean(state?.blockedUntil > Date.now()),
239
- }
240
- }
241
- }
242
-
243
- async function probe(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
244
- const settings = settingsOf(ctx)
245
- const profile = { ...profileOf(settings, row.provider), provider: row.provider }
246
- let adapter
247
- try { adapter = selectAdapter(profile) } catch (error) {
248
- return { provider: row.provider, status: 'invalid-config', message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
249
- }
250
- if (!adapter?.health) return { provider: row.provider, status: 'unsupported', message: 'provider has no health probe' }
251
- const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
252
- if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
253
- return { provider: row.provider, status: 'dormant', message: 'provider has no API key route configured' }
254
- }
255
- const startedAt = Date.now()
256
- let retries = 0
257
- try {
258
- const result = await retryWithBackoff(async () => {
259
- const timed = createTimeoutSignal(signal, reliability.timeoutMs)
260
- try {
261
- return await adapter.health(profile, { signal: timed.signal })
262
- } finally {
263
- timed.cleanup()
264
- }
265
- }, {
266
- attempts: reliability.retryAttempts,
267
- baseDelayMs: reliability.retryBaseDelayMs,
268
- maxDelayMs: reliability.retryMaxDelayMs,
269
- signal,
270
- onRetry: () => { retries += 1 },
271
- })
272
- recordCredentialOutcome(row.provider, { status: 'ok', code: result?.statusCode ?? 200, retries })
273
- return {
274
- provider: row.provider,
275
- status: 'ok',
276
- statusCode: result?.statusCode ?? 200,
277
- latencyMs: result?.latencyMs ?? Date.now() - startedAt,
278
- retries,
279
- }
280
- } catch (error) {
281
- recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
282
- return {
283
- provider: row.provider,
284
- status: 'error',
285
- statusCode: statusOfError(error),
286
- latencyMs: Date.now() - startedAt,
287
- message: diagnosticMessage(error),
288
- errorType: adapterErrorType(error),
289
- retries,
290
- retryable: isRetryableError(error),
291
- }
292
- }
293
- }
294
-
295
- async function credentialDiagnostics(options = {}) {
296
- const settings = settingsOf(ctx)
297
- const selected = typeof options.provider === 'string' && options.provider
298
- ? providers().filter((row) => row.provider === options.provider && row.configured)
299
- : providers().filter((row) => row.configured)
300
- const results = await Promise.all(selected.map(async (row) => {
301
- const refs = refsFor(row, settings)
302
- const rotation = rotationRefs(settings, row.provider)
303
- const diagnostics = typeof resolveCredential.diagnostics === 'function'
304
- ? await resolveCredential.diagnostics(refs)
305
- : []
306
- return {
307
- provider: row.provider,
308
- displayName: row.displayName,
309
- refs: diagnostics,
310
- rotation: {
311
- configured: rotation.length > 0,
312
- keyCount: rotation.length,
313
- fallbackOrder: diagnostics.filter((item) => rotation.includes(item.ref)).map((item) => item.label),
314
- },
315
- ...(credentialOutcomes.has(row.provider) ? { lastRequest: structuredClone(credentialOutcomes.get(row.provider)) } : {}),
316
- }
317
- }))
318
- return { checkedAt: Date.now(), results }
319
- }
320
-
321
- async function health(options = {}) {
322
- if (healthRunning) return healthRunning
323
- const config = getConfig() ?? {}
324
- const reliability = reliabilityOptions(config, options)
325
- const providerConfig = Array.isArray(config.providers) ? config.providers : []
326
- const selected = typeof options.provider === 'string' && options.provider
327
- ? providers().filter((row) => row.provider === options.provider)
328
- : providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
329
- const startedAt = Date.now()
330
- const task = (async () => {
331
- const results = await mapWithConcurrency(
332
- selected,
333
- (row) => probe(row, { signal: options.signal, reliability }),
334
- reliability.concurrency,
335
- )
336
- const snapshot = { startedAt, finishedAt: Date.now(), reliability, results }
337
- lastHealth = snapshot
338
- return snapshot
339
- })()
340
- healthRunning = task.finally(() => { healthRunning = null })
341
- return healthRunning
342
- }
343
-
344
- async function run(options = {}) {
345
- if (running) return running
346
- const config = getConfig() ?? {}
347
- const reliability = reliabilityOptions(config, options)
348
- const cachedCatalogs = structuredClone(config.modelCatalogs ?? {})
349
- const nextLifecycle = structuredClone(config.modelLifecycle ?? {})
350
- const lifecycleRun = (Number(config.lifecycleRevision) || 0) + 1
351
- const lifecycleConfig = lifecycleOptions(config)
352
- const dryRun = options.dryRun !== false
353
- const removeMissing = options.removeMissing === true
354
- const providerConfig = Array.isArray(config.providers) ? config.providers : []
355
- const selected = typeof options.provider === 'string' && options.provider
356
- ? providers().filter((row) => row.provider === options.provider)
357
- : providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
358
- const startedAt = Date.now()
359
- const task = (async () => {
360
- const discoveredRows = await mapWithConcurrency(
361
- selected,
362
- (row) => discover(row, { signal: options.signal, reliability }),
363
- reliability.concurrency,
364
- )
365
- const results = []
366
- for (let index = 0; index < selected.length; index += 1) {
367
- const row = selected[index]
368
- const discovered = discoveredRows[index]
369
- if (discovered.status !== 'ok') {
370
- results.push({ ...discovered, changed: false })
371
- continue
372
- }
373
- const settings = settingsOf(ctx)
374
- const current = profileOf(settings, row.provider).models ?? row.models
375
- const fullModels = normalizeModels(row.provider, discovered.models)
376
- const lifecycleBefore = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? current)
377
- const lifecycle = updateLifecycle(config.modelLifecycle?.[row.provider], lifecycleBefore, fullModels, {
378
- runNumber: lifecycleRun,
379
- ...lifecycleConfig,
380
- removeMissing,
381
- })
382
- nextLifecycle[row.provider] = lifecycle.records
383
- const policy = policyFor(config, row.provider)
384
- const policyModels = filterModels(fullModels, policy)
385
- const selectedModels = selectionFor(config, row.provider)
386
- const advertised = selectedModels.length
387
- ? policyModels.filter((model) => selectedModels.includes(model.id))
388
- : policyModels
389
- const explicitRemoveIds = new Set(lifecycle.removedIds)
390
- const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
391
- results.push({
392
- ...discovered,
393
- discoveredModels: fullModels,
394
- availableModels: policyModels,
395
- policy,
396
- selectedModels,
397
- advertised,
398
- lifecycle: lifecycle.statuses,
399
- lifecycleChanges: lifecycle.changes,
400
- ...reconciliation,
401
- removeMissing: lifecycle.removedIds.length > 0,
402
- changed: reconciliation.changed,
403
- })
404
- const freshIds = new Set(fullModels.map((model) => model.id))
405
- const removedIds = new Set(lifecycle.removedIds)
406
- const retainedCatalog = lifecycleBefore.filter((model) => !freshIds.has(model.id) && !removedIds.has(model.id))
407
- cachedCatalogs[row.provider] = normalizeModels(row.provider, [...retainedCatalog, ...fullModels])
408
- }
409
- const result = { startedAt, finishedAt: Date.now(), dryRun, removeMissing, reliability, lifecycleRevision: lifecycleRun, results, applied: false }
410
- const report = createSyncReport(result, { source: options.reportSource === 'schedule' ? 'schedule' : 'manual' })
411
- lastReport = report
412
- const notificationLimit = config.notificationLimit
413
- transientNotifications = appendNotification(transientNotifications, report, { limit: notificationLimit })
414
- result.report = report
415
- if (!dryRun && results.some((row) => row.status === 'ok' && row.changed)) {
416
- const settings = settingsOf(ctx)
417
- const section = settings?.get?.(PIAI_NS)
418
- const descriptor = descriptorOf(settings, PIAI_NS)
419
- if (!section || !descriptor || typeof settings.replace !== 'function') {
420
- throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
421
- }
422
- const next = structuredClone(section)
423
- for (const row of results) {
424
- if (row.status === 'ok' && row.changed) next.providers = catalogPatch(next, row.provider, row.next).providers
425
- }
426
- await settings.replace(PIAI_NS, next, descriptor.revision)
427
- result.applied = true
428
- }
429
- if (!dryRun) {
430
- const previousCatalogs = config.modelCatalogs ?? {}
431
- const patch = { modelLifecycle: nextLifecycle, lifecycleRevision: lifecycleRun }
432
- if (JSON.stringify(previousCatalogs) !== JSON.stringify(cachedCatalogs)) patch.modelCatalogs = cachedCatalogs
433
- const previousHistory = Array.isArray(config.history) ? config.history : []
434
- const nextVersion = previousHistory.reduce((max, entry) => Math.max(max, Number(entry?.version) || 0), 0) + 1
435
- const entry = createHistoryEntry(result, { version: nextVersion })
436
- patch.history = appendHistory(previousHistory, entry, config.historyLimit)
437
- const previousNotifications = Array.isArray(config.notifications) ? config.notifications : []
438
- const nextNotifications = appendNotification(previousNotifications, report, { limit: config.notificationLimit })
439
- if (JSON.stringify(previousNotifications) !== JSON.stringify(nextNotifications)) patch.notifications = nextNotifications
440
- result.historyId = entry.id
441
- try {
442
- await saveConfig(patch)
443
- if (patch.modelCatalogs) result.catalogCachePersisted = true
444
- result.historyPersisted = true
445
- } catch (error) {
446
- // The active DSH catalog has already been revision-checked. A cache
447
- // or history failure is visible but must not undo that successful
448
- // provider update.
449
- if (patch.modelCatalogs) result.catalogCacheError = errorMessage(error)
450
- result.historyError = errorMessage(error)
451
- }
452
- }
453
- result.notifications = notificationLedger()
454
- lastRun = result
455
- return result
456
- })()
457
- running = task.finally(() => { running = null })
458
- return running
459
- }
460
-
461
- function history(options = {}) {
462
- const config = getConfig() ?? {}
463
- return listHistory(config.history, options)
464
- }
465
-
466
- function notificationLedger(options = {}) {
467
- const config = getConfig() ?? {}
468
- const byId = new Map()
469
- for (const row of [...(Array.isArray(config.notifications) ? config.notifications : []), ...transientNotifications]) {
470
- if (row?.id) byId.set(row.id, row)
471
- }
472
- return listNotifications([...byId.values()], options)
473
- }
474
-
475
- async function updateNotificationState(id, field) {
476
- const config = getConfig() ?? {}
477
- const persisted = Array.isArray(config.notifications) ? config.notifications : []
478
- if (persisted.some((row) => row?.id === id)) {
479
- const next = updateNotification(persisted, id, field)
480
- await saveConfig({ notifications: next })
481
- transientNotifications = transientNotifications.filter((row) => row?.id !== id)
482
- return next.find((row) => row.id === id)
483
- }
484
- transientNotifications = updateNotification(transientNotifications, id, field)
485
- return transientNotifications.find((row) => row.id === id)
486
- }
487
-
488
- async function rollbackHistory({ historyId, provider } = {}) {
489
- if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
490
- if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
491
- const config = getConfig() ?? {}
492
- const entry = historyEntry(config.history, historyId)
493
- if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
494
- const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
495
- if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
496
- if (!providers().some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
497
- const settings = settingsOf(ctx)
498
- const section = settings?.get?.(PIAI_NS)
499
- const descriptor = descriptorOf(settings, PIAI_NS)
500
- if (!section || !descriptor || typeof settings.replace !== 'function') {
501
- throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
502
- }
503
- const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
504
- const target = normalizeModels(provider, snapshot.before)
505
- if (JSON.stringify(current) === JSON.stringify(target)) {
506
- return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
507
- }
508
- const next = catalogPatch(section, provider, target)
509
- await settings.replace(PIAI_NS, next, descriptor.revision)
510
- return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
511
- }
512
-
513
- async function setModelPolicy(provider, policy) {
514
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
515
- const row = providers().find((item) => item.provider === provider && item.configured)
516
- if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
517
- const normalized = validatePolicy(policy)
518
- const config = getConfig() ?? {}
519
- const previous = structuredClone(config.modelPolicies ?? {})
520
- const modelPolicies = { ...previous, [provider]: normalized }
521
- await saveConfig({ modelPolicies })
522
- return { provider, policy: normalized, applied: false }
523
- }
524
-
525
- async function setModelSelection(provider, models) {
526
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
527
- if (!Array.isArray(models)) throw new Error('models must be an array')
528
- const selectedModels = [...new Set(models.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
529
- const config = getConfig() ?? {}
530
- const row = providers().find((item) => item.provider === provider)
531
- if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
532
- const latest = lastRun?.results?.find((item) => item.provider === provider && item.status === 'ok')
533
- const cached = normalizeModels(provider, config.modelCatalogs?.[provider])
534
- const latestCatalog = latest?.availableModels ?? latest?.models
535
- const source = latestCatalog !== undefined
536
- ? latestCatalog
537
- : cached.length > 0 ? cached : row.availableModels ?? row.models
538
- const available = filterModels(normalizeModels(provider, source), policyFor(config, provider))
539
- const safeAvailable = selectableModels(config.modelLifecycle, provider, available)
540
- const availableById = new Map(available.map((model) => [model.id, model]))
541
- const unknown = selectedModels.filter((id) => !availableById.has(id))
542
- if (unknown.length > 0) throw Object.assign(new Error(`unknown models for ${provider}: ${unknown.join(', ')}`), { code: 'UNKNOWN_MODEL' })
543
- if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
544
-
545
- const settings = settingsOf(ctx)
546
- const section = settings?.get?.(PIAI_NS)
547
- const descriptor = descriptorOf(settings, PIAI_NS)
548
- if (!section || !descriptor || typeof settings.replace !== 'function') {
549
- throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
550
- }
551
- const current = normalizeModels(provider, profileOf(settings, provider).models ?? row.models)
552
- const nextModels = selectedModels.length
553
- ? selectedModels.map((id) => availableById.get(id))
554
- : (safeAvailable.length > 0 ? safeAvailable : current)
555
- const next = catalogPatch(section, provider, nextModels)
556
- const previousModelSelections = structuredClone(config.modelSelections ?? {})
557
- const modelSelections = { ...previousModelSelections, [provider]: selectedModels }
558
-
559
- // Persist the allowlist first. If the LLM catalog write fails, roll the
560
- // allowlist back so the two namespaces cannot be left disagreeing.
561
- await saveConfig({ modelSelections })
562
- try {
563
- await settings.replace(PIAI_NS, next, descriptor.revision)
564
- } catch (error) {
565
- try {
566
- await saveConfig({ modelSelections: previousModelSelections })
567
- } catch {
568
- // Preserve the original, more actionable catalog-write error.
569
- }
570
- throw error
571
- }
572
- return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
573
- }
574
-
575
- return Object.freeze({
576
- listProviders: providers,
577
- getProvider: (provider) => providers().find((row) => row.provider === provider) ?? null,
578
- adapterRegistry: registry,
579
- registerAdapter: registry.register,
580
- discover,
581
- run,
582
- setModelSelection,
583
- setModelPolicy,
584
- listPolicies,
585
- health,
586
- credentialDiagnostics,
587
- history,
588
- report: () => structuredClone(lastReport),
589
- notifications: notificationLedger,
590
- updateNotification: updateNotificationState,
591
- rollbackHistory,
592
- status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}) }),
593
- })
594
- }
1
+ import { listApiKeyProviders } from './inventory.js'
2
+ import { createProviderAdapterRegistry } from './adapter-registry.js'
3
+ import { createCredentialResolver } from './credentials.js'
4
+ import { reconcileModels, catalogPatch } from './reconcile.js'
5
+ import { normalizeModels } from './models.js'
6
+ import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
7
+ import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
8
+ import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
9
+ import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
10
+ import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
11
+
12
+ const PIAI_NS = 'llm-pi-ai'
13
+ const DEFAULT_RELIABILITY = Object.freeze({
14
+ timeoutMs: 15000,
15
+ retryAttempts: 3,
16
+ retryBaseDelayMs: 250,
17
+ retryMaxDelayMs: 4000,
18
+ concurrency: 4,
19
+ circuitBreakerFailures: 3,
20
+ circuitBreakerCooldownMs: 300000,
21
+ })
22
+
23
+ function boundedInteger(value, fallback, minimum, maximum) {
24
+ const number = Number(value)
25
+ if (!Number.isFinite(number)) return fallback
26
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)))
27
+ }
28
+
29
+ function reliabilityOptions(config, options) {
30
+ const source = { ...config, ...options }
31
+ const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
32
+ const cooldownMs = source.circuitBreakerCooldownMs !== undefined
33
+ ? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
34
+ : boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
35
+ return {
36
+ timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
37
+ retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
38
+ retryBaseDelayMs: baseDelay,
39
+ retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
40
+ concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
41
+ circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
42
+ circuitBreakerCooldownMs: cooldownMs,
43
+ }
44
+ }
45
+
46
+ function settingsOf(ctx) {
47
+ try { return ctx.get('settings') } catch { return undefined }
48
+ }
49
+
50
+ function descriptorOf(settings, ns) {
51
+ try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
52
+ catch { return undefined }
53
+ }
54
+
55
+ function profileOf(settings, provider) {
56
+ try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
57
+ catch { return {} }
58
+ }
59
+
60
+ function errorMessage(error) {
61
+ return error instanceof Error ? error.message : String(error)
62
+ }
63
+
64
+ function diagnosticMessage(error) {
65
+ return errorMessage(error).replace(/(api[_-]?key|token|secret|authorization|password)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]').slice(0, 160)
66
+ }
67
+
68
+ function adapterErrorType(error) {
69
+ if (error?.adapterCode === 'auth') return 'auth'
70
+ if (error?.adapterCode === 'schema') return 'schema'
71
+ if (error?.adapterCode === 'endpoint') return 'endpoint'
72
+ if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
73
+ const status = statusOfError(error)
74
+ if (status === 401 || status === 403) return 'auth'
75
+ if (status) return 'http'
76
+ return 'endpoint'
77
+ }
78
+
79
+ function rotationRefs(settings, provider) {
80
+ try {
81
+ const rows = settings?.get?.('dsh-key-rotation')?.providers
82
+ if (!Array.isArray(rows)) return []
83
+ return rows.filter((row) => row?.provider === provider).flatMap((row) => Array.isArray(row.keys) ? row.keys : [])
84
+ } catch {
85
+ return []
86
+ }
87
+ }
88
+
89
+ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
90
+ const resolveCredential = createCredentialResolver(ctx)
91
+ const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl, adapters: adapterImplementations })
92
+ let running = null
93
+ let lastRun = null
94
+ let lastHealth = null
95
+ let healthRunning = null
96
+ const circuits = new Map()
97
+ const credentialOutcomes = new Map()
98
+ let lastReport = null
99
+ let transientNotifications = []
100
+
101
+ function recordCredentialOutcome(provider, outcome) {
102
+ credentialOutcomes.set(provider, { ...outcome, at: Date.now() })
103
+ }
104
+
105
+ function selectAdapter(profile) {
106
+ const config = getConfig() ?? {}
107
+ return registry.select(profile, config.adapterRegistry?.[profile.provider])
108
+ }
109
+
110
+ function refsFor(row, settings) {
111
+ const profile = profileOf(settings, row.provider)
112
+ const refs = [
113
+ row.apiKeyEnv,
114
+ profile.credentialRef,
115
+ ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
116
+ ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
117
+ ...rotationRefs(settings, row.provider),
118
+ ]
119
+ return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
120
+ }
121
+
122
+ function circuitOpen(provider, reliability) {
123
+ if (reliability.circuitBreakerFailures <= 0) return null
124
+ const state = circuits.get(provider)
125
+ if (!state) return null
126
+ if (state.blockedUntil > Date.now()) {
127
+ return { retryAfterMs: state.blockedUntil - Date.now() }
128
+ }
129
+ if (state.blockedUntil) circuits.delete(provider)
130
+ return null
131
+ }
132
+
133
+ function recordFailure(provider, reliability, error) {
134
+ if (reliability.circuitBreakerFailures <= 0 || !isRetryableError(error)) return
135
+ const state = circuits.get(provider) ?? { failures: 0, blockedUntil: 0 }
136
+ state.failures += 1
137
+ if (state.failures >= reliability.circuitBreakerFailures) {
138
+ state.blockedUntil = Date.now() + reliability.circuitBreakerCooldownMs
139
+ }
140
+ circuits.set(provider, state)
141
+ }
142
+
143
+ function policyFor(config, provider) {
144
+ return normalizePolicy(config?.modelPolicies?.[provider])
145
+ }
146
+
147
+ function providers() {
148
+ const config = getConfig() ?? {}
149
+ const lastResults = new Map((lastRun?.results ?? []).map((row) => [row.provider, row]))
150
+ return listApiKeyProviders(ctx.llm, settingsOf(ctx)).map((row) => {
151
+ const selection = Array.isArray(config.modelSelections?.[row.provider])
152
+ ? [...new Set(config.modelSelections[row.provider].filter((id) => typeof id === 'string' && id))]
153
+ : []
154
+ const latest = lastResults.get(row.provider)
155
+ const policy = policyFor(config, row.provider)
156
+ const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider])
157
+ const available = latest?.status === 'ok'
158
+ ? latest.availableModels ?? latest.models ?? row.models
159
+ : cached.length > 0 ? cached : row.models
160
+ const availableModels = filterModels(available, policy)
161
+ const lifecycleModels = lifecycleStatus(config.modelLifecycle, row.provider, availableModels)
162
+ return {
163
+ ...row,
164
+ selectedModels: selection,
165
+ policy,
166
+ availableModels,
167
+ lifecycleModels,
168
+ selectableModels: lifecycleModels.filter((model) => model.lifecycleStatus === 'active'),
169
+ }
170
+ })
171
+ }
172
+
173
+ function listPolicies() {
174
+ const config = getConfig() ?? {}
175
+ return Object.fromEntries(providers()
176
+ .filter((row) => row.configured)
177
+ .map((row) => [row.provider, policyFor(config, row.provider)]))
178
+ }
179
+
180
+ function selectionFor(config, provider) {
181
+ const selection = config?.modelSelections?.[provider]
182
+ return Array.isArray(selection) ? [...new Set(selection.filter((id) => typeof id === 'string' && id))] : []
183
+ }
184
+
185
+ async function discover(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
186
+ const settings = settingsOf(ctx)
187
+ const profile = { ...profileOf(settings, row.provider), provider: row.provider }
188
+ let adapter
189
+ try { adapter = selectAdapter(profile) } catch (error) {
190
+ return { provider: row.provider, status: 'invalid-config', models: [], message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
191
+ }
192
+ if (!adapter) return { provider: row.provider, status: 'unsupported', models: [], message: 'no supported API-key model adapter' }
193
+ const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
194
+ if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
195
+ return { provider: row.provider, status: 'dormant', models: [], message: 'provider has no API key route configured' }
196
+ }
197
+ const open = circuitOpen(row.provider, reliability)
198
+ if (open) {
199
+ return {
200
+ provider: row.provider,
201
+ status: 'circuit-open',
202
+ models: [],
203
+ message: 'provider circuit is open after repeated transient failures',
204
+ retryAfterMs: open.retryAfterMs,
205
+ }
206
+ }
207
+ let retries = 0
208
+ try {
209
+ const models = await retryWithBackoff(async () => {
210
+ const timed = createTimeoutSignal(signal, reliability.timeoutMs)
211
+ try {
212
+ return await adapter.discover(profile, { signal: timed.signal })
213
+ } finally {
214
+ timed.cleanup()
215
+ }
216
+ }, {
217
+ attempts: reliability.retryAttempts,
218
+ baseDelayMs: reliability.retryBaseDelayMs,
219
+ maxDelayMs: reliability.retryMaxDelayMs,
220
+ signal,
221
+ onRetry: () => { retries += 1 },
222
+ })
223
+ circuits.delete(row.provider)
224
+ recordCredentialOutcome(row.provider, { status: 'ok', retries })
225
+ return { provider: row.provider, status: 'ok', models, retries }
226
+ } catch (error) {
227
+ if (!signal?.aborted) recordFailure(row.provider, reliability, error)
228
+ const state = circuits.get(row.provider)
229
+ recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
230
+ return {
231
+ provider: row.provider,
232
+ status: 'error',
233
+ models: [],
234
+ message: diagnosticMessage(error),
235
+ errorType: adapterErrorType(error),
236
+ retries,
237
+ retryable: isRetryableError(error),
238
+ circuitOpen: Boolean(state?.blockedUntil > Date.now()),
239
+ }
240
+ }
241
+ }
242
+
243
+ async function probe(row, { signal, reliability = DEFAULT_RELIABILITY } = {}) {
244
+ const settings = settingsOf(ctx)
245
+ const profile = { ...profileOf(settings, row.provider), provider: row.provider }
246
+ let adapter
247
+ try { adapter = selectAdapter(profile) } catch (error) {
248
+ return { provider: row.provider, status: 'invalid-config', message: diagnosticMessage(error), code: error?.code ?? 'ADAPTER_CONFIG_INVALID' }
249
+ }
250
+ if (!adapter?.health) return { provider: row.provider, status: 'unsupported', message: 'provider has no health probe' }
251
+ const adapterConfig = getConfig()?.adapterRegistry?.[row.provider]
252
+ if (!profile.apiKeyEnv && !profile.baseURL && !adapterConfig?.endpoint && !profile.adapterId) {
253
+ return { provider: row.provider, status: 'dormant', message: 'provider has no API key route configured' }
254
+ }
255
+ const startedAt = Date.now()
256
+ let retries = 0
257
+ try {
258
+ const result = await retryWithBackoff(async () => {
259
+ const timed = createTimeoutSignal(signal, reliability.timeoutMs)
260
+ try {
261
+ return await adapter.health(profile, { signal: timed.signal })
262
+ } finally {
263
+ timed.cleanup()
264
+ }
265
+ }, {
266
+ attempts: reliability.retryAttempts,
267
+ baseDelayMs: reliability.retryBaseDelayMs,
268
+ maxDelayMs: reliability.retryMaxDelayMs,
269
+ signal,
270
+ onRetry: () => { retries += 1 },
271
+ })
272
+ recordCredentialOutcome(row.provider, { status: 'ok', code: result?.statusCode ?? 200, retries })
273
+ return {
274
+ provider: row.provider,
275
+ status: 'ok',
276
+ statusCode: result?.statusCode ?? 200,
277
+ latencyMs: result?.latencyMs ?? Date.now() - startedAt,
278
+ retries,
279
+ }
280
+ } catch (error) {
281
+ recordCredentialOutcome(row.provider, { status: 'error', code: statusOfError(error), errorType: adapterErrorType(error), retryable: isRetryableError(error), message: diagnosticMessage(error), retries })
282
+ return {
283
+ provider: row.provider,
284
+ status: 'error',
285
+ statusCode: statusOfError(error),
286
+ latencyMs: Date.now() - startedAt,
287
+ message: diagnosticMessage(error),
288
+ errorType: adapterErrorType(error),
289
+ retries,
290
+ retryable: isRetryableError(error),
291
+ }
292
+ }
293
+ }
294
+
295
+ async function credentialDiagnostics(options = {}) {
296
+ const settings = settingsOf(ctx)
297
+ const selected = typeof options.provider === 'string' && options.provider
298
+ ? providers().filter((row) => row.provider === options.provider && row.configured)
299
+ : providers().filter((row) => row.configured)
300
+ const results = await Promise.all(selected.map(async (row) => {
301
+ const refs = refsFor(row, settings)
302
+ const rotation = rotationRefs(settings, row.provider)
303
+ const diagnostics = typeof resolveCredential.diagnostics === 'function'
304
+ ? await resolveCredential.diagnostics(refs)
305
+ : []
306
+ return {
307
+ provider: row.provider,
308
+ displayName: row.displayName,
309
+ refs: diagnostics,
310
+ rotation: {
311
+ configured: rotation.length > 0,
312
+ keyCount: rotation.length,
313
+ fallbackOrder: diagnostics.filter((item) => rotation.includes(item.ref)).map((item) => item.label),
314
+ },
315
+ ...(credentialOutcomes.has(row.provider) ? { lastRequest: structuredClone(credentialOutcomes.get(row.provider)) } : {}),
316
+ }
317
+ }))
318
+ return { checkedAt: Date.now(), results }
319
+ }
320
+
321
+ async function health(options = {}) {
322
+ if (healthRunning) return healthRunning
323
+ const config = getConfig() ?? {}
324
+ const reliability = reliabilityOptions(config, options)
325
+ const providerConfig = Array.isArray(config.providers) ? config.providers : []
326
+ const selected = typeof options.provider === 'string' && options.provider
327
+ ? providers().filter((row) => row.provider === options.provider)
328
+ : providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
329
+ const startedAt = Date.now()
330
+ const task = (async () => {
331
+ const results = await mapWithConcurrency(
332
+ selected,
333
+ (row) => probe(row, { signal: options.signal, reliability }),
334
+ reliability.concurrency,
335
+ )
336
+ const snapshot = { startedAt, finishedAt: Date.now(), reliability, results }
337
+ lastHealth = snapshot
338
+ return snapshot
339
+ })()
340
+ healthRunning = task.finally(() => { healthRunning = null })
341
+ return healthRunning
342
+ }
343
+
344
+ async function run(options = {}) {
345
+ if (running) return running
346
+ const config = getConfig() ?? {}
347
+ const reliability = reliabilityOptions(config, options)
348
+ const cachedCatalogs = structuredClone(config.modelCatalogs ?? {})
349
+ const nextLifecycle = structuredClone(config.modelLifecycle ?? {})
350
+ const lifecycleRun = (Number(config.lifecycleRevision) || 0) + 1
351
+ const lifecycleConfig = lifecycleOptions(config)
352
+ const dryRun = options.dryRun !== false
353
+ const removeMissing = options.removeMissing === true
354
+ const providerConfig = Array.isArray(config.providers) ? config.providers : []
355
+ const globalAutoApply = config.autoApply === true
356
+ const providerAutoApply = (provider) => { const entry = providerConfig.find((item) => item.provider === provider); return entry && typeof entry.autoApply === 'boolean' ? entry.autoApply : globalAutoApply }
357
+ const selected = typeof options.provider === 'string' && options.provider
358
+ ? providers().filter((row) => row.provider === options.provider)
359
+ : providers().filter((row) => providerConfig.length === 0 || providerConfig.some((item) => item.provider === row.provider && item.enabled !== false))
360
+ const startedAt = Date.now()
361
+ const task = (async () => {
362
+ const discoveredRows = await mapWithConcurrency(
363
+ selected,
364
+ (row) => discover(row, { signal: options.signal, reliability }),
365
+ reliability.concurrency,
366
+ )
367
+ const results = []
368
+ for (let index = 0; index < selected.length; index += 1) {
369
+ const row = selected[index]
370
+ const discovered = discoveredRows[index]
371
+ if (discovered.status !== 'ok') {
372
+ results.push({ ...discovered, changed: false })
373
+ continue
374
+ }
375
+ const settings = settingsOf(ctx)
376
+ const current = profileOf(settings, row.provider).models ?? row.models
377
+ const fullModels = normalizeModels(row.provider, discovered.models)
378
+ const lifecycleBefore = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? current)
379
+ const lifecycle = updateLifecycle(config.modelLifecycle?.[row.provider], lifecycleBefore, fullModels, {
380
+ runNumber: lifecycleRun,
381
+ ...lifecycleConfig,
382
+ removeMissing,
383
+ })
384
+ nextLifecycle[row.provider] = lifecycle.records
385
+ const policy = policyFor(config, row.provider)
386
+ const policyModels = filterModels(fullModels, policy)
387
+ const selectedModels = selectionFor(config, row.provider)
388
+ const advertised = selectedModels.length
389
+ ? policyModels.filter((model) => selectedModels.includes(model.id))
390
+ : policyModels
391
+ const explicitRemoveIds = new Set(lifecycle.removedIds)
392
+ const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
393
+ results.push({
394
+ ...discovered,
395
+ discoveredModels: fullModels,
396
+ availableModels: policyModels,
397
+ policy,
398
+ selectedModels,
399
+ advertised,
400
+ lifecycle: lifecycle.statuses,
401
+ lifecycleChanges: lifecycle.changes,
402
+ ...reconciliation,
403
+ removeMissing: lifecycle.removedIds.length > 0,
404
+ changed: reconciliation.changed,
405
+ })
406
+ const freshIds = new Set(fullModels.map((model) => model.id))
407
+ const removedIds = new Set(lifecycle.removedIds)
408
+ const retainedCatalog = lifecycleBefore.filter((model) => !freshIds.has(model.id) && !removedIds.has(model.id))
409
+ cachedCatalogs[row.provider] = normalizeModels(row.provider, [...retainedCatalog, ...fullModels])
410
+ }
411
+ const result = { startedAt, finishedAt: Date.now(), dryRun, removeMissing, reliability, lifecycleRevision: lifecycleRun, results, applied: false }
412
+ const report = createSyncReport(result, { source: options.reportSource === 'schedule' ? 'schedule' : 'manual' })
413
+ lastReport = report
414
+ const notificationLimit = config.notificationLimit
415
+ transientNotifications = appendNotification(transientNotifications, report, { limit: notificationLimit })
416
+ result.report = report
417
+ const resultsToApply = results.filter((row) => row.status === 'ok' && row.changed && (!dryRun || providerAutoApply(row.provider)))
418
+ if (resultsToApply.length > 0) {
419
+ const settings = settingsOf(ctx)
420
+ const section = settings?.get?.(PIAI_NS)
421
+ const descriptor = descriptorOf(settings, PIAI_NS)
422
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
423
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
424
+ }
425
+ const next = structuredClone(section)
426
+ for (const row of resultsToApply) {
427
+ next.providers = catalogPatch(next, row.provider, row.next).providers
428
+ }
429
+ await settings.replace(PIAI_NS, next, descriptor.revision)
430
+ result.applied = true
431
+ }
432
+ if (!dryRun || resultsToApply.length > 0) {
433
+ const previousCatalogs = config.modelCatalogs ?? {}
434
+ const patch = { modelLifecycle: nextLifecycle, lifecycleRevision: lifecycleRun }
435
+ if (JSON.stringify(previousCatalogs) !== JSON.stringify(cachedCatalogs)) patch.modelCatalogs = cachedCatalogs
436
+ const previousHistory = Array.isArray(config.history) ? config.history : []
437
+ const nextVersion = previousHistory.reduce((max, entry) => Math.max(max, Number(entry?.version) || 0), 0) + 1
438
+ const entry = createHistoryEntry(result, { version: nextVersion })
439
+ patch.history = appendHistory(previousHistory, entry, config.historyLimit)
440
+ const previousNotifications = Array.isArray(config.notifications) ? config.notifications : []
441
+ const nextNotifications = appendNotification(previousNotifications, report, { limit: config.notificationLimit })
442
+ if (JSON.stringify(previousNotifications) !== JSON.stringify(nextNotifications)) patch.notifications = nextNotifications
443
+ result.historyId = entry.id
444
+ try {
445
+ await saveConfig(patch)
446
+ if (patch.modelCatalogs) result.catalogCachePersisted = true
447
+ result.historyPersisted = true
448
+ } catch (error) {
449
+ // The active DSH catalog has already been revision-checked. A cache
450
+ // or history failure is visible but must not undo that successful
451
+ // provider update.
452
+ if (patch.modelCatalogs) result.catalogCacheError = errorMessage(error)
453
+ result.historyError = errorMessage(error)
454
+ }
455
+ }
456
+ result.notifications = notificationLedger()
457
+ lastRun = result
458
+ return result
459
+ })()
460
+ running = task.finally(() => { running = null })
461
+ return running
462
+ }
463
+
464
+ function history(options = {}) {
465
+ const config = getConfig() ?? {}
466
+ return listHistory(config.history, options)
467
+ }
468
+
469
+ function notificationLedger(options = {}) {
470
+ const config = getConfig() ?? {}
471
+ const byId = new Map()
472
+ for (const row of [...(Array.isArray(config.notifications) ? config.notifications : []), ...transientNotifications]) {
473
+ if (row?.id) byId.set(row.id, row)
474
+ }
475
+ return listNotifications([...byId.values()], options)
476
+ }
477
+
478
+ async function updateNotificationState(id, field) {
479
+ const config = getConfig() ?? {}
480
+ const persisted = Array.isArray(config.notifications) ? config.notifications : []
481
+ if (persisted.some((row) => row?.id === id)) {
482
+ const next = updateNotification(persisted, id, field)
483
+ await saveConfig({ notifications: next })
484
+ transientNotifications = transientNotifications.filter((row) => row?.id !== id)
485
+ return next.find((row) => row.id === id)
486
+ }
487
+ transientNotifications = updateNotification(transientNotifications, id, field)
488
+ return transientNotifications.find((row) => row.id === id)
489
+ }
490
+
491
+ async function tryModel({ provider, model } = {}) {
492
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
493
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
494
+ const start = Date.now()
495
+ // Use health as lightweight probe; latency is the signal
496
+ const result = await health({ provider })
497
+ const entry = result?.results?.find((r) => r.provider === provider)
498
+ return { provider, model, ok: entry?.status === 'ok', latencyMs: Date.now() - start, status: entry?.status ?? 'unknown' }
499
+ }
500
+
501
+ async function rollbackHistory({ historyId, provider } = {}) {
502
+ if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
503
+ if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
504
+ const config = getConfig() ?? {}
505
+ const entry = historyEntry(config.history, historyId)
506
+ if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
507
+ const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
508
+ if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
509
+ if (!providers().some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
510
+ const settings = settingsOf(ctx)
511
+ const section = settings?.get?.(PIAI_NS)
512
+ const descriptor = descriptorOf(settings, PIAI_NS)
513
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
514
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
515
+ }
516
+ const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
517
+ const target = normalizeModels(provider, snapshot.before)
518
+ if (JSON.stringify(current) === JSON.stringify(target)) {
519
+ return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
520
+ }
521
+ const next = catalogPatch(section, provider, target)
522
+ await settings.replace(PIAI_NS, next, descriptor.revision)
523
+ return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
524
+ }
525
+
526
+ async function setModelPolicy(provider, policy) {
527
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
528
+ const row = providers().find((item) => item.provider === provider && item.configured)
529
+ if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
530
+ const normalized = validatePolicy(policy)
531
+ const config = getConfig() ?? {}
532
+ const previous = structuredClone(config.modelPolicies ?? {})
533
+ const modelPolicies = { ...previous, [provider]: normalized }
534
+ await saveConfig({ modelPolicies })
535
+ return { provider, policy: normalized, applied: false }
536
+ }
537
+
538
+ async function setModelSelection(provider, models) {
539
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
540
+ if (!Array.isArray(models)) throw new Error('models must be an array')
541
+ const selectedModels = [...new Set(models.filter((id) => typeof id === 'string' && id.trim()).map((id) => id.trim()))]
542
+ const config = getConfig() ?? {}
543
+ const row = providers().find((item) => item.provider === provider)
544
+ if (!row) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
545
+ const latest = lastRun?.results?.find((item) => item.provider === provider && item.status === 'ok')
546
+ const cached = normalizeModels(provider, config.modelCatalogs?.[provider])
547
+ const latestCatalog = latest?.availableModels ?? latest?.models
548
+ const source = latestCatalog !== undefined
549
+ ? latestCatalog
550
+ : cached.length > 0 ? cached : row.availableModels ?? row.models
551
+ const available = filterModels(normalizeModels(provider, source), policyFor(config, provider))
552
+ const safeAvailable = selectableModels(config.modelLifecycle, provider, available)
553
+ const availableById = new Map(available.map((model) => [model.id, model]))
554
+ const unknown = selectedModels.filter((id) => !availableById.has(id))
555
+ if (unknown.length > 0) throw Object.assign(new Error(`unknown models for ${provider}: ${unknown.join(', ')}`), { code: 'UNKNOWN_MODEL' })
556
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
557
+
558
+ const settings = settingsOf(ctx)
559
+ const section = settings?.get?.(PIAI_NS)
560
+ const descriptor = descriptorOf(settings, PIAI_NS)
561
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
562
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
563
+ }
564
+ const current = normalizeModels(provider, profileOf(settings, provider).models ?? row.models)
565
+ const nextModels = selectedModels.length
566
+ ? selectedModels.map((id) => availableById.get(id))
567
+ : (safeAvailable.length > 0 ? safeAvailable : current)
568
+ const next = catalogPatch(section, provider, nextModels)
569
+ const previousModelSelections = structuredClone(config.modelSelections ?? {})
570
+ const modelSelections = { ...previousModelSelections, [provider]: selectedModels }
571
+
572
+ // Persist the allowlist first. If the LLM catalog write fails, roll the
573
+ // allowlist back so the two namespaces cannot be left disagreeing.
574
+ await saveConfig({ modelSelections })
575
+ try {
576
+ await settings.replace(PIAI_NS, next, descriptor.revision)
577
+ } catch (error) {
578
+ try {
579
+ await saveConfig({ modelSelections: previousModelSelections })
580
+ } catch {
581
+ // Preserve the original, more actionable catalog-write error.
582
+ }
583
+ throw error
584
+ }
585
+ return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
586
+ }
587
+
588
+ return Object.freeze({
589
+ listProviders: providers,
590
+ getProvider: (provider) => providers().find((row) => row.provider === provider) ?? null,
591
+ adapterRegistry: registry,
592
+ registerAdapter: registry.register,
593
+ discover,
594
+ run,
595
+ setModelSelection,
596
+ setModelPolicy,
597
+ listPolicies,
598
+ health,
599
+ tryModel,
600
+ credentialDiagnostics,
601
+ history,
602
+ report: () => structuredClone(lastReport),
603
+ notifications: notificationLedger,
604
+ updateNotification: updateNotificationState,
605
+ rollbackHistory,
606
+ status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}) }),
607
+ })
608
+ }