@goodandready/dsh-model-sync 0.5.7 → 0.5.9

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'])
@@ -99,14 +97,32 @@ function mappedRows(provider, rows, descriptor) {
99
97
  }
100
98
 
101
99
  function parseRows(provider, payload, descriptor) {
100
+ if (payload && (payload.error || payload.errors)) {
101
+ const message = typeof payload.error === 'string'
102
+ ? payload.error
103
+ : payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
104
+ const err = new Error(`endpoint returned error payload: ${message}`)
105
+ err.adapterCode = 'schema'
106
+ throw err
107
+ }
102
108
  const parserKind = descriptor.parse || descriptor.parser
103
109
  if (parserKind === 'clinebot-plan') {
104
110
  const plan = payload?.data?.plan || payload?.data || payload?.plan || payload
105
111
  const included = plan?.features?.included || plan?.includedModels || []
112
+ if (!Array.isArray(included)) {
113
+ const err = new Error('model catalog response does not contain plan features')
114
+ err.adapterCode = 'schema'
115
+ throw err
116
+ }
106
117
  return parseClinePassModels(included)
107
118
  }
108
119
  if (parserKind === 'google') {
109
- const rows = Array.isArray(payload?.models) ? payload.models : []
120
+ const rows = payload?.models
121
+ if (!Array.isArray(rows)) {
122
+ const err = new Error('model catalog response does not contain models array')
123
+ err.adapterCode = 'schema'
124
+ throw err
125
+ }
110
126
  return normalizeModels(provider, rows.map((row) => ({
111
127
  ...row,
112
128
  id: String(row.name ?? '').replace(/^models\//, ''),
@@ -115,8 +131,13 @@ function parseRows(provider, payload, descriptor) {
115
131
  maxTokens: row.outputTokenLimit,
116
132
  })))
117
133
  }
118
- const rows = pathValue(payload, descriptor.modelsPath) ?? payload?.data ?? payload?.models ?? payload?.availableModels
119
- return normalizeModels(provider, Array.isArray(rows) ? mappedRows(provider, rows, descriptor) : [])
134
+ const rows = pathValue(payload, descriptor.modelsPath) ?? (Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels))
135
+ if (!Array.isArray(rows)) {
136
+ const err = new Error('model catalog response does not contain models array')
137
+ err.adapterCode = 'schema'
138
+ throw err
139
+ }
140
+ return normalizeModels(provider, mappedRows(provider, rows, descriptor))
120
141
  }
121
142
 
122
143
  function privateHttpHost(hostname) {
@@ -214,17 +235,14 @@ async function fetchSpecific(profile, descriptor, options) {
214
235
  }
215
236
  const respEtag = response.headers?.get?.('etag') || undefined
216
237
  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
238
+ let text
239
+ try {
240
+ text = await readBoundedText(response, MAX_RESPONSE_BYTES)
241
+ } catch (err) {
242
+ if (err.message === 'model catalog response is too large') {
243
+ err.adapterCode = 'schema'
244
+ }
245
+ throw err
228
246
  }
229
247
  try { payload = JSON.parse(text) } catch {
230
248
  const error = new Error('model catalog response is not valid JSON')
@@ -15,7 +15,7 @@ export function validateAliasName(name) {
15
15
  return /^@?[a-zA-Z0-9_\-.]{1,64}$/.test(clean)
16
16
  }
17
17
 
18
- function modelScoreForCode(provider, model) {
18
+ function modelScoreForCode(model) {
19
19
  let score = 0
20
20
  const id = String(model?.id ?? '').toLowerCase()
21
21
  const name = String(model?.name ?? '').toLowerCase()
@@ -208,7 +208,7 @@ export function resolveAlias(aliasName, {
208
208
  if (strategy === 'best-code') {
209
209
  const scored = available.map((item) => ({
210
210
  ...item,
211
- score: modelScoreForCode(item.provider, item.model),
211
+ score: modelScoreForCode(item.model),
212
212
  }))
213
213
  scored.sort((a, b) => b.score - a.score)
214
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,
@@ -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,21 +10,86 @@ 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')
36
+ }
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
21
68
  }
22
- return text
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) {
26
- const rows = payload?.data ?? payload?.models ?? payload?.availableModels
27
- return Array.isArray(rows) ? rows : []
78
+ if (payload && (payload.error || payload.errors)) {
79
+ const message = typeof payload.error === 'string'
80
+ ? payload.error
81
+ : payload.error?.message || (Array.isArray(payload.errors) ? payload.errors.map(e => e?.message || e).join(', ') : 'endpoint returned error payload')
82
+ const err = new Error(`endpoint returned error payload: ${message}`)
83
+ err.adapterCode = 'schema'
84
+ throw err
85
+ }
86
+ const rows = Array.isArray(payload) ? payload : (payload?.data ?? payload?.models ?? payload?.availableModels)
87
+ if (!Array.isArray(rows)) {
88
+ const err = new Error('model catalog response does not contain models array')
89
+ err.adapterCode = 'schema'
90
+ throw err
91
+ }
92
+ return rows
28
93
  }
29
94
 
30
95
  export function canUseGenericAdapter(profile) {
package/lib/history.js CHANGED
@@ -27,6 +27,11 @@ function cloneModel(provider, model) {
27
27
  if (normalized.pricing) compact.pricing = normalized.pricing
28
28
  if (normalized.contextWindow !== undefined) compact.contextWindow = normalized.contextWindow
29
29
  if (normalized.maxTokens !== undefined) compact.maxTokens = normalized.maxTokens
30
+ if (normalized.input !== undefined) compact.input = normalized.input
31
+ if (normalized.compat !== undefined) compact.compat = normalized.compat
32
+ if (normalized.tags !== undefined) compact.tags = normalized.tags
33
+ if (normalized.aliases !== undefined && normalized.aliases.length > 0) compact.aliases = normalized.aliases
34
+ if (normalized.lifecycle !== undefined) compact.lifecycle = normalized.lifecycle
30
35
  return compact
31
36
  }
32
37
 
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/index.js CHANGED
@@ -44,7 +44,7 @@ export const Config = Schema.object({
44
44
  title: Schema.string().required(),
45
45
  message: Schema.string().required(),
46
46
  providers: Schema.array(Schema.object({}).loose()).default([]),
47
- }).loose()).default([]),
47
+ }).loose()).default([]).volatile(),
48
48
  staleGraceRuns: Schema.number().default(2).volatile(),
49
49
  lifecycleRetentionRuns: Schema.number().default(20).volatile(),
50
50
  lifecycleRevision: Schema.number().default(0).volatile(),
@@ -54,14 +54,14 @@ export const Config = Schema.object({
54
54
  firstMissingAt: Schema.number().default(0),
55
55
  lastSeenAt: Schema.number().default(0),
56
56
  lastObservedRun: Schema.number().default(0),
57
- }).loose())).default({}),
57
+ }).loose())).default({}).volatile(),
58
58
  history: Schema.array(Schema.object({
59
59
  id: Schema.string().required(),
60
60
  version: Schema.number().required(),
61
61
  startedAt: Schema.number().required(),
62
62
  finishedAt: Schema.number().required(),
63
63
  providers: Schema.array(Schema.object({}).loose()).default([]),
64
- }).loose()).default([]),
64
+ }).loose()).default([]).volatile(),
65
65
  providers: Schema.array(Schema.object({
66
66
  provider: Schema.string().required(),
67
67
  enabled: Schema.boolean().default(true),
@@ -69,7 +69,7 @@ export const Config = Schema.object({
69
69
  jitterMinutes: Schema.number().default(0),
70
70
  ttlMinutes: Schema.number().default(0),
71
71
  autoApply: Schema.boolean().default(false),
72
- })).default([]),
72
+ })).default([]).volatile(),
73
73
  aliases: Schema.dict(Schema.object({
74
74
  provider: Schema.string(),
75
75
  model: Schema.string(),
@@ -79,7 +79,7 @@ export const Config = Schema.object({
79
79
  })),
80
80
  strategy: Schema.string(),
81
81
  updatedAt: Schema.number().default(0),
82
- }).loose()).default({}),
82
+ }).loose()).default({}).volatile(),
83
83
  modelSelections: Schema.dict(Schema.array(Schema.string())).default({}).volatile(),
84
84
  adapterRegistry: Schema.dict(Schema.object({
85
85
  enabled: Schema.boolean().default(true),
@@ -90,7 +90,7 @@ export const Config = Schema.object({
90
90
  fields: Schema.any().default({}),
91
91
  capabilityMap: Schema.any().default({}),
92
92
  headers: Schema.dict(Schema.string()).default({}),
93
- }).loose()).default({}),
93
+ }).loose()).default({}).volatile(),
94
94
  modelPolicies: Schema.dict(Schema.object({
95
95
  include: Schema.array(Schema.string()).default([]),
96
96
  exclude: Schema.array(Schema.string()).default([]),
@@ -100,7 +100,7 @@ export const Config = Schema.object({
100
100
  maxPricePerMillion: Schema.number(),
101
101
  enableContextFilter: Schema.boolean().default(false),
102
102
  minContextTokens: Schema.number(),
103
- }).loose()).default({}),
103
+ }).loose()).default({}).volatile(),
104
104
  // Full catalogs are cached separately from the allowlist applied to DSH.
105
105
  // This keeps the model picker useful after a restart without widening the
106
106
  // active llm-pi-ai catalog.
@@ -108,7 +108,7 @@ export const Config = Schema.object({
108
108
  provider: Schema.string().required(),
109
109
  id: Schema.string().required(),
110
110
  name: Schema.string().required(),
111
- }).loose())).default({}),
111
+ }).loose())).default({}).volatile(),
112
112
  })
113
113
 
114
114
  // DSH serves a namespace's settings form from the volatile fields of its profile
@@ -123,6 +123,7 @@ export function plainConfig(value) {
123
123
  }
124
124
 
125
125
  export function apply(ctx, config) {
126
+ let liveConfig = undefined
126
127
  const baseConfig = structuredClone(plainConfig(config || {}))
127
128
  const resolveConfig = (value) => {
128
129
  try {
@@ -134,7 +135,7 @@ export function apply(ctx, config) {
134
135
  return liveConfig ?? baseConfig
135
136
  }
136
137
  }
137
- let liveConfig = resolveConfig(baseConfig)
138
+ liveConfig = resolveConfig(baseConfig)
138
139
  let reconfigure = () => {}
139
140
  let saveConfigImpl = async (patch) => {
140
141
  const plainPatch = plainConfig(patch)
@@ -146,14 +147,16 @@ export function apply(ctx, config) {
146
147
  ctx.logger.debug('[dsh-model-sync] settings service read failed:', err)
147
148
  }
148
149
  }
149
- if (settings && typeof settings.update === 'function') {
150
- try {
151
- await settings.update(NS, plainPatch)
152
- } catch (err) {
153
- if (typeof ctx.logger?.warn === 'function') {
154
- ctx.logger.warn('[dsh-model-sync] settings.update failed:', err)
155
- }
150
+ if (!settings || typeof settings.update !== 'function') {
151
+ throw Object.assign(new Error('dsh-model-sync settings are not writable'), { code: 'CONFIG_UNAVAILABLE' })
152
+ }
153
+ try {
154
+ await settings.update(NS, plainPatch)
155
+ } catch (err) {
156
+ if (typeof ctx.logger?.warn === 'function') {
157
+ ctx.logger.warn('[dsh-model-sync] settings.update failed:', err)
156
158
  }
159
+ throw err
157
160
  }
158
161
  liveConfig = resolveConfig({ ...liveConfig, ...plainPatch })
159
162
  reconfigure()
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
@@ -276,6 +273,7 @@ function normalizePricing(row) {
276
273
  }
277
274
  if (typeof source.currency === 'string' && source.currency.trim()) pricing.currency = source.currency.trim()
278
275
  if (typeof source.unit === 'string' && source.unit.trim()) pricing.unit = source.unit.trim()
276
+ if (typeof source.source === 'string' && source.source.trim()) pricing.source = source.source.trim()
279
277
  return pricing
280
278
  }
281
279
 
@@ -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) {
@@ -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
  }
@@ -159,12 +159,18 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
159
159
  recordCredentialOutcome(row.provider, { status: 'ok', retries })
160
160
  if (result && result.notModified) {
161
161
  const config = getConfig() ?? {}
162
- const cached = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? row.models ?? [])
162
+ const existing = providerHttpMeta.get(row.provider)
163
+ if (existing && (result.etag || result.lastModified)) {
164
+ if (result.etag) existing.etag = result.etag
165
+ if (result.lastModified) existing.lastModified = result.lastModified
166
+ }
167
+ const memoryModels = existing?.models
168
+ const cached = normalizeModels(row.provider, memoryModels ?? config.modelCatalogs?.[row.provider] ?? row.models ?? [])
163
169
  return { provider: row.provider, status: 'ok', models: cached, retries, notModified: true }
164
170
  }
165
171
  const models = Array.isArray(result) ? result : []
166
172
  if (result?.__httpMeta) {
167
- providerHttpMeta.set(row.provider, result.__httpMeta)
173
+ providerHttpMeta.set(row.provider, { ...result.__httpMeta, models })
168
174
  }
169
175
  return { provider: row.provider, status: 'ok', models, retries }
170
176
  } catch (error) {
@@ -332,7 +338,10 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
332
338
  const advertised = selectedModels.length
333
339
  ? policyModels.filter((model) => selectedModels.includes(model.id))
334
340
  : policyModels
335
- const explicitRemoveIds = new Set(lifecycle.removedIds)
341
+ const policyExcludedIds = current
342
+ .filter((model) => !filterModels([model], policy).length || (selectedModels.length && !selectedModels.includes(model.id)))
343
+ .map((model) => model.id)
344
+ const explicitRemoveIds = new Set([...lifecycle.removedIds, ...policyExcludedIds])
336
345
  const reconciliation = reconcileModels(row.provider, current, advertised, { removeMissing: false, removeIds: explicitRemoveIds })
337
346
  results.push({
338
347
  ...discovered,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.7",
3
+ "version": "0.5.9",
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
  }