@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/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
- return {
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
  }
@@ -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 defaultDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1)))
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
- : defaultDelayMs
128
+ : calculatedDelayMs
125
129
  onRetry?.({ attempt, nextAttempt: attempt + 1, delayMs, error })
126
130
  await delay(delayMs, signal)
127
131
  }
@@ -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 models = await retryWithBackoff(async () => {
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",