@goodandready/dsh-model-sync 0.5.5 → 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/adapter-registry.js +18 -0
- package/lib/alias-resolver.js +22 -8
- package/lib/diff-notifier.js +28 -2
- package/lib/http.js +33 -5
- package/lib/index.js +2 -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 +21 -2
- package/lib/updater.js +0 -1
- package/package.json +1 -1
package/lib/adapter-registry.js
CHANGED
|
@@ -137,6 +137,24 @@ export function normalizeAdapterConfig(config, profile = {}) {
|
|
|
137
137
|
if (endpoint.protocol === 'http:' && !privateHttpHost(endpoint.hostname)) {
|
|
138
138
|
throw Object.assign(new Error('adapter endpoint must use https outside loopback'), { code: 'ADAPTER_CONFIG_INVALID' })
|
|
139
139
|
}
|
|
140
|
+
if (profile?.provider && PROVIDER_ADAPTERS[profile.provider]) {
|
|
141
|
+
const defaultEndpoint = PROVIDER_ADAPTERS[profile.provider].endpoint
|
|
142
|
+
let defaultUrl
|
|
143
|
+
try { defaultUrl = new URL(defaultEndpoint) } catch (_err) { defaultUrl = null }
|
|
144
|
+
if (defaultUrl) {
|
|
145
|
+
const knownHost = defaultUrl.hostname.toLowerCase()
|
|
146
|
+
const targetHost = endpoint.hostname.toLowerCase()
|
|
147
|
+
const isDomainMatch = targetHost === knownHost || targetHost.endsWith('.' + knownHost.replace(/^api\./, ''))
|
|
148
|
+
const isLoopback = privateHttpHost(targetHost)
|
|
149
|
+
const isExplicitlyAllowed = source.allowCustomHost === true || source.allowCustomEndpoint === true
|
|
150
|
+
if (!isDomainMatch && !isLoopback && !isExplicitlyAllowed) {
|
|
151
|
+
throw Object.assign(
|
|
152
|
+
new Error(`adapter endpoint host "${endpoint.hostname}" does not match known provider domain for "${profile.provider}"`),
|
|
153
|
+
{ code: 'ADAPTER_HOST_MISMATCH' }
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
140
158
|
const auth = source.auth === undefined ? 'bearer' : String(source.auth)
|
|
141
159
|
if (!SAFE_AUTH.has(auth)) throw Object.assign(new Error('adapter auth mode is unsupported'), { code: 'ADAPTER_CONFIG_INVALID' })
|
|
142
160
|
const parse = source.parser === undefined ? 'openai' : String(source.parser)
|
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({
|
|
@@ -114,6 +114,20 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
|
|
|
114
114
|
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
|
|
115
115
|
return { ok: false, error: "URL must use http or https protocol" }
|
|
116
116
|
}
|
|
117
|
+
const hostname = parsedUrl.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
118
|
+
if (
|
|
119
|
+
hostname === '169.254.169.254' ||
|
|
120
|
+
hostname.startsWith('169.254.') ||
|
|
121
|
+
hostname === 'metadata.google.internal' ||
|
|
122
|
+
hostname === '0.0.0.0' ||
|
|
123
|
+
hostname === '255.255.255.255'
|
|
124
|
+
) {
|
|
125
|
+
return { ok: false, error: "webhook url targets a forbidden metadata or link-local address" }
|
|
126
|
+
}
|
|
127
|
+
const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
|
|
128
|
+
if (parsedUrl.protocol === "http:" && !isLoopback) {
|
|
129
|
+
return { ok: false, error: "insecure http webhook url is only permitted on loopback" }
|
|
130
|
+
}
|
|
117
131
|
} catch (err) {
|
|
118
132
|
return { ok: false, error: "Invalid URL: " + (err?.message || String(err)) }
|
|
119
133
|
}
|
|
@@ -181,9 +195,21 @@ export async function dispatchDiffNotifications(ctx, config, results, { fetchImp
|
|
|
181
195
|
|
|
182
196
|
let webhookResult = null
|
|
183
197
|
if (config?.diffWebhookUrl && typeof config.diffWebhookUrl === "string" && config.diffWebhookUrl.trim()) {
|
|
198
|
+
let secret = config.diffWebhookSecret || ''
|
|
199
|
+
const secretRef = config.diffWebhookSecretRef || (typeof config.diffWebhookSecret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(config.diffWebhookSecret) ? config.diffWebhookSecret : '')
|
|
200
|
+
if (secretRef) {
|
|
201
|
+
try {
|
|
202
|
+
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secretRef) : ctx?.get?.('credentials')?.resolve?.(secretRef))
|
|
203
|
+
if (res) secret = typeof res === 'string' ? res : res?.value || secret
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (typeof ctx?.logger?.debug === 'function') {
|
|
206
|
+
ctx.logger.debug('[dsh-model-sync] webhook secret resolution fallback:', err)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
184
210
|
webhookResult = await sendWebhookNotification(
|
|
185
211
|
config.diffWebhookUrl,
|
|
186
|
-
|
|
212
|
+
secret,
|
|
187
213
|
{
|
|
188
214
|
event: "model-sync.diff",
|
|
189
215
|
...events,
|
package/lib/http.js
CHANGED
|
@@ -2,8 +2,31 @@ import { validateAliasName } from './alias-resolver.js'
|
|
|
2
2
|
function normalizeWebhookTestRequest(body) {
|
|
3
3
|
if (!body || typeof body !== "object") throw Object.assign(new Error("body is required"), { code: "INVALID_REQUEST" })
|
|
4
4
|
if (typeof body.url !== "string" || !body.url.trim()) throw Object.assign(new Error("url is required"), { code: "INVALID_REQUEST" })
|
|
5
|
+
let parsed
|
|
6
|
+
try {
|
|
7
|
+
parsed = new URL(body.url.trim())
|
|
8
|
+
} catch (err) {
|
|
9
|
+
throw Object.assign(new Error("Invalid URL: " + (err?.message || String(err))), { code: "INVALID_REQUEST" })
|
|
10
|
+
}
|
|
11
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
12
|
+
throw Object.assign(new Error("URL must use http or https protocol"), { code: "INVALID_REQUEST" })
|
|
13
|
+
}
|
|
14
|
+
const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
15
|
+
const isLoopback = hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1' || hostname.startsWith('127.') || hostname.startsWith('::ffff:127.')
|
|
16
|
+
if (parsed.protocol === "http:" && !isLoopback) {
|
|
17
|
+
throw Object.assign(new Error("insecure http webhook url is only permitted on loopback"), { code: "INVALID_REQUEST" })
|
|
18
|
+
}
|
|
19
|
+
if (
|
|
20
|
+
hostname === '169.254.169.254' ||
|
|
21
|
+
hostname.startsWith('169.254.') ||
|
|
22
|
+
hostname === 'metadata.google.internal' ||
|
|
23
|
+
hostname === '0.0.0.0' ||
|
|
24
|
+
hostname === '255.255.255.255'
|
|
25
|
+
) {
|
|
26
|
+
throw Object.assign(new Error("webhook url targets a forbidden metadata or link-local address"), { code: "INVALID_REQUEST" })
|
|
27
|
+
}
|
|
5
28
|
return {
|
|
6
|
-
url:
|
|
29
|
+
url: parsed.toString(),
|
|
7
30
|
secret: typeof body.secret === "string" ? body.secret.trim() : "",
|
|
8
31
|
}
|
|
9
32
|
}
|
|
@@ -28,7 +51,9 @@ export function isLoopbackAddress(ip) {
|
|
|
28
51
|
export function trusted(req, { requireLoopback = false } = {}) {
|
|
29
52
|
const remoteAddress = req?.socket?.remoteAddress
|
|
30
53
|
if (requireLoopback || req?.method === 'POST') {
|
|
31
|
-
if (!isLoopbackAddress(remoteAddress)) return false
|
|
54
|
+
if (remoteAddress && !isLoopbackAddress(remoteAddress)) return false
|
|
55
|
+
if (!remoteAddress && req?.socket) return false
|
|
56
|
+
if (!remoteAddress && !isLoopbackAddress(req?.headers?.host?.split(':')[0])) return false
|
|
32
57
|
}
|
|
33
58
|
const fetchSite = req.headers?.['sec-fetch-site']
|
|
34
59
|
if (fetchSite === 'cross-site') return false
|
|
@@ -59,8 +84,10 @@ export function trusted(req, { requireLoopback = false } = {}) {
|
|
|
59
84
|
if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
|
|
60
85
|
// If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
|
|
61
86
|
if (req.headers?.['sec-fetch-mode'] && !host) return false
|
|
62
|
-
// For
|
|
63
|
-
if (req?.method === 'POST' && !origin && !referer
|
|
87
|
+
// For requests requiring loopback or POST without any browser origin/referer headers, require explicit loopback
|
|
88
|
+
if ((requireLoopback || req?.method === 'POST') && !origin && !referer) {
|
|
89
|
+
if (!isLoopbackAddress(remoteAddress ?? (req?.headers?.host ? req.headers.host.split(':')[0] : ''))) return false
|
|
90
|
+
}
|
|
64
91
|
return true
|
|
65
92
|
}
|
|
66
93
|
|
|
@@ -247,7 +274,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
|
|
|
247
274
|
return []
|
|
248
275
|
}
|
|
249
276
|
const makeHandler = (route) => async (req, res) => {
|
|
250
|
-
|
|
277
|
+
const isPublicStatus = route === 'status' && req.method === 'GET'
|
|
278
|
+
if (!trusted(req, { requireLoopback: !isPublicStatus })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
|
|
251
279
|
if (route === 'status') {
|
|
252
280
|
if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
|
|
253
281
|
const payload = { ...synchronizer.status(), scheduler: getSchedulerStatus(), providers: synchronizer.listProviders() }
|
package/lib/index.js
CHANGED
|
@@ -27,7 +27,8 @@ export const Config = Schema.object({
|
|
|
27
27
|
historyLimit: Schema.number().default(50).volatile(),
|
|
28
28
|
notificationLimit: Schema.number().default(50).volatile(),
|
|
29
29
|
diffWebhookUrl: Schema.string().default().volatile(),
|
|
30
|
-
|
|
30
|
+
diffWebhookSecretRef: Schema.string().role('credential-ref').default('').volatile(),
|
|
31
|
+
diffWebhookSecret: Schema.string().role('credential-ref').default('').volatile(),
|
|
31
32
|
notifyOnModelAdded: Schema.boolean().default(true).volatile(),
|
|
32
33
|
notifyOnModelDeprecated: Schema.boolean().default(true).volatile(),
|
|
33
34
|
notifyOnPriceChanged: Schema.boolean().default(true).volatile(),
|
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,
|
|
@@ -554,7 +562,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
554
562
|
notifications: notificationLedger,
|
|
555
563
|
updateNotification: updateNotificationState,
|
|
556
564
|
rollbackHistory,
|
|
557
|
-
testWebhook: async ({ url, secret }) =>
|
|
565
|
+
testWebhook: async ({ url, secret }) => {
|
|
566
|
+
let resolvedSecret = secret || ''
|
|
567
|
+
if (typeof secret === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(secret)) {
|
|
568
|
+
try {
|
|
569
|
+
const res = await (ctx?.credentials?.resolve ? ctx.credentials.resolve(secret) : ctx?.get?.('credentials')?.resolve?.(secret))
|
|
570
|
+
if (res) resolvedSecret = typeof res === 'string' ? res : res?.value || resolvedSecret
|
|
571
|
+
} catch (err) {
|
|
572
|
+
ctx?.logger?.debug?.('[dsh-model-sync] secret ref resolution fallback:', err)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return sendWebhookNotification(url, resolvedSecret, { event: 'model-sync.test', timestamp: Date.now(), test: true })
|
|
576
|
+
},
|
|
558
577
|
status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
|
|
559
578
|
})
|
|
560
579
|
}
|
package/lib/updater.js
CHANGED
|
@@ -237,7 +237,6 @@ export async function installExact(target, packageSpec, options = {}) {
|
|
|
237
237
|
'--profile',
|
|
238
238
|
target.profileName,
|
|
239
239
|
'add',
|
|
240
|
-
'--config.minimumReleaseAge=0',
|
|
241
240
|
packageSpec,
|
|
242
241
|
`--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
|
|
243
242
|
],
|