@goodandready/dsh-model-sync 0.3.9 → 0.3.11

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