@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.
@@ -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)
@@ -1,9 +1,12 @@
1
1
  export const VIRTUAL_STRATEGIES = Object.freeze(['fastest', 'cheapest', 'best-code'])
2
2
 
3
- export function isVirtualAlias(name) {
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 clean === '@fast' || clean === '@cheap' || clean === '@best-code' || clean.startsWith('@')
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
- ...item,
140
- latencyMs: estimateLatency(item.provider, item.model, modelProbes),
141
- }))
142
- scored.sort((a, b) => a.latencyMs - b.latencyMs)
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
 
@@ -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?.outputPerToken && bp?.inputPerToken !== ap?.inputPerToken
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
- config.diffWebhookSecret,
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: body.url.trim(),
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 POST write requests without any browser origin/referer headers, require explicit loopback remoteAddress
63
- if (req?.method === 'POST' && !origin && !referer && !isLoopbackAddress(remoteAddress)) return false
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
- if (!trusted(req, { requireLoopback: req.method === 'POST' })) return json(res, 403, { error: { code: 'forbidden', message: 'dsh-model-sync: cross-origin or non-loopback request rejected' } })
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
- diffWebhookSecret: Schema.string().default().volatile(),
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
- || Object.keys(normalized.requireCapabilities).length > 0
59
- || Object.keys(normalized.denyCapabilities).length > 0
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] !== expected) return false
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] === denied) return false
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
@@ -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
- for (const [key, val] of Object.entries(table)) {
109
- if (rawId.startsWith(key) || cleaned.startsWith(key)) return { ...val }
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 all tables if cleaned matches well-known key
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) + 1,
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
- return {
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: entry?.status === 'ok',
12
- latencyMs: Date.now() - start,
13
- status: entry?.status ?? 'unknown',
14
- ...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
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 = 3
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 = await Promise.all(
27
- chunk.map((model) => tryModel({ provider, model }).catch((err) => ({
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: false,
31
- latencyMs: 0,
32
- status: 'error',
33
- error: err instanceof Error ? err.message : String(err)
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 {
@@ -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({ health })
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 }) => sendWebhookNotification(url, secret, { event: 'model-sync.test', timestamp: Date.now(), test: true }),
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
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",