@goodandready/dsh-model-sync 0.3.13 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ import { statusOfError } from './reliability.js'
2
+
3
+ export const PIAI_NS = 'llm-pi-ai'
4
+ export const DEFAULT_RELIABILITY = Object.freeze({
5
+ timeoutMs: 15000,
6
+ retryAttempts: 3,
7
+ retryBaseDelayMs: 250,
8
+ retryMaxDelayMs: 4000,
9
+ concurrency: 4,
10
+ circuitBreakerFailures: 3,
11
+ circuitBreakerCooldownMs: 300000,
12
+ })
13
+
14
+ export function boundedInteger(value, fallback, minimum, maximum) {
15
+ const number = Number(value)
16
+ if (!Number.isFinite(number)) return fallback
17
+ return Math.min(maximum, Math.max(minimum, Math.floor(number)))
18
+ }
19
+
20
+ export function reliabilityOptions(config, options) {
21
+ const source = { ...config, ...options }
22
+ const baseDelay = boundedInteger(source.retryBaseDelayMs, DEFAULT_RELIABILITY.retryBaseDelayMs, 0, 60000)
23
+ const cooldownMs = source.circuitBreakerCooldownMs !== undefined
24
+ ? boundedInteger(source.circuitBreakerCooldownMs, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
25
+ : boundedInteger(Number(source.circuitBreakerCooldownMinutes) * 60000, DEFAULT_RELIABILITY.circuitBreakerCooldownMs, 0, 3600000)
26
+ return {
27
+ timeoutMs: boundedInteger(source.requestTimeoutMs, DEFAULT_RELIABILITY.timeoutMs, 100, 120000),
28
+ retryAttempts: boundedInteger(source.retryAttempts, DEFAULT_RELIABILITY.retryAttempts, 1, 6),
29
+ retryBaseDelayMs: baseDelay,
30
+ retryMaxDelayMs: Math.max(baseDelay, boundedInteger(source.retryMaxDelayMs, DEFAULT_RELIABILITY.retryMaxDelayMs, 0, 120000)),
31
+ concurrency: boundedInteger(source.concurrency, DEFAULT_RELIABILITY.concurrency, 1, 32),
32
+ circuitBreakerFailures: boundedInteger(source.circuitBreakerFailures, DEFAULT_RELIABILITY.circuitBreakerFailures, 0, 20),
33
+ circuitBreakerCooldownMs: cooldownMs,
34
+ }
35
+ }
36
+
37
+ export function settingsOf(ctx) {
38
+ try { return (typeof ctx.get === 'function' ? (ctx.get('settings') ?? ctx.settings) : ctx.settings) } catch { return ctx.settings }
39
+ }
40
+
41
+ export function llmOf(ctx) {
42
+ try {
43
+ const llm = typeof ctx.get === 'function' ? ctx.get('llm') : null
44
+ return (llm && typeof llm === 'object' && (llm.listConfigurableProviders || llm.listProviders)) ? llm : ctx.llm
45
+ } catch {
46
+ return ctx.llm
47
+ }
48
+ }
49
+
50
+ export function descriptorOf(settings, ns) {
51
+ try { return settings?.describe?.({ redactSecrets: true })?.find((row) => row.ns === ns) }
52
+ catch { return undefined }
53
+ }
54
+
55
+ export function profileOf(settings, provider) {
56
+ try { return settings?.get(PIAI_NS)?.providers?.[provider] ?? {} }
57
+ catch { return {} }
58
+ }
59
+
60
+ export function errorMessage(error) {
61
+ return error instanceof Error ? error.message : String(error)
62
+ }
63
+
64
+ export 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
+ export 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
+ export 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
+ export 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
+ }
@@ -0,0 +1,50 @@
1
+ export function createModelProber({ health }) {
2
+ async function tryModel({ provider, model } = {}) {
3
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
4
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
5
+ const start = Date.now()
6
+ const result = await health({ provider })
7
+ const entry = result?.results?.find((r) => r.provider === provider)
8
+ return {
9
+ provider,
10
+ model,
11
+ ok: entry?.status === 'ok',
12
+ latencyMs: Date.now() - start,
13
+ status: entry?.status ?? 'unknown',
14
+ ...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
15
+ }
16
+ }
17
+
18
+ async function batchTryModels({ provider, models } = {}) {
19
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
20
+ if (!Array.isArray(models)) throw new Error('models must be an array')
21
+ const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
22
+ const results = []
23
+ const concurrency = 3
24
+ for (let i = 0; i < list.length; i += concurrency) {
25
+ const chunk = list.slice(i, i + concurrency)
26
+ const chunkResults = await Promise.all(
27
+ chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
28
+ provider,
29
+ model,
30
+ ok: false,
31
+ latencyMs: 0,
32
+ status: 'error',
33
+ error: err instanceof Error ? err.message : String(err)
34
+ })))
35
+ )
36
+ results.push(...chunkResults)
37
+ }
38
+ const reachable = results.filter((r) => r.ok).length
39
+ return {
40
+ provider,
41
+ results,
42
+ summary: { total: results.length, reachable, unreachable: results.length - reachable }
43
+ }
44
+ }
45
+
46
+ return {
47
+ tryModel,
48
+ batchTryModels,
49
+ }
50
+ }
@@ -0,0 +1,118 @@
1
+ export function createConfigTransfer({ getConfig = () => ({}), saveConfig = async () => {}, listProviders = () => [], validatePolicy } = {}) {
2
+ function exportConfig() {
3
+ const config = getConfig() ?? {}
4
+ const pmap = {}
5
+ for (const p of listProviders()) {
6
+ if (p.configured) {
7
+ pmap[p.provider] = {
8
+ policy: config.modelPolicies?.[p.provider] ?? {},
9
+ selectedModels: config.modelSelections?.[p.provider] ?? []
10
+ }
11
+ }
12
+ }
13
+ return {
14
+ version: '1.0',
15
+ exportedAt: new Date().toISOString(),
16
+ plugin: '@goodandready/dsh-model-sync',
17
+ providers: pmap,
18
+ aliases: config.aliases ?? {},
19
+ scheduler: {
20
+ scheduleEnabled: config.scheduleEnabled ?? false,
21
+ intervalMinutes: config.intervalMinutes ?? 60,
22
+ autoApply: config.autoApply ?? false
23
+ }
24
+ }
25
+ }
26
+
27
+ async function importConfig(data) {
28
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
29
+ throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
30
+ }
31
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
32
+ const config = getConfig() ?? {}
33
+ const importedProviders = []
34
+ const importedAliases = []
35
+ const patch = {}
36
+
37
+ if (data.providers && typeof data.providers === 'object') {
38
+ const currentPolicies = structuredClone(config.modelPolicies ?? {})
39
+ const currentSelections = structuredClone(config.modelSelections ?? {})
40
+ for (const [pname, pdata] of Object.entries(data.providers)) {
41
+ if (pdata && typeof pdata === 'object') {
42
+ if (pdata.policy) {
43
+ currentPolicies[pname] = typeof validatePolicy === 'function' ? validatePolicy(pdata.policy) : pdata.policy
44
+ importedProviders.push(pname)
45
+ }
46
+ if (Array.isArray(pdata.selectedModels)) {
47
+ currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
48
+ importedProviders.push(pname)
49
+ }
50
+ }
51
+ }
52
+ patch.modelPolicies = currentPolicies
53
+ patch.modelSelections = currentSelections
54
+ }
55
+
56
+ if (data.aliases && typeof data.aliases === 'object') {
57
+ const currentAliases = structuredClone(config.aliases ?? {})
58
+ for (const [k, v] of Object.entries(data.aliases)) {
59
+ if (typeof k === 'string' && /^[a-zA-Z0-9_\-.]{1,64}$/.test(k.trim()) && v && typeof v === 'object' && typeof v.provider === 'string' && typeof v.model === 'string') {
60
+ currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
61
+ importedAliases.push(k.trim())
62
+ }
63
+ }
64
+ patch.aliases = currentAliases
65
+ }
66
+
67
+ if (data.scheduler && typeof data.scheduler === 'object') {
68
+ if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
69
+ if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
70
+ if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
71
+ }
72
+
73
+ await saveConfig(patch)
74
+ return {
75
+ success: true,
76
+ importedProviders: [...new Set(importedProviders)],
77
+ importedAliases: [...new Set(importedAliases)]
78
+ }
79
+ }
80
+
81
+ function getAliases() {
82
+ const config = getConfig() ?? {}
83
+ return config.aliases ?? {}
84
+ }
85
+
86
+ async function setAlias({ alias, provider, model } = {}) {
87
+ if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
88
+ throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
89
+ }
90
+ const trimmedAlias = alias.trim()
91
+ if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
92
+ if (typeof model !== 'string' || !model) throw new Error('model is required')
93
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
94
+ const config = getConfig() ?? {}
95
+ const currentAliases = structuredClone(config.aliases ?? {})
96
+ currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
97
+ await saveConfig({ aliases: currentAliases })
98
+ return { alias: trimmedAlias, provider, model, aliases: currentAliases }
99
+ }
100
+
101
+ async function deleteAlias({ alias } = {}) {
102
+ if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
103
+ if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
104
+ const config = getConfig() ?? {}
105
+ const currentAliases = structuredClone(config.aliases ?? {})
106
+ delete currentAliases[alias.trim()]
107
+ await saveConfig({ aliases: currentAliases })
108
+ return { alias: alias.trim(), deleted: true, aliases: currentAliases }
109
+ }
110
+
111
+ return {
112
+ exportConfig,
113
+ importConfig,
114
+ getAliases,
115
+ setAlias,
116
+ deleteAlias,
117
+ }
118
+ }
@@ -9,117 +9,23 @@ import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './
9
9
  import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
10
10
  import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
11
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
- }
12
+ import {
13
+ PIAI_NS,
14
+ DEFAULT_RELIABILITY,
15
+ boundedInteger,
16
+ reliabilityOptions,
17
+ settingsOf,
18
+ llmOf,
19
+ descriptorOf,
20
+ profileOf,
21
+ errorMessage,
22
+ diagnosticMessage,
23
+ adapterErrorType,
24
+ replaceWithRetry,
25
+ rotationRefs,
26
+ } from './synchronizer-helpers.js'
27
+ import { createConfigTransfer } from './synchronizer-transfer.js'
28
+ import { createModelProber } from './synchronizer-prober.js'
123
29
 
124
30
  export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
125
31
  const resolveCredential = createCredentialResolver(ctx)
@@ -531,22 +437,9 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
531
437
  return transientNotifications.find((row) => row.id === id)
532
438
  }
533
439
 
534
- async function tryModel({ provider, model } = {}) {
535
- if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
536
- if (typeof model !== 'string' || !model) throw new Error('model is required')
537
- const start = Date.now()
538
- // Use health as lightweight probe; latency is the signal
539
- const result = await health({ provider })
540
- const entry = result?.results?.find((r) => r.provider === provider)
541
- return {
542
- provider,
543
- model,
544
- ok: entry?.status === 'ok',
545
- latencyMs: Date.now() - start,
546
- status: entry?.status ?? 'unknown',
547
- ...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
548
- }
549
- }
440
+ const prober = createModelProber({ health })
441
+ const tryModel = prober.tryModel
442
+ const batchTryModels = prober.batchTryModels
550
443
 
551
444
  async function rollbackHistory({ historyId, provider } = {}) {
552
445
  if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
@@ -632,6 +525,17 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
632
525
  }
633
526
  return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
634
527
  }
528
+ const transfer = createConfigTransfer({
529
+ getConfig,
530
+ saveConfig,
531
+ listProviders: providers,
532
+ validatePolicy,
533
+ })
534
+ const exportConfig = transfer.exportConfig
535
+ const importConfig = transfer.importConfig
536
+ const getAliases = transfer.getAliases
537
+ const setAlias = transfer.setAlias
538
+ const deleteAlias = transfer.deleteAlias
635
539
 
636
540
  return Object.freeze({
637
541
  listProviders: providers,
@@ -645,12 +549,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
645
549
  listPolicies,
646
550
  health,
647
551
  tryModel,
552
+ batchTryModels,
553
+ exportConfig,
554
+ importConfig,
555
+ getAliases,
556
+ setAlias,
557
+ deleteAlias,
648
558
  credentialDiagnostics,
649
559
  history,
650
560
  report: () => structuredClone(lastReport),
651
561
  notifications: notificationLedger,
652
562
  updateNotification: updateNotificationState,
653
563
  rollbackHistory,
654
- status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}) }),
564
+ status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
655
565
  })
656
566
  }