@goodandready/dsh-model-sync 0.3.14 → 0.4.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.
package/lib/index.js CHANGED
@@ -3,6 +3,7 @@ import { createProviderService } from './provider-service.js'
3
3
  import { createModelSynchronizer } from './synchronizer.js'
4
4
  import { createSyncScheduler } from './scheduler.js'
5
5
  import { registerHttpApi } from './http.js'
6
+ import { registerPluginUpdater } from './updater.js'
6
7
 
7
8
  export const name = '@goodandready/dsh-model-sync'
8
9
  export const inject = ['llm', 'settings', 'webServer', 'credentials']
@@ -143,7 +144,9 @@ export function apply(ctx, config) {
143
144
  }
144
145
 
145
146
  let settings
146
- try { settings = ctx.get('settings') } catch {}
147
+ try { settings = ctx.get('settings') } catch (e) {
148
+ if (typeof ctx.logger?.debug === 'function') ctx.logger.debug('[dsh-model-sync] settings service not immediately available:', e)
149
+ }
147
150
  if (!bindSettings(settings, ctx)) {
148
151
  ctx.inject(['settings'], (sctx) => {
149
152
  bindSettings((typeof sctx.get === 'function' ? sctx.get('settings') : null) ?? sctx.settings, sctx)
@@ -163,6 +166,11 @@ export function apply(ctx, config) {
163
166
  for (const dispose of registrations) if (typeof dispose === 'function') dispose()
164
167
  }
165
168
  }, 'dsh-model-sync: http api')
169
+ ctx.effect(() => registerPluginUpdater(ctx, {
170
+ packageName: name,
171
+ endpoint: '/api/dsh-model-sync/update',
172
+ manifestUrl: new URL('../package.json', import.meta.url),
173
+ }), 'dsh-model-sync: plugin updater')
166
174
  ctx.effect(() => {
167
175
  if (!liveConfig.enabled) return () => {}
168
176
  ctx.provide('modelSync', service)
package/lib/models.js CHANGED
@@ -92,7 +92,14 @@ function normalizeCapabilities(row) {
92
92
  }
93
93
  }
94
94
 
95
- const sources = [row?.capabilities, row?.supportedCapabilities, row?.supported_capabilities]
95
+ const sources = [
96
+ row?.capabilities,
97
+ row?.supportedCapabilities,
98
+ row?.supported_capabilities,
99
+ row?.supportedFeatures,
100
+ row?.supported_features,
101
+ row?.features,
102
+ ]
96
103
  for (const source of sources) {
97
104
  if (Array.isArray(source)) {
98
105
  for (const token of source) {
@@ -148,6 +155,74 @@ function normalizeCapabilities(row) {
148
155
  return Object.keys(result).length > 0 ? result : undefined
149
156
  }
150
157
 
158
+
159
+ function isGroqMinijinjaModel(id) {
160
+ if (typeof id !== 'string') return false
161
+ const lower = id.toLowerCase().replace(/^groq\//, '')
162
+ return (
163
+ lower.startsWith('qwen') ||
164
+ lower.startsWith('meta-llama') ||
165
+ lower.startsWith('llama') ||
166
+ lower.startsWith('mistral') ||
167
+ lower.startsWith('mixtral') ||
168
+ lower.startsWith('gemma') ||
169
+ lower.startsWith('deepseek')
170
+ )
171
+ }
172
+
173
+ function normalizeCompat(provider, row, id) {
174
+ const source = row?.compat && typeof row.compat === 'object' ? { ...row.compat } : {}
175
+ const isGroq = provider === 'groq'
176
+ const isDeepSeek = provider === 'deepseek'
177
+
178
+ let defaultSupportsDeveloperRole
179
+ if (isGroq && id && isGroqMinijinjaModel(id)) {
180
+ defaultSupportsDeveloperRole = false
181
+ } else if (isDeepSeek) {
182
+ defaultSupportsDeveloperRole = false
183
+ }
184
+
185
+ if (source.supportsDeveloperRole === undefined && defaultSupportsDeveloperRole !== undefined) {
186
+ source.supportsDeveloperRole = defaultSupportsDeveloperRole
187
+ }
188
+
189
+ return Object.keys(source).length > 0 ? source : undefined
190
+ }
191
+
192
+ function normalizeInput(row) {
193
+ const modalitySources = [row?.inputModalities, row?.input_modalities, row?.modalities]
194
+ for (const source of modalitySources) {
195
+ if (Array.isArray(source) && source.length > 0) {
196
+ const set = new Set()
197
+ for (const item of source) {
198
+ if (typeof item !== 'string') continue
199
+ const val = item.trim().toLowerCase()
200
+ if (val === 'image' || val === 'vision') set.add('image')
201
+ else if (val === 'text') set.add('text')
202
+ else if (val) set.add(val)
203
+ }
204
+ const order = ['text', 'image']
205
+ const result = []
206
+ for (const k of order) {
207
+ if (set.has(k)) {
208
+ result.push(k)
209
+ set.delete(k)
210
+ }
211
+ }
212
+ for (const k of set) {
213
+ result.push(k)
214
+ }
215
+ if (result.length > 0) return result
216
+ }
217
+ }
218
+
219
+ if (Array.isArray(row?.input)) {
220
+ return row.input.map((item) => (typeof item === 'string' ? item.trim() : item)).filter(Boolean)
221
+ }
222
+
223
+ return undefined
224
+ }
225
+
151
226
  function normalizeTags(row) {
152
227
  const source = row?.tags ?? row?.labels
153
228
  if (!Array.isArray(source)) return undefined
@@ -208,6 +283,9 @@ export function normalizeModel(provider, row) {
208
283
  row = { id: row }
209
284
  }
210
285
  if (!row || typeof row !== 'object') return null
286
+ const effectiveProvider = (typeof provider === 'string' && provider.trim())
287
+ ? provider.trim()
288
+ : (typeof row.provider === 'string' ? row.provider.trim() : '')
211
289
  const id = String(row.id ?? row.slug ?? row.model ?? '').trim()
212
290
  if (!id) return null
213
291
  const name = String(row.name ?? row.displayName ?? row.display_name ?? id).trim() || id
@@ -218,13 +296,17 @@ export function normalizeModel(provider, row) {
218
296
  const tags = normalizeTags(row)
219
297
  const aliases = normalizeAliases(row, id)
220
298
  const lifecycle = normalizeLifecycle(row)
299
+ const input = normalizeInput(row)
300
+ const compat = normalizeCompat(effectiveProvider, row, id)
221
301
  return {
222
- provider,
302
+ provider: effectiveProvider || provider,
223
303
  id,
224
304
  name,
225
305
  ...(contextWindow === undefined ? {} : { contextWindow }),
226
306
  ...(maxTokens === undefined ? {} : { maxTokens }),
227
307
  ...(row.description ? { description: String(row.description) } : {}),
308
+ ...(input === undefined ? {} : { input }),
309
+ ...(compat === undefined ? {} : { compat }),
228
310
  ...(capabilities === undefined ? {} : { capabilities }),
229
311
  ...(pricing === undefined ? {} : { pricing }),
230
312
  ...(tags === undefined ? {} : { tags }),
package/lib/reconcile.js CHANGED
@@ -18,8 +18,20 @@ export function reconcileModels(provider, current, discovered, {
18
18
  const stale = []
19
19
  for (const row of before) {
20
20
  const fresh = advertisedById.get(row.id)
21
- if (fresh) next.push(fresh)
22
- else if (renamedBefore.has(row.id)) {
21
+ if (fresh) {
22
+ const merged = {
23
+ ...row,
24
+ ...fresh,
25
+ ...(row.capabilities || fresh.capabilities ? {
26
+ capabilities: { ...(fresh.capabilities ?? {}), ...(row.capabilities ?? {}) },
27
+ } : {}),
28
+ ...(row.compat || fresh.compat ? {
29
+ compat: { ...(fresh.compat ?? {}), ...(row.compat ?? {}) },
30
+ } : {}),
31
+ ...(fresh.input?.length ? { input: fresh.input } : row.input ? { input: row.input } : {}),
32
+ }
33
+ next.push(merged)
34
+ } else if (renamedBefore.has(row.id)) {
23
35
  // An explicit provider alias is safe evidence that the old id was renamed.
24
36
  } else {
25
37
  stale.push(row)
@@ -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
+ }