@goodandready/dsh-model-sync 0.5.6 → 0.5.7
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/alias-resolver.js +22 -8
- package/lib/diff-notifier.js +1 -1
- package/lib/policy.js +18 -4
- package/lib/pricing-database.js +4 -11
- package/lib/reporting.js +1 -1
- package/lib/scheduler.js +1 -2
- package/lib/synchronizer-prober.js +72 -15
- package/lib/synchronizer-transfer.js +2 -2
- package/lib/synchronizer.js +9 -1
- package/package.json +1 -1
package/lib/alias-resolver.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export const VIRTUAL_STRATEGIES = Object.freeze(['fastest', 'cheapest', 'best-code'])
|
|
2
2
|
|
|
3
|
-
export
|
|
3
|
+
export const VIRTUAL_ALIASES = Object.freeze(new Set(['@fast', '@cheap', '@best-code']))
|
|
4
|
+
|
|
5
|
+
export function isVirtualAlias(name, strategy) {
|
|
6
|
+
if (strategy && VIRTUAL_STRATEGIES.includes(typeof strategy === 'string' ? strategy.trim() : '')) return true
|
|
4
7
|
if (typeof name !== 'string') return false
|
|
5
8
|
const clean = name.trim().toLowerCase()
|
|
6
|
-
return
|
|
9
|
+
return VIRTUAL_ALIASES.has(clean)
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export function validateAliasName(name) {
|
|
@@ -135,11 +138,22 @@ export function resolveAlias(aliasName, {
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
if (strategy === 'fastest') {
|
|
138
|
-
const scored = available.map((item) =>
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
const scored = available.map((item) => {
|
|
142
|
+
const probeKey = `${item.provider}:${item.model.id}`
|
|
143
|
+
const probe = modelProbes[probeKey]
|
|
144
|
+
const measured = probe?.latencyMs && Number.isFinite(probe.latencyMs) && probe.ok !== false
|
|
145
|
+
const latencyMs = measured ? Math.round(probe.latencyMs) : estimateLatency(item.provider, item.model, modelProbes)
|
|
146
|
+
return {
|
|
147
|
+
...item,
|
|
148
|
+
latencyMs,
|
|
149
|
+
measured: Boolean(measured),
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
scored.sort((a, b) => {
|
|
153
|
+
if (a.measured && !b.measured) return -1
|
|
154
|
+
if (!a.measured && b.measured) return 1
|
|
155
|
+
return a.latencyMs - b.latencyMs
|
|
156
|
+
})
|
|
143
157
|
const best = scored[0]
|
|
144
158
|
return {
|
|
145
159
|
alias: clean,
|
|
@@ -149,7 +163,7 @@ export function resolveAlias(aliasName, {
|
|
|
149
163
|
name: best.model.name || best.model.id,
|
|
150
164
|
latencyMs: best.latencyMs,
|
|
151
165
|
fallbackUsed: false,
|
|
152
|
-
reason: 'lowest_latency',
|
|
166
|
+
reason: best.measured ? 'lowest_latency' : 'estimated_lowest_latency',
|
|
153
167
|
}
|
|
154
168
|
}
|
|
155
169
|
|
package/lib/diff-notifier.js
CHANGED
|
@@ -74,7 +74,7 @@ export function extractDiffEvents(results, options = {}) {
|
|
|
74
74
|
const bp = pair?.before?.pricing
|
|
75
75
|
const ap = pair?.after?.pricing
|
|
76
76
|
if (bp || ap) {
|
|
77
|
-
const inputDiff = bp?.inputPerToken !== ap?.
|
|
77
|
+
const inputDiff = bp?.inputPerToken !== ap?.inputPerToken
|
|
78
78
|
const outputDiff = bp?.outputPerToken !== ap?.outputPerToken
|
|
79
79
|
if (inputDiff || outputDiff) {
|
|
80
80
|
priceChanged.push({
|
package/lib/policy.js
CHANGED
|
@@ -33,9 +33,21 @@ export function normalizePolicy(policy) {
|
|
|
33
33
|
return res
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
function isDangerousPattern(pattern) {
|
|
37
|
+
if (typeof pattern !== 'string') return false
|
|
38
|
+
// Check for nested quantifiers like (a+)+, (a*)*, (a+)*, (a{1,2})+
|
|
39
|
+
if (/(\([^)]*[+*]\)[+*?{])|(\([^)]*\{[0-9]+,[0-9]*\}\)[+*?{])/.test(pattern)) return true
|
|
40
|
+
// Check for quantified groups containing quantified inner constructs
|
|
41
|
+
if (/\([^\)]*([+*]|\{[0-9]+,[0-9]*\})[^\)]*\)([+*]|\{[0-9]+,[0-9]*\})/.test(pattern)) return true
|
|
42
|
+
// Check for multiple consecutive open-ended wildcards (e.g. .*.*)
|
|
43
|
+
if (/(\.\*|\.\+){2,}/.test(pattern)) return true
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
function compilePatterns(patterns, field) {
|
|
37
48
|
return patterns.map((pattern) => {
|
|
38
49
|
if (pattern.length > MAX_PATTERN_LENGTH) throw Object.assign(new Error(`${field} pattern is too long`), { code: 'INVALID_POLICY' })
|
|
50
|
+
if (isDangerousPattern(pattern)) throw Object.assign(new Error(`${field} pattern has catastrophic backtracking risk: ${pattern}`), { code: 'INVALID_POLICY' })
|
|
39
51
|
try {
|
|
40
52
|
return new RegExp(pattern, 'i')
|
|
41
53
|
} catch {
|
|
@@ -53,10 +65,12 @@ export function validatePolicy(policy) {
|
|
|
53
65
|
|
|
54
66
|
export function hasPolicy(policy) {
|
|
55
67
|
const normalized = normalizePolicy(policy)
|
|
68
|
+
const hasRequire = Object.values(normalized.requireCapabilities).some((v) => v === true)
|
|
69
|
+
const hasDeny = Object.values(normalized.denyCapabilities).some((v) => v === true)
|
|
56
70
|
return normalized.include.length > 0
|
|
57
71
|
|| normalized.exclude.length > 0
|
|
58
|
-
||
|
|
59
|
-
||
|
|
72
|
+
|| hasRequire
|
|
73
|
+
|| hasDeny
|
|
60
74
|
|| (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined)
|
|
61
75
|
|| (normalized.enableContextFilter && normalized.minContextTokens !== undefined)
|
|
62
76
|
}
|
|
@@ -74,10 +88,10 @@ export function filterModels(models, policy) {
|
|
|
74
88
|
if (exclude.some((pattern) => pattern.test(haystack))) return false
|
|
75
89
|
const modelCapabilities = model?.capabilities ?? {}
|
|
76
90
|
for (const [key, expected] of Object.entries(normalized.requireCapabilities)) {
|
|
77
|
-
if (modelCapabilities[key] !==
|
|
91
|
+
if (expected === true && modelCapabilities[key] !== true) return false
|
|
78
92
|
}
|
|
79
93
|
for (const [key, denied] of Object.entries(normalized.denyCapabilities)) {
|
|
80
|
-
if (modelCapabilities[key] ===
|
|
94
|
+
if (denied === true && modelCapabilities[key] === true) return false
|
|
81
95
|
}
|
|
82
96
|
if (normalized.enableCostFilter && normalized.maxPricePerMillion !== undefined) {
|
|
83
97
|
const inputPrice = model?.pricing?.inputPerToken
|
package/lib/pricing-database.js
CHANGED
|
@@ -100,14 +100,13 @@ export function lookupReferencePricing(provider, modelId) {
|
|
|
100
100
|
const rawId = modelId.toLowerCase().trim()
|
|
101
101
|
const cleaned = cleanId(rawId)
|
|
102
102
|
|
|
103
|
-
// 1. Direct check in provider table
|
|
103
|
+
// 1. Direct check in provider table (exact match or date-cleaned match)
|
|
104
104
|
const table = REFERENCE_PRICING[provKey]
|
|
105
105
|
if (table) {
|
|
106
106
|
if (table[rawId]) return { ...table[rawId] }
|
|
107
107
|
if (table[cleaned]) return { ...table[cleaned] }
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}
|
|
108
|
+
// If the provider has a dedicated pricing table, do not search other providers' tables
|
|
109
|
+
return undefined
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
// 2. Vendor prefix check (e.g. "openai/gpt-4o" or "anthropic/claude-3-5-sonnet")
|
|
@@ -120,18 +119,12 @@ export function lookupReferencePricing(provider, modelId) {
|
|
|
120
119
|
if (subTable) {
|
|
121
120
|
if (subTable[subId]) return { ...subTable[subId] }
|
|
122
121
|
if (subTable[subCleaned]) return { ...subTable[subCleaned] }
|
|
123
|
-
for (const [key, val] of Object.entries(subTable)) {
|
|
124
|
-
if (subId.startsWith(key) || subCleaned.startsWith(key)) return { ...val }
|
|
125
|
-
}
|
|
126
122
|
}
|
|
127
123
|
}
|
|
128
124
|
|
|
129
|
-
// 3. Fallback search across
|
|
125
|
+
// 3. Fallback search across tables ONLY for unknown/aggregator providers without a dedicated table
|
|
130
126
|
for (const [p, t] of Object.entries(REFERENCE_PRICING)) {
|
|
131
127
|
if (t[cleaned]) return { ...t[cleaned] }
|
|
132
|
-
for (const [key, val] of Object.entries(t)) {
|
|
133
|
-
if (cleaned.startsWith(key)) return { ...val }
|
|
134
|
-
}
|
|
135
128
|
}
|
|
136
129
|
|
|
137
130
|
return undefined
|
package/lib/reporting.js
CHANGED
|
@@ -127,7 +127,7 @@ export function appendNotification(existing, report, { limit = DEFAULT_NOTIFICAT
|
|
|
127
127
|
rows.push({
|
|
128
128
|
...current,
|
|
129
129
|
updatedAt: now,
|
|
130
|
-
occurrences: boundedInteger(current.occurrences, 1, 1, 1000000)
|
|
130
|
+
occurrences: boundedInteger(Number(current.occurrences || 0) + 1, 1, 1, 1000000),
|
|
131
131
|
})
|
|
132
132
|
} else {
|
|
133
133
|
rows.push(notification)
|
package/lib/scheduler.js
CHANGED
|
@@ -82,6 +82,7 @@ export function createSyncScheduler(sync, {
|
|
|
82
82
|
async function tick() {
|
|
83
83
|
const config = getConfig() ?? {}
|
|
84
84
|
if (config.enabled === false || config.scheduleEnabled === false || running) return
|
|
85
|
+
resetStates(config)
|
|
85
86
|
const rows = scheduleRows(config)
|
|
86
87
|
if (rows.length === 0) return
|
|
87
88
|
const now = nowFn()
|
|
@@ -131,8 +132,6 @@ export function createSyncScheduler(sync, {
|
|
|
131
132
|
}
|
|
132
133
|
|
|
133
134
|
function status() {
|
|
134
|
-
const config = getConfig() ?? {}
|
|
135
|
-
resetStates(config)
|
|
136
135
|
const values = [...state.values()]
|
|
137
136
|
const nextAt = (timer !== null && values.length > 0) ? Math.min(...values.map((row) => row.nextAt)) : null
|
|
138
137
|
const lastRunAt = values.filter((row) => row.lastRunAt).reduce((max, row) => Math.max(max, row.lastRunAt), 0) || null
|
|
@@ -1,40 +1,92 @@
|
|
|
1
|
-
export function createModelProber({ health }) {
|
|
1
|
+
export function createModelProber({ health, getProviderCatalog = () => [] } = {}) {
|
|
2
|
+
const modelProbes = new Map()
|
|
3
|
+
|
|
2
4
|
async function tryModel({ provider, model } = {}) {
|
|
3
5
|
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
4
6
|
if (typeof model !== 'string' || !model) throw new Error('model is required')
|
|
5
7
|
const start = Date.now()
|
|
6
8
|
const result = await health({ provider })
|
|
7
9
|
const entry = result?.results?.find((r) => r.provider === provider)
|
|
8
|
-
|
|
10
|
+
const providerOk = entry?.status === 'ok'
|
|
11
|
+
|
|
12
|
+
// Verify model in provider catalog if catalog is available
|
|
13
|
+
const catalog = typeof getProviderCatalog === 'function' ? getProviderCatalog(provider) : []
|
|
14
|
+
const hasCatalog = Array.isArray(catalog) && catalog.length > 0
|
|
15
|
+
const inCatalog = !hasCatalog || catalog.some((m) => (typeof m === 'string' ? m : m?.id) === model)
|
|
16
|
+
|
|
17
|
+
const ok = Boolean(providerOk && inCatalog)
|
|
18
|
+
const latencyMs = Date.now() - start
|
|
19
|
+
const status = !providerOk ? (entry?.status ?? 'error') : (!inCatalog ? 'model_not_found' : 'ok')
|
|
20
|
+
const errorMsg = !providerOk
|
|
21
|
+
? (entry?.message || entry?.status || 'probe failed')
|
|
22
|
+
: (!inCatalog ? `model "${model}" not found in provider catalog` : undefined)
|
|
23
|
+
|
|
24
|
+
const probeResult = {
|
|
9
25
|
provider,
|
|
10
26
|
model,
|
|
11
|
-
ok
|
|
12
|
-
latencyMs
|
|
13
|
-
status
|
|
14
|
-
...(
|
|
27
|
+
ok,
|
|
28
|
+
latencyMs,
|
|
29
|
+
status,
|
|
30
|
+
...(errorMsg ? { error: errorMsg } : {}),
|
|
15
31
|
}
|
|
32
|
+
|
|
33
|
+
modelProbes.set(`${provider}:${model}`, {
|
|
34
|
+
latencyMs,
|
|
35
|
+
ok,
|
|
36
|
+
status,
|
|
37
|
+
timestamp: Date.now(),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return probeResult
|
|
16
41
|
}
|
|
17
42
|
|
|
18
43
|
async function batchTryModels({ provider, models } = {}) {
|
|
19
44
|
if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
|
|
20
45
|
if (!Array.isArray(models)) throw new Error('models must be an array')
|
|
21
46
|
const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
|
|
47
|
+
|
|
48
|
+
// Probe provider health ONCE for the entire batch rather than per-model
|
|
49
|
+
const start = Date.now()
|
|
50
|
+
const healthResult = await health({ provider })
|
|
51
|
+
const entry = healthResult?.results?.find((r) => r.provider === provider)
|
|
52
|
+
const providerOk = entry?.status === 'ok'
|
|
53
|
+
const catalog = typeof getProviderCatalog === 'function' ? getProviderCatalog(provider) : []
|
|
54
|
+
const hasCatalog = Array.isArray(catalog) && catalog.length > 0
|
|
55
|
+
const baseLatency = Date.now() - start
|
|
56
|
+
|
|
22
57
|
const results = []
|
|
23
|
-
const concurrency =
|
|
58
|
+
const concurrency = 5
|
|
24
59
|
for (let i = 0; i < list.length; i += concurrency) {
|
|
25
60
|
const chunk = list.slice(i, i + concurrency)
|
|
26
|
-
const chunkResults =
|
|
27
|
-
|
|
61
|
+
const chunkResults = chunk.map((model) => {
|
|
62
|
+
const inCatalog = !hasCatalog || catalog.some((m) => (typeof m === 'string' ? m : m?.id) === model)
|
|
63
|
+
const ok = Boolean(providerOk && inCatalog)
|
|
64
|
+
const status = !providerOk ? (entry?.status ?? 'error') : (!inCatalog ? 'model_not_found' : 'ok')
|
|
65
|
+
const errorMsg = !providerOk
|
|
66
|
+
? (entry?.message || entry?.status || 'probe failed')
|
|
67
|
+
: (!inCatalog ? `model "${model}" not found in provider catalog` : undefined)
|
|
68
|
+
|
|
69
|
+
const itemResult = {
|
|
28
70
|
provider,
|
|
29
71
|
model,
|
|
30
|
-
ok
|
|
31
|
-
latencyMs:
|
|
32
|
-
status
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
72
|
+
ok,
|
|
73
|
+
latencyMs: baseLatency,
|
|
74
|
+
status,
|
|
75
|
+
...(errorMsg ? { error: errorMsg } : {}),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
modelProbes.set(`${provider}:${model}`, {
|
|
79
|
+
latencyMs: baseLatency,
|
|
80
|
+
ok,
|
|
81
|
+
status,
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
return itemResult
|
|
86
|
+
})
|
|
36
87
|
results.push(...chunkResults)
|
|
37
88
|
}
|
|
89
|
+
|
|
38
90
|
const reachable = results.filter((r) => r.ok).length
|
|
39
91
|
return {
|
|
40
92
|
provider,
|
|
@@ -43,8 +95,13 @@ export function createModelProber({ health }) {
|
|
|
43
95
|
}
|
|
44
96
|
}
|
|
45
97
|
|
|
98
|
+
function getModelProbes() {
|
|
99
|
+
return Object.fromEntries(modelProbes)
|
|
100
|
+
}
|
|
101
|
+
|
|
46
102
|
return {
|
|
47
103
|
tryModel,
|
|
48
104
|
batchTryModels,
|
|
105
|
+
getModelProbes,
|
|
49
106
|
}
|
|
50
107
|
}
|
|
@@ -74,7 +74,7 @@ export function createConfigTransfer({
|
|
|
74
74
|
const strat = v.strategy.trim()
|
|
75
75
|
if (VIRTUAL_STRATEGIES.includes(strat)) {
|
|
76
76
|
entry.strategy = strat
|
|
77
|
-
entry.isVirtual = isVirtualAlias(k)
|
|
77
|
+
entry.isVirtual = isVirtualAlias(k, strat)
|
|
78
78
|
} else {
|
|
79
79
|
continue
|
|
80
80
|
}
|
|
@@ -125,7 +125,7 @@ export function createConfigTransfer({
|
|
|
125
125
|
}
|
|
126
126
|
currentAliases[trimmedAlias] = {
|
|
127
127
|
strategy: cleanStrategy,
|
|
128
|
-
isVirtual: isVirtualAlias(trimmedAlias),
|
|
128
|
+
isVirtual: isVirtualAlias(trimmedAlias, cleanStrategy),
|
|
129
129
|
updatedAt: Date.now(),
|
|
130
130
|
}
|
|
131
131
|
} else {
|
package/lib/synchronizer.js
CHANGED
|
@@ -452,7 +452,13 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
452
452
|
return transientNotifications.find((row) => row.id === id)
|
|
453
453
|
}
|
|
454
454
|
|
|
455
|
-
const prober = createModelProber({
|
|
455
|
+
const prober = createModelProber({
|
|
456
|
+
health,
|
|
457
|
+
getProviderCatalog: (p) => {
|
|
458
|
+
const row = providers().find((item) => item.provider === p)
|
|
459
|
+
return row?.availableModels || row?.models || []
|
|
460
|
+
},
|
|
461
|
+
})
|
|
456
462
|
|
|
457
463
|
const rollbackHistory = (opts) => performHistoryRollback({ ctx, config: getConfig() ?? {}, providers: providers(), ...opts })
|
|
458
464
|
|
|
@@ -533,6 +539,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
533
539
|
validatePolicy,
|
|
534
540
|
isCircuitOpen: (p) => Boolean(circuits.get(p)?.blockedUntil > Date.now()),
|
|
535
541
|
getHealthResults: () => lastHealth?.results ?? [],
|
|
542
|
+
getModelProbes: () => prober.getModelProbes(),
|
|
536
543
|
})
|
|
537
544
|
|
|
538
545
|
return Object.freeze({
|
|
@@ -547,6 +554,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
547
554
|
listPolicies,
|
|
548
555
|
health,
|
|
549
556
|
...prober,
|
|
557
|
+
getModelProbes: prober.getModelProbes,
|
|
550
558
|
...transfer,
|
|
551
559
|
credentialDiagnostics,
|
|
552
560
|
history,
|