@goodandready/dsh-model-sync 0.3.12 → 0.3.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/README.ru.md +34 -0
- package/README.zh.md +34 -0
- package/lib/adapter-registry.js +20 -3
- package/lib/client.js +994 -81
- package/lib/generic-adapter.js +23 -5
- package/lib/http.js +298 -237
- package/lib/index.js +172 -163
- package/lib/models.js +300 -282
- package/lib/policy.js +25 -2
- package/lib/reliability.js +6 -2
- package/lib/synchronizer.js +157 -3
- package/package.json +1 -1
package/lib/policy.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const CAPABILITY_KEYS = new Set(['vision', 'tools', 'reasoning', 'embeddings'])
|
|
1
|
+
const CAPABILITY_KEYS = new Set(['vision', 'tools', 'reasoning', 'embeddings', 'code'])
|
|
2
2
|
const MAX_PATTERNS = 100
|
|
3
3
|
const MAX_PATTERN_LENGTH = 256
|
|
4
4
|
|
|
@@ -16,12 +16,21 @@ function capabilities(value) {
|
|
|
16
16
|
|
|
17
17
|
export function normalizePolicy(policy) {
|
|
18
18
|
const value = policy && typeof policy === 'object' && !Array.isArray(policy) ? policy : {}
|
|
19
|
-
|
|
19
|
+
const res = {
|
|
20
20
|
include: list(value.include),
|
|
21
21
|
exclude: list(value.exclude),
|
|
22
22
|
requireCapabilities: capabilities(value.requireCapabilities),
|
|
23
23
|
denyCapabilities: capabilities(value.denyCapabilities),
|
|
24
24
|
}
|
|
25
|
+
if (value.enableCostFilter) res.enableCostFilter = true
|
|
26
|
+
if (typeof value.maxPricePerMillion === 'number' && Number.isFinite(value.maxPricePerMillion) && value.maxPricePerMillion >= 0) {
|
|
27
|
+
res.maxPricePerMillion = value.maxPricePerMillion
|
|
28
|
+
}
|
|
29
|
+
if (value.enableContextFilter) res.enableContextFilter = true
|
|
30
|
+
if (typeof value.minContextTokens === 'number' && Number.isFinite(value.minContextTokens) && value.minContextTokens >= 0) {
|
|
31
|
+
res.minContextTokens = Math.floor(value.minContextTokens)
|
|
32
|
+
}
|
|
33
|
+
return res
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
function compilePatterns(patterns, field) {
|
|
@@ -48,6 +57,8 @@ export function hasPolicy(policy) {
|
|
|
48
57
|
|| normalized.exclude.length > 0
|
|
49
58
|
|| Object.keys(normalized.requireCapabilities).length > 0
|
|
50
59
|
|| Object.keys(normalized.denyCapabilities).length > 0
|
|
60
|
+
|| (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined)
|
|
61
|
+
|| (normalized.enableContextFilter && normalized.minContextTokens !== undefined)
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
export function filterModels(models, policy) {
|
|
@@ -66,6 +77,18 @@ export function filterModels(models, policy) {
|
|
|
66
77
|
for (const [key, denied] of Object.entries(normalized.denyCapabilities)) {
|
|
67
78
|
if (modelCapabilities[key] === denied) return false
|
|
68
79
|
}
|
|
80
|
+
if (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined) {
|
|
81
|
+
const inputPrice = model?.pricing?.inputPerToken
|
|
82
|
+
if (typeof inputPrice === 'number' && Number.isFinite(inputPrice)) {
|
|
83
|
+
if (inputPrice * 1000000 > normalized.maxPricePerMillion) return false
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (normalized.enableContextFilter && normalized.minContextTokens !== undefined) {
|
|
87
|
+
const context = model?.contextWindow
|
|
88
|
+
if (typeof context === 'number' && Number.isFinite(context)) {
|
|
89
|
+
if (context < normalized.minContextTokens) return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
69
92
|
return true
|
|
70
93
|
})
|
|
71
94
|
}
|
package/lib/reliability.js
CHANGED
|
@@ -105,6 +105,7 @@ export async function retryWithBackoff(operation, {
|
|
|
105
105
|
attempts = 3,
|
|
106
106
|
baseDelayMs = 250,
|
|
107
107
|
maxDelayMs = 4000,
|
|
108
|
+
jitter = true,
|
|
108
109
|
signal,
|
|
109
110
|
onRetry,
|
|
110
111
|
} = {}) {
|
|
@@ -117,11 +118,14 @@ export async function retryWithBackoff(operation, {
|
|
|
117
118
|
} catch (error) {
|
|
118
119
|
lastError = error
|
|
119
120
|
if (attempt >= total || !isRetryableError(error)) throw error
|
|
120
|
-
const
|
|
121
|
+
const exponential = Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1))
|
|
122
|
+
// Full jitter: randomized between [0.5, 1.0] of calculated exponential backoff
|
|
123
|
+
const jitterFactor = jitter ? (0.5 + Math.random() * 0.5) : 1
|
|
124
|
+
const calculatedDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.round(exponential * jitterFactor))
|
|
121
125
|
const retryAfterMs = Number(error?.retryAfterMs)
|
|
122
126
|
const delayMs = (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
|
|
123
127
|
? Math.min(Math.max(0, Number(maxDelayMs) || 0), retryAfterMs)
|
|
124
|
-
:
|
|
128
|
+
: calculatedDelayMs
|
|
125
129
|
onRetry?.({ attempt, nextAttempt: attempt + 1, delayMs, error })
|
|
126
130
|
await delay(delayMs, signal)
|
|
127
131
|
}
|
package/lib/synchronizer.js
CHANGED
|
@@ -132,6 +132,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
132
132
|
const credentialOutcomes = new Map()
|
|
133
133
|
let lastReport = null
|
|
134
134
|
let transientNotifications = []
|
|
135
|
+
const providerHttpMeta = new Map()
|
|
135
136
|
|
|
136
137
|
function recordCredentialOutcome(provider, outcome) {
|
|
137
138
|
credentialOutcomes.set(provider, { ...outcome, at: Date.now() })
|
|
@@ -240,11 +241,12 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
let retries = 0
|
|
244
|
+
const existingMeta = providerHttpMeta.get(row.provider)
|
|
243
245
|
try {
|
|
244
|
-
const
|
|
246
|
+
const result = await retryWithBackoff(async () => {
|
|
245
247
|
const timed = createTimeoutSignal(signal, reliability.timeoutMs)
|
|
246
248
|
try {
|
|
247
|
-
return await adapter.discover(profile, { signal: timed.signal })
|
|
249
|
+
return await adapter.discover(profile, { signal: timed.signal, etag: existingMeta?.etag, lastModified: existingMeta?.lastModified })
|
|
248
250
|
} finally {
|
|
249
251
|
timed.cleanup()
|
|
250
252
|
}
|
|
@@ -257,6 +259,15 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
257
259
|
})
|
|
258
260
|
circuits.delete(row.provider)
|
|
259
261
|
recordCredentialOutcome(row.provider, { status: 'ok', retries })
|
|
262
|
+
if (result && result.notModified) {
|
|
263
|
+
const config = getConfig() ?? {}
|
|
264
|
+
const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? row.models ?? [])
|
|
265
|
+
return { provider: row.provider, status: 'ok', models: cached, retries, notModified: true }
|
|
266
|
+
}
|
|
267
|
+
const models = Array.isArray(result) ? result : []
|
|
268
|
+
if (result?.__httpMeta) {
|
|
269
|
+
providerHttpMeta.set(row.provider, result.__httpMeta)
|
|
270
|
+
}
|
|
260
271
|
return { provider: row.provider, status: 'ok', models, retries }
|
|
261
272
|
} catch (error) {
|
|
262
273
|
if (!signal?.aborted) recordFailure(row.provider, reliability, error)
|
|
@@ -536,6 +547,34 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
536
547
|
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
537
548
|
}
|
|
538
549
|
}
|
|
550
|
+
async function batchTryModels({ provider, models } = {}) {
|
|
551
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
552
|
+
if (!Array.isArray(models)) throw new Error('models must be an array')
|
|
553
|
+
const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
|
|
554
|
+
const results = []
|
|
555
|
+
const concurrency = 3
|
|
556
|
+
for (let i = 0; i < list.length; i += concurrency) {
|
|
557
|
+
const chunk = list.slice(i, i + concurrency)
|
|
558
|
+
const chunkResults = await Promise.all(
|
|
559
|
+
chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
|
|
560
|
+
provider,
|
|
561
|
+
model,
|
|
562
|
+
ok: false,
|
|
563
|
+
latencyMs: 0,
|
|
564
|
+
status: 'error',
|
|
565
|
+
error: err instanceof Error ? err.message : String(err)
|
|
566
|
+
})))
|
|
567
|
+
)
|
|
568
|
+
results.push(...chunkResults)
|
|
569
|
+
}
|
|
570
|
+
const reachable = results.filter((r) => r.ok).length
|
|
571
|
+
return {
|
|
572
|
+
provider,
|
|
573
|
+
results,
|
|
574
|
+
summary: { total: results.length, reachable, unreachable: results.length - reachable }
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
539
578
|
|
|
540
579
|
async function rollbackHistory({ historyId, provider } = {}) {
|
|
541
580
|
if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
|
|
@@ -621,6 +660,115 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
621
660
|
}
|
|
622
661
|
return { provider, selectedModels, models: nextModels, availableModels: available, selectableModels: safeAvailable, applied: true }
|
|
623
662
|
}
|
|
663
|
+
function exportConfig() {
|
|
664
|
+
const config = getConfig() ?? {}
|
|
665
|
+
const pmap = {}
|
|
666
|
+
for (const p of providers()) {
|
|
667
|
+
if (p.configured) {
|
|
668
|
+
pmap[p.provider] = {
|
|
669
|
+
policy: config.modelPolicies?.[p.provider] ?? {},
|
|
670
|
+
selectedModels: config.modelSelections?.[p.provider] ?? []
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return {
|
|
675
|
+
version: '1.0',
|
|
676
|
+
exportedAt: new Date().toISOString(),
|
|
677
|
+
plugin: '@goodandready/dsh-model-sync',
|
|
678
|
+
providers: pmap,
|
|
679
|
+
aliases: config.aliases ?? {},
|
|
680
|
+
scheduler: {
|
|
681
|
+
scheduleEnabled: config.scheduleEnabled ?? false,
|
|
682
|
+
intervalMinutes: config.intervalMinutes ?? 60,
|
|
683
|
+
autoApply: config.autoApply ?? false
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async function importConfig(data) {
|
|
689
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
690
|
+
throw Object.assign(new Error('invalid config payload'), { code: 'INVALID_CONFIG' })
|
|
691
|
+
}
|
|
692
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
693
|
+
const config = getConfig() ?? {}
|
|
694
|
+
const importedProviders = []
|
|
695
|
+
const importedAliases = []
|
|
696
|
+
const patch = {}
|
|
697
|
+
|
|
698
|
+
if (data.providers && typeof data.providers === 'object') {
|
|
699
|
+
const currentPolicies = structuredClone(config.modelPolicies ?? {})
|
|
700
|
+
const currentSelections = structuredClone(config.modelSelections ?? {})
|
|
701
|
+
for (const [pname, pdata] of Object.entries(data.providers)) {
|
|
702
|
+
if (pdata && typeof pdata === 'object') {
|
|
703
|
+
if (pdata.policy) {
|
|
704
|
+
currentPolicies[pname] = validatePolicy(pdata.policy)
|
|
705
|
+
importedProviders.push(pname)
|
|
706
|
+
}
|
|
707
|
+
if (Array.isArray(pdata.selectedModels)) {
|
|
708
|
+
currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
|
|
709
|
+
importedProviders.push(pname)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
patch.modelPolicies = currentPolicies
|
|
714
|
+
patch.modelSelections = currentSelections
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (data.aliases && typeof data.aliases === 'object') {
|
|
718
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
719
|
+
for (const [k, v] of Object.entries(data.aliases)) {
|
|
720
|
+
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') {
|
|
721
|
+
currentAliases[k.trim()] = { provider: v.provider, model: v.model, updatedAt: Date.now() }
|
|
722
|
+
importedAliases.push(k.trim())
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
patch.aliases = currentAliases
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (data.scheduler && typeof data.scheduler === 'object') {
|
|
729
|
+
if (typeof data.scheduler.scheduleEnabled === 'boolean') patch.scheduleEnabled = data.scheduler.scheduleEnabled
|
|
730
|
+
if (typeof data.scheduler.intervalMinutes === 'number') patch.intervalMinutes = data.scheduler.intervalMinutes
|
|
731
|
+
if (typeof data.scheduler.autoApply === 'boolean') patch.autoApply = data.scheduler.autoApply
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
await saveConfig(patch)
|
|
735
|
+
return {
|
|
736
|
+
success: true,
|
|
737
|
+
importedProviders: [...new Set(importedProviders)],
|
|
738
|
+
importedAliases: [...new Set(importedAliases)]
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function getAliases() {
|
|
743
|
+
const config = getConfig() ?? {}
|
|
744
|
+
return config.aliases ?? {}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
async function setAlias({ alias, provider, model } = {}) {
|
|
748
|
+
if (typeof alias !== 'string' || !/^[a-zA-Z0-9_\-.]{1,64}$/.test(alias.trim())) {
|
|
749
|
+
throw Object.assign(new Error('alias must be 1-64 alphanumeric characters or _-.'), { code: 'INVALID_ALIAS' })
|
|
750
|
+
}
|
|
751
|
+
const trimmedAlias = alias.trim()
|
|
752
|
+
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
753
|
+
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
754
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
755
|
+
const config = getConfig() ?? {}
|
|
756
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
757
|
+
currentAliases[trimmedAlias] = { provider, model, updatedAt: Date.now() }
|
|
758
|
+
await saveConfig({ aliases: currentAliases })
|
|
759
|
+
return { alias: trimmedAlias, provider, model, aliases: currentAliases }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
async function deleteAlias({ alias } = {}) {
|
|
763
|
+
if (typeof alias !== 'string' || !alias) throw new Error('alias is required')
|
|
764
|
+
if (typeof saveConfig !== 'function') throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
|
|
765
|
+
const config = getConfig() ?? {}
|
|
766
|
+
const currentAliases = structuredClone(config.aliases ?? {})
|
|
767
|
+
delete currentAliases[alias.trim()]
|
|
768
|
+
await saveConfig({ aliases: currentAliases })
|
|
769
|
+
return { alias: alias.trim(), deleted: true, aliases: currentAliases }
|
|
770
|
+
}
|
|
771
|
+
|
|
624
772
|
|
|
625
773
|
return Object.freeze({
|
|
626
774
|
listProviders: providers,
|
|
@@ -634,12 +782,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
634
782
|
listPolicies,
|
|
635
783
|
health,
|
|
636
784
|
tryModel,
|
|
785
|
+
batchTryModels,
|
|
786
|
+
exportConfig,
|
|
787
|
+
importConfig,
|
|
788
|
+
getAliases,
|
|
789
|
+
setAlias,
|
|
790
|
+
deleteAlias,
|
|
637
791
|
credentialDiagnostics,
|
|
638
792
|
history,
|
|
639
793
|
report: () => structuredClone(lastReport),
|
|
640
794
|
notifications: notificationLedger,
|
|
641
795
|
updateNotification: updateNotificationState,
|
|
642
796
|
rollbackHistory,
|
|
643
|
-
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}) }),
|
|
797
|
+
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
|
|
644
798
|
})
|
|
645
799
|
}
|