@goodandready/dsh-model-sync 0.5.6 → 0.5.8

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.
@@ -1,8 +1,6 @@
1
1
  import { catalogRequestError } from './reliability.js'
2
- import { canUseGenericAdapter, createGenericAdapter } from './generic-adapter.js'
2
+ import { canUseGenericAdapter, createGenericAdapter, readBoundedText, MAX_RESPONSE_BYTES } from './generic-adapter.js'
3
3
  import { normalizeModels, parseClinePassModels } from './models.js'
4
-
5
- const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
6
4
  const SAFE_AUTH = new Set(['bearer', 'x-api-key', 'query-key', 'none'])
7
5
  const SAFE_PARSERS = new Set(['openai', 'google', 'clinebot-plan'])
8
6
  const CAPABILITIES = new Set(['vision', 'tools', 'reasoning', 'embeddings', 'code'])
@@ -214,17 +212,14 @@ async function fetchSpecific(profile, descriptor, options) {
214
212
  }
215
213
  const respEtag = response.headers?.get?.('etag') || undefined
216
214
  const respLastMod = response.headers?.get?.('last-modified') || undefined
217
- const declared = Number(response.headers?.get?.('content-length') ?? NaN)
218
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
219
- const error = new Error('model catalog response is too large')
220
- error.adapterCode = 'schema'
221
- throw error
222
- }
223
- const text = await response.text()
224
- if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
225
- const error = new Error('model catalog response is too large')
226
- error.adapterCode = 'schema'
227
- throw error
215
+ let text
216
+ try {
217
+ text = await readBoundedText(response, MAX_RESPONSE_BYTES)
218
+ } catch (err) {
219
+ if (err.message === 'model catalog response is too large') {
220
+ err.adapterCode = 'schema'
221
+ }
222
+ throw err
228
223
  }
229
224
  try { payload = JSON.parse(text) } catch {
230
225
  const error = new Error('model catalog response is not valid JSON')
@@ -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) {
@@ -12,7 +15,7 @@ export function validateAliasName(name) {
12
15
  return /^@?[a-zA-Z0-9_\-.]{1,64}$/.test(clean)
13
16
  }
14
17
 
15
- function modelScoreForCode(provider, model) {
18
+ function modelScoreForCode(model) {
16
19
  let score = 0
17
20
  const id = String(model?.id ?? '').toLowerCase()
18
21
  const name = String(model?.name ?? '').toLowerCase()
@@ -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
 
@@ -194,7 +208,7 @@ export function resolveAlias(aliasName, {
194
208
  if (strategy === 'best-code') {
195
209
  const scored = available.map((item) => ({
196
210
  ...item,
197
- score: modelScoreForCode(item.provider, item.model),
211
+ score: modelScoreForCode(item.model),
198
212
  }))
199
213
  scored.sort((a, b) => b.score - a.score)
200
214
  const best = scored[0]
package/lib/client.js CHANGED
@@ -25,23 +25,23 @@ window.__ModuleLoader__.load({
25
25
  '.dms-card-body{border-top:1px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px;display:flex;flex-direction:column;gap:10px;box-sizing:border-box}' +
26
26
  '.dms-wrap{display:flex;flex-direction:column;gap:14px;max-width:760px;box-sizing:border-box;color:var(--dsw-alias-label-primary,inherit)}' +
27
27
  '.dms-wrap>p{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.55}' +
28
- '.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
28
+ '.dms-toolbar,.dms-section{display:flex;flex-direction:row;align-items:center;flex-wrap:wrap;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box}' +
29
29
  '.dms-toolbar>select{flex:1 1 190px;min-width:190px}' +
30
30
  '.dms-toolbar>.dms-actions{flex-basis:100%}' +
31
31
  '.dms-toolbar>label{display:inline-flex;align-items:flex-start;gap:7px;font-size:12px;line-height:1.35;color:var(--dsw-alias-label-secondary)}' +
32
32
  '.dms-toolbar>select,.dms-editor input,.dms-editor textarea,.dms-input{box-sizing:border-box;min-width:0;background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-2,transparent));border:1px solid var(--dsw-alias-border-l1,var(--dsw-alias-border-l2));border-radius:6px;color:var(--dsw-alias-label-primary);font:inherit;padding:7px 9px}' +
33
33
  '.dms-toolbar>label input{accent-color:var(--dsw-alias-brand-primary,var(--dsw-alias-button-info-fill))}' +
34
34
  '.dms-actions{display:flex;align-items:center;flex-wrap:wrap;gap:7px}' +
35
- '.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
36
- '.dms-btn{margin:0!important;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.35;padding:6px 10px;cursor:pointer;white-space:normal}' +
35
+ '.dms-wrap>.dms-actions{padding:10px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent)}' +
36
+ '.dms-btn{margin:0!important;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);font:inherit;font-size:12px;line-height:1.35;padding:6px 10px;cursor:pointer;white-space:normal}' +
37
37
  '.dms-btn:hover:not(:disabled){border-color:var(--dsw-alias-brand-primary);background:var(--dsw-alias-bg-layer-2)}' +
38
38
  '.dms-btn:disabled{opacity:.45;cursor:default}' +
39
39
  '.dms-section>strong{display:block;font-size:13px;font-weight:650}' +
40
40
  '.dms-provider-wrap{margin:0 0 7px}' +
41
- '.dms-provider{display:grid!important;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;padding:10px 11px;margin:0;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent)}' +
41
+ '.dms-provider{display:grid!important;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;padding:10px 11px;margin:0;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-2,transparent)}' +
42
42
  '.dms-provider>span{min-width:0;overflow-wrap:anywhere;line-height:1.4}' +
43
43
  '.dms-section>div[style]{padding:8px 0;border-top:1px solid var(--dsw-alias-border-l2)}' +
44
- '.dms-editor{display:flex;flex-direction:column;align-items:stretch;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2,var(--dsw-alias-border-primary));border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box;border-color:var(--dsw-alias-brand-primary,var(--dsw-alias-border-l2))}' +
44
+ '.dms-editor{display:flex;flex-direction:column;align-items:stretch;gap:10px;padding:13px 14px;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-1,transparent);box-sizing:border-box;border-color:var(--dsw-alias-brand-primary,var(--dsw-alias-border-l2))}' +
45
45
  '.dms-editor strong{font-size:13px}' +
46
46
  '.dms-editor label{display:block;gap:5px;margin-top:8px;color:var(--dsw-alias-label-secondary);font-size:12px}' +
47
47
  '.dms-editor label input,.dms-editor label textarea{width:100%}' +
@@ -53,10 +53,10 @@ window.__ModuleLoader__.load({
53
53
  '.dms-grid-2{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px}' +
54
54
  '.dms-badge{font-size:11px;padding:2px 8px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2);display:inline-flex;align-items:center;gap:4px;font-weight:500}' +
55
55
  '.dms-badge-ok{border-color:var(--dsw-alias-state-success-primary);color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary,var(--dsw-alias-bg-layer-2))}' +
56
- '.dms-badge-warn{border-color:var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);background:var(--dsw-alias-state-warning-secondary,var(--dsw-alias-bg-layer-2))}' +
56
+ '.dms-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:var(--dsw-alias-state-warn-secondary,var(--dsw-alias-bg-layer-2))}' +
57
57
  '.dms-badge-bad{border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary);background:var(--dsw-alias-state-error-secondary,var(--dsw-alias-bg-layer-2))}' +
58
58
  '.dms-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 10px;font-size:13px;box-sizing:border-box}' +
59
- '.dms-input:focus{outline:none;border-color:var(--dsw-alias-state-brand-primary)}' +
59
+ '.dms-input:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}' +
60
60
  '.dms-btn-primary{background:var(--dsw-alias-label-primary)!important;color:var(--dsw-alias-bg-layer-3)!important;border-color:transparent!important;font-weight:600}' +
61
61
  '.dms-btn-primary:hover:not(:disabled){opacity:0.88}' +
62
62
  '.dms-btn-danger{color:var(--dsw-alias-state-error-primary)!important;border-color:var(--dsw-alias-state-error-primary)!important}' +
@@ -517,7 +517,7 @@ window.__ModuleLoader__.load({
517
517
  }, updaterChecking ? t.checkingUpdates : (updaterStatus ? t.upToDate : t.checkForUpdates))
518
518
  )
519
519
  ),
520
- updaterNotice ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-success)', marginTop: 8, fontSize: 12 } }, updaterNotice) : null,
520
+ updaterNotice ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-success-primary)', marginTop: 8, fontSize: 12 } }, updaterNotice) : null,
521
521
  updaterError ? React.createElement('div', { style: { color: 'var(--dsw-alias-state-error-primary)', marginTop: 8, fontSize: 12 } }, updaterError) : null
522
522
  )
523
523
  }
@@ -1117,7 +1117,7 @@ window.__ModuleLoader__.load({
1117
1117
  return React.createElement('div', { key: p.name, style: { display: 'flex', flexDirection: 'column', gap: 4, padding: '8px 10px', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: 6, background: 'var(--dsw-alias-bg-layer-2)' } },
1118
1118
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
1119
1119
  React.createElement('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } },
1120
- React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13, color: 'var(--dsw-alias-state-brand-primary)' } }, p.name),
1120
+ React.createElement('strong', { style: { fontFamily: 'monospace', fontSize: 13, color: 'var(--dsw-alias-brand-primary)' } }, p.name),
1121
1121
  React.createElement('span', { style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, p.label)
1122
1122
  ),
1123
1123
  button(isResolving ? t.resolvingAlias : t.resolveAlias, () => resolveSmartAlias(p.name), 'secondary')
@@ -1318,8 +1318,8 @@ window.__ModuleLoader__.load({
1318
1318
  React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }),
1319
1319
  ` ${model.name ?? model.id} (${model.id})`,
1320
1320
  isNewModel(model, row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null,
1321
- deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null,
1322
- (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, '★') : null,
1321
+ deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warn-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null,
1322
+ (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warn-primary)', color: 'white' } }, '★') : null,
1323
1323
  (model.pricing && minPrice !== Infinity && model.pricing.inputPerToken === minPrice) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null,
1324
1324
  model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null,
1325
1325
  model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken * 1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken !== undefined ? ` / cache $${(model.pricing.cacheReadPerToken * 1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken !== undefined ? ` / out $${(model.pricing.outputPerToken * 1000000).toFixed(2)}` : '')) : null,
@@ -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({
@@ -1,7 +1,7 @@
1
1
  import { catalogRequestError } from './reliability.js'
2
2
  import { normalizeModels } from './models.js'
3
3
 
4
- const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
4
+ export const MAX_RESPONSE_BYTES = 4 * 1024 * 1024
5
5
  const SUPPORTED_APIS = new Set(['openai-completions', 'openai-responses'])
6
6
 
7
7
  function endpointFor(baseURL) {
@@ -10,16 +10,68 @@ function endpointFor(baseURL) {
10
10
  return base.endsWith('/models') ? base : `${base}/models`
11
11
  }
12
12
 
13
- async function readBody(response) {
13
+ export async function readBoundedText(response, maxBytes = MAX_RESPONSE_BYTES) {
14
14
  const declared = Number(response.headers?.get?.('content-length') ?? NaN)
15
- if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
15
+ if (Number.isFinite(declared) && declared > maxBytes) {
16
16
  throw new Error('model catalog response is too large')
17
17
  }
18
- const text = await response.text()
19
- if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
20
- throw new Error('model catalog response is too large')
18
+
19
+ if (response.body && typeof response.body[Symbol.asyncIterator] === 'function') {
20
+ const chunks = []
21
+ let totalBytes = 0
22
+ for await (const chunk of response.body) {
23
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
24
+ totalBytes += buf.byteLength
25
+ if (totalBytes > maxBytes) {
26
+ if (typeof response.body.cancel === 'function') {
27
+ try { await response.body.cancel('response too large') } catch (_err) { /* best-effort */ }
28
+ } else if (typeof response.body.destroy === 'function') {
29
+ try { response.body.destroy() } catch (_err) { /* best-effort */ }
30
+ }
31
+ throw new Error('model catalog response is too large')
32
+ }
33
+ chunks.push(buf)
34
+ }
35
+ return Buffer.concat(chunks).toString('utf8')
21
36
  }
22
- return text
37
+
38
+ if (response.body && typeof response.body.getReader === 'function') {
39
+ const reader = response.body.getReader()
40
+ const chunks = []
41
+ let totalBytes = 0
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read()
45
+ if (done) break
46
+ if (value) {
47
+ const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)
48
+ totalBytes += buf.byteLength
49
+ if (totalBytes > maxBytes) {
50
+ try { await reader.cancel('response too large') } catch (_err) { /* best-effort */ }
51
+ throw new Error('model catalog response is too large')
52
+ }
53
+ chunks.push(buf)
54
+ }
55
+ }
56
+ } finally {
57
+ try { reader.releaseLock?.() } catch (_err) { /* best-effort */ }
58
+ }
59
+ return Buffer.concat(chunks).toString('utf8')
60
+ }
61
+
62
+ if (typeof response.text === 'function') {
63
+ const text = await response.text()
64
+ if (Buffer.byteLength(text, 'utf8') > maxBytes) {
65
+ throw new Error('model catalog response is too large')
66
+ }
67
+ return text
68
+ }
69
+
70
+ return ''
71
+ }
72
+
73
+ async function readBody(response) {
74
+ return readBoundedText(response, MAX_RESPONSE_BYTES)
23
75
  }
24
76
 
25
77
  function rowsFrom(payload) {
package/lib/http.js CHANGED
@@ -1,4 +1,7 @@
1
+ import crypto from 'node:crypto'
1
2
  import { validateAliasName } from './alias-resolver.js'
3
+ import { validatePolicy } from './policy.js'
4
+
2
5
  function normalizeWebhookTestRequest(body) {
3
6
  if (!body || typeof body !== "object") throw Object.assign(new Error("body is required"), { code: "INVALID_REQUEST" })
4
7
  if (typeof body.url !== "string" || !body.url.trim()) throw Object.assign(new Error("url is required"), { code: "INVALID_REQUEST" })
@@ -30,8 +33,6 @@ function normalizeWebhookTestRequest(body) {
30
33
  secret: typeof body.secret === "string" ? body.secret.trim() : "",
31
34
  }
32
35
  }
33
- import crypto from 'node:crypto'
34
- import { validatePolicy } from './policy.js'
35
36
 
36
37
  const BASE = '/dsh-model-sync'
37
38
 
@@ -82,8 +83,6 @@ export function trusted(req, { requireLoopback = false } = {}) {
82
83
  }
83
84
  // If sec-fetch-site is same-origin or same-site, allow
84
85
  if (fetchSite === 'same-origin' || fetchSite === 'same-site') return true
85
- // If sec-fetch-mode is present but origin/referer missing on a cross-site request, reject
86
- if (req.headers?.['sec-fetch-mode'] && !host) return false
87
86
  // For requests requiring loopback or POST without any browser origin/referer headers, require explicit loopback
88
87
  if ((requireLoopback || req?.method === 'POST') && !origin && !referer) {
89
88
  if (!isLoopbackAddress(remoteAddress ?? (req?.headers?.host ? req.headers.host.split(':')[0] : ''))) return false
package/lib/models.js CHANGED
@@ -146,11 +146,8 @@ function normalizeCapabilities(row) {
146
146
  if (!result.code && (/(^|[_-])(coder|code|starcoder|codellama)($|[_-])/i.test(idStr) || idStr.includes('coder') || idStr.includes('code-'))) {
147
147
  result.code = true
148
148
  }
149
- if (!result.vision && (/(^|[_-])(vision|vl|multimodal|4v|4o|flash)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision'))) {
150
- // Only set vision if clearly indicated by vision/vl tags
151
- if (/(^|[_-])(vision|vl)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision')) {
152
- result.vision = true
153
- }
149
+ if (!result.vision && (/(^|[_-])(vision|vl)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision'))) {
150
+ result.vision = true
154
151
  }
155
152
 
156
153
  return Object.keys(result).length > 0 ? result : undefined
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
@@ -110,13 +110,11 @@ export async function retryWithBackoff(operation, {
110
110
  onRetry,
111
111
  } = {}) {
112
112
  const total = Math.max(1, Math.floor(Number(attempts) || 1))
113
- let lastError
114
113
  for (let attempt = 1; attempt <= total; attempt += 1) {
115
114
  if (signal?.aborted) throw signal.reason ?? abortError()
116
115
  try {
117
116
  return await operation({ attempt })
118
117
  } catch (error) {
119
- lastError = error
120
118
  if (attempt >= total || !isRetryableError(error)) throw error
121
119
  const exponential = Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1))
122
120
  // Full jitter: randomized between [0.5, 1.0] of calculated exponential backoff
@@ -130,7 +128,6 @@ export async function retryWithBackoff(operation, {
130
128
  await delay(delayMs, signal)
131
129
  }
132
130
  }
133
- throw lastError
134
131
  }
135
132
 
136
133
  export async function mapWithConcurrency(items, worker, concurrency = 4) {
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
  }
@@ -54,7 +54,7 @@ export function createConfigTransfer({
54
54
  importedProviders.push(pname)
55
55
  }
56
56
  if (Array.isArray(pdata.selectedModels)) {
57
- currentSelections[pname] = pdata.selectedModels.filter((m) => typeof m === 'string')
57
+ currentSelections[pname] = [...new Set(pdata.selectedModels.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 1000)
58
58
  importedProviders.push(pname)
59
59
  }
60
60
  }
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -53,7 +53,10 @@
53
53
  "license": "MIT",
54
54
  "peerDependencies": {
55
55
  "@deepseek-ai/cordis": "^4.0.1",
56
- "@deepseek-ai/schemastery": "^3.18.4",
57
- "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1"
56
+ "@deepseek-ai/dsh-credentials": "^0.1.7-rc.2 || ^0.2.0-rc.1",
57
+ "@deepseek-ai/dsh-host-webserver": "^0.1.7-rc.2 || ^0.2.0-rc.1",
58
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2 || ^0.2.0-rc.1",
59
+ "@deepseek-ai/dsh-settings": "^0.1.7-rc.2 || ^0.2.0-rc.1",
60
+ "@deepseek-ai/schemastery": "^3.18.4"
58
61
  }
59
62
  }