@goodandready/dsh-model-sync 0.5.10 → 0.5.12

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.
@@ -109,7 +109,12 @@ async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, si
109
109
  const apiKey = keyRef ? await resolveCredential(keyRef) : ''
110
110
  const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey, etag, lastModified), signal })
111
111
  if (response.status === 304) return response
112
- if (!response.ok) throw catalogRequestError(response.status, response)
112
+ if (!response.ok) {
113
+ if (typeof response.body?.cancel === "function") {
114
+ try { await response.body.cancel() } catch (_err) { /* best-effort cancel */ }
115
+ }
116
+ throw catalogRequestError(response.status, response)
117
+ }
113
118
  return response
114
119
  }
115
120
 
@@ -125,6 +130,9 @@ export async function discoverOpenAIModels(profile, {
125
130
  }
126
131
  const response = await resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal, etag, lastModified })
127
132
  if (response.status === 304) {
133
+ if (typeof response.body?.cancel === "function") {
134
+ try { await response.body.cancel() } catch (_err) { /* best-effort cancel */ }
135
+ }
128
136
  return { notModified: true, etag: response.headers?.get?.('etag') || etag, lastModified: response.headers?.get?.('last-modified') || lastModified }
129
137
  }
130
138
  const respEtag = response.headers?.get?.('etag') || undefined
package/lib/inventory.js CHANGED
@@ -66,17 +66,25 @@ export function listApiKeyProviders(llm, settings) {
66
66
  const seen = new Set()
67
67
  for (const entry of directory) {
68
68
  let profile = profiles[entry.provider]
69
+ let configured = Object.prototype.hasOwnProperty.call(profiles, entry.provider)
69
70
  if (!profile && (entry.provider === 'commandcode' || entry.settingsNs === 'llm-commandcode')) {
70
71
  const cc = sectionOf(settings, 'llm-commandcode')
71
- profile = {
72
- apiKeyEnv: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
73
- credentialRef: cc?.apiKeyEnv ?? 'COMMANDCODE_API_KEY',
74
- baseURL: cc?.apiBase ?? 'https://api.commandcode.ai',
75
- models: cc?.visibleModels ?? [],
72
+ if (cc && typeof cc === 'object' && Object.keys(cc).length > 0) {
73
+ const hasCredentials = Boolean(cc.apiKey || cc.apiKeyEnv || cc.credentialRef || cc.apiKeyRef)
74
+ const hasModels = Array.isArray(cc.visibleModels) && cc.visibleModels.length > 0
75
+ const hasBase = Boolean(cc.apiBase)
76
+ if (hasCredentials || hasModels || hasBase) {
77
+ configured = true
78
+ profile = {
79
+ apiKeyEnv: cc.apiKeyEnv ?? (hasCredentials ? 'COMMANDCODE_API_KEY' : ''),
80
+ credentialRef: cc.apiKeyEnv ?? cc.credentialRef ?? '',
81
+ baseURL: cc.apiBase ?? 'https://api.commandcode.ai',
82
+ models: cc.visibleModels ?? [],
83
+ }
84
+ }
76
85
  }
77
- } else {
78
- profile = profile ?? {}
79
86
  }
87
+ profile = profile ?? {}
80
88
  if (!isApiKeyProvider(entry, profile) || seen.has(entry.provider)) continue
81
89
  seen.add(entry.provider)
82
90
  out.push({
@@ -85,7 +93,7 @@ export function listApiKeyProviders(llm, settings) {
85
93
  settingsNs: entry.settingsNs,
86
94
  settingsPath: [...(entry.settingsPath ?? [])],
87
95
  declared: entry.declared === true,
88
- configured: Object.prototype.hasOwnProperty.call(profiles, entry.provider),
96
+ configured,
89
97
  live: live.has(entry.provider),
90
98
  apiKeyEnv: profile.apiKeyEnv ?? profile.credentialRef ?? profile.apiKeyRef ?? '',
91
99
  credentialRef: profile.credentialRef ?? profile.apiKeyRef ?? profile.apiKeyEnv ?? '',
package/lib/models.js CHANGED
@@ -140,13 +140,13 @@ function normalizeCapabilities(row) {
140
140
 
141
141
  // Model ID & name heuristic markers for modern models
142
142
  const idStr = String(row?.id ?? row?.slug ?? row?.model ?? '').toLowerCase()
143
- if (!result.reasoning && (/(^|[_-])(o1|o3|r1|qwq|deepseek-r1|thinking)($|[_-])/i.test(idStr) || idStr.includes('-r1') || idStr.includes('reasoning') || idStr.includes('thinking'))) {
143
+ if (result.reasoning === undefined && (/(^|[_-])(o1|o3|r1|qwq|deepseek-r1|thinking)($|[_-])/i.test(idStr) || idStr.includes('-r1') || idStr.includes('reasoning') || idStr.includes('thinking'))) {
144
144
  result.reasoning = true
145
145
  }
146
- if (!result.code && (/(^|[_-])(coder|code|starcoder|codellama)($|[_-])/i.test(idStr) || idStr.includes('coder') || idStr.includes('code-'))) {
146
+ if (result.code === undefined && (/(^|[_-])(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)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision'))) {
149
+ if (result.vision === undefined && (/(^|[_-])(vision|vl)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision'))) {
150
150
  result.vision = true
151
151
  }
152
152
 
@@ -277,6 +277,27 @@ function normalizePricing(row) {
277
277
  return pricing
278
278
  }
279
279
 
280
+ function normalizeDate(val) {
281
+ if (val === undefined || val === null || val === '') return undefined
282
+ if (typeof val === 'number' && Number.isFinite(val)) {
283
+ const ms = val < 1e11 ? val * 1000 : val
284
+ const d = new Date(ms)
285
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
286
+ }
287
+ if (typeof val === 'string' && val.trim()) {
288
+ const s = val.trim()
289
+ if (/^\d+$/.test(s)) {
290
+ const n = Number(s)
291
+ const ms = n < 1e11 ? n * 1000 : n
292
+ const d = new Date(ms)
293
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
294
+ }
295
+ const d = new Date(s)
296
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
297
+ }
298
+ return undefined
299
+ }
300
+
280
301
  export function normalizeModel(provider, row) {
281
302
  if (typeof row === 'string') {
282
303
  row = { id: row }
@@ -297,10 +318,14 @@ export function normalizeModel(provider, row) {
297
318
  const lifecycle = normalizeLifecycle(row)
298
319
  const input = normalizeInput(row)
299
320
  const compat = normalizeCompat(effectiveProvider, row, id)
321
+ const firstSeenAt = normalizeDate(row.firstSeenAt)
322
+ const createdAt = normalizeDate(row.createdAt ?? row.created)
300
323
  return {
301
324
  provider: effectiveProvider || provider,
302
325
  id,
303
326
  name,
327
+ ...(firstSeenAt === undefined ? {} : { firstSeenAt }),
328
+ ...(createdAt === undefined ? {} : { createdAt }),
304
329
  ...(contextWindow === undefined ? {} : { contextWindow }),
305
330
  ...(maxTokens === undefined ? {} : { maxTokens }),
306
331
  ...(row.description ? { description: String(row.description) } : {}),
package/lib/reconcile.js CHANGED
@@ -22,6 +22,8 @@ export function reconcileModels(provider, current, discovered, {
22
22
  const merged = {
23
23
  ...row,
24
24
  ...fresh,
25
+ ...(row.firstSeenAt || fresh.firstSeenAt ? { firstSeenAt: row.firstSeenAt ?? fresh.firstSeenAt } : {}),
26
+ ...(row.createdAt || fresh.createdAt ? { createdAt: row.createdAt ?? fresh.createdAt } : {}),
25
27
  ...(row.capabilities || fresh.capabilities ? {
26
28
  capabilities: { ...(fresh.capabilities ?? {}), ...(row.capabilities ?? {}) },
27
29
  } : {}),
@@ -1,5 +1,39 @@
1
1
  const RETRYABLE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504, 522, 524])
2
- const RETRYABLE_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT'])
2
+ const RETRYABLE_CODES = new Set([
3
+ 'ECONNRESET',
4
+ 'ECONNREFUSED',
5
+ 'ETIMEDOUT',
6
+ 'EAI_AGAIN',
7
+ 'UND_ERR_CONNECT_TIMEOUT',
8
+ 'UND_ERR_HEADERS_TIMEOUT',
9
+ 'UND_ERR_BODY_TIMEOUT',
10
+ 'UND_ERR_SOCKET',
11
+ 'UND_ERR_DESTROYED',
12
+ 'UND_ERR_CLOSED',
13
+ 'EHOSTUNREACH',
14
+ 'ENETUNREACH',
15
+ 'EPIPE',
16
+ ])
17
+
18
+ function isTimeout(err) {
19
+ if (!err) return false
20
+ if (err.name === 'TimeoutError') return true
21
+ if (
22
+ err.code === 'ETIMEDOUT' ||
23
+ err.code === 'UND_ERR_CONNECT_TIMEOUT' ||
24
+ err.code === 'UND_ERR_HEADERS_TIMEOUT' ||
25
+ err.code === 'UND_ERR_BODY_TIMEOUT'
26
+ ) return true
27
+ const msg = String(err.message || '').toLowerCase()
28
+ return msg.includes('timed out') || msg.includes('timeout')
29
+ }
30
+
31
+ function isAbort(err) {
32
+ if (!err) return false
33
+ if (err.name === 'AbortError') return true
34
+ const msg = String(err.message || '').toLowerCase()
35
+ return msg.includes('aborted') || msg.includes('the operation was aborted')
36
+ }
3
37
 
4
38
  export function statusOfError(error) {
5
39
  const direct = Number(error?.status ?? error?.statusCode)
@@ -10,10 +44,36 @@ export function statusOfError(error) {
10
44
 
11
45
  export function isRetryableError(error) {
12
46
  if (!error) return false
13
- if (error.name === 'AbortError' || error.name === 'TimeoutError') return true
14
- if (RETRYABLE_CODES.has(error.code)) return true
15
- const status = statusOfError(error)
16
- return status !== undefined && RETRYABLE_STATUSES.has(status)
47
+
48
+ let curr = error
49
+ let depth = 0
50
+ let hasTimeout = false
51
+ let hasAbort = false
52
+
53
+ while (curr && depth < 6) {
54
+ if (isTimeout(curr)) {
55
+ hasTimeout = true
56
+ break
57
+ }
58
+ if (isAbort(curr)) {
59
+ hasAbort = true
60
+ }
61
+ if (curr.code && RETRYABLE_CODES.has(curr.code)) {
62
+ return true
63
+ }
64
+ const status = statusOfError(curr)
65
+ if (status !== undefined) {
66
+ if (RETRYABLE_STATUSES.has(status)) return true
67
+ if (status >= 400 && status < 500) return false
68
+ }
69
+ curr = curr.cause
70
+ depth++
71
+ }
72
+
73
+ if (hasTimeout) return true
74
+ if (hasAbort) return false
75
+
76
+ return false
17
77
  }
18
78
 
19
79
  export function parseRetryAfter(response) {
@@ -46,7 +106,10 @@ export function catalogRequestError(status, response) {
46
106
  const error = new Error(`model catalog request failed with HTTP ${status}`)
47
107
  error.status = Number(status)
48
108
  const retryAfterMs = parseRetryAfter(response)
49
- if (retryAfterMs !== undefined) error.retryAfterMs = retryAfterMs
109
+ if (retryAfterMs !== undefined) {
110
+ error.retryAfterMs = retryAfterMs
111
+ error.retryAt = new Date(Date.now() + retryAfterMs).toISOString()
112
+ }
50
113
  return error
51
114
  }
52
115
 
@@ -105,6 +168,7 @@ export async function retryWithBackoff(operation, {
105
168
  attempts = 3,
106
169
  baseDelayMs = 250,
107
170
  maxDelayMs = 4000,
171
+ maxRetryAfterMs = 300000,
108
172
  jitter = true,
109
173
  signal,
110
174
  onRetry,
@@ -116,14 +180,22 @@ export async function retryWithBackoff(operation, {
116
180
  return await operation({ attempt })
117
181
  } catch (error) {
118
182
  if (attempt >= total || !isRetryableError(error)) throw error
119
- const exponential = Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1))
120
- // Full jitter: randomized between [0.5, 1.0] of calculated exponential backoff
121
- const jitterFactor = jitter ? (0.5 + Math.random() * 0.5) : 1
122
- const calculatedDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.round(exponential * jitterFactor))
123
183
  const retryAfterMs = Number(error?.retryAfterMs)
124
- const delayMs = (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
125
- ? Math.min(Math.max(0, Number(maxDelayMs) || 0), retryAfterMs)
126
- : calculatedDelayMs
184
+ let delayMs
185
+ if (Number.isFinite(retryAfterMs) && retryAfterMs > 0) {
186
+ if (retryAfterMs > maxRetryAfterMs) {
187
+ if (!error.retryAt) {
188
+ error.retryAt = new Date(Date.now() + retryAfterMs).toISOString()
189
+ }
190
+ throw error
191
+ }
192
+ delayMs = retryAfterMs
193
+ } else {
194
+ const exponential = Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1))
195
+ // Full jitter: randomized between [0.5, 1.0] of calculated exponential backoff
196
+ const jitterFactor = jitter ? (0.5 + Math.random() * 0.5) : 1
197
+ delayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.round(exponential * jitterFactor))
198
+ }
127
199
  onRetry?.({ attempt, nextAttempt: attempt + 1, delayMs, error })
128
200
  await delay(delayMs, signal)
129
201
  }
@@ -142,4 +214,4 @@ export async function mapWithConcurrency(items, worker, concurrency = 4) {
142
214
  }
143
215
  }))
144
216
  return values
145
- }
217
+ }
@@ -1,30 +1,94 @@
1
- export function createModelProber({ health, getProviderCatalog = () => [] } = {}) {
1
+ export function createModelProber({
2
+ health,
3
+ getProviderCatalog = () => [],
4
+ probeModel,
5
+ inferModel,
6
+ } = {}) {
2
7
  const modelProbes = new Map()
8
+ const probeFn = probeModel ?? inferModel
3
9
 
4
- async function tryModel({ provider, model } = {}) {
10
+ async function tryModel({ provider, model, signal } = {}) {
5
11
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
6
12
  if (typeof model !== 'string' || !model) throw new Error('model is required')
7
- const start = Date.now()
8
- const result = await health({ provider })
9
- const entry = result?.results?.find((r) => r.provider === provider)
10
- const providerOk = entry?.status === 'ok'
11
13
 
12
14
  // Verify model in provider catalog if catalog is available
13
15
  const catalog = typeof getProviderCatalog === 'function' ? getProviderCatalog(provider) : []
14
16
  const hasCatalog = Array.isArray(catalog) && catalog.length > 0
15
17
  const inCatalog = !hasCatalog || catalog.some((m) => (typeof m === 'string' ? m : m?.id) === model)
18
+ if (hasCatalog && !inCatalog) {
19
+ const probeResult = {
20
+ provider,
21
+ model,
22
+ ok: false,
23
+ latencyMs: 0,
24
+ status: 'model_not_found',
25
+ error: `model "${model}" not found in provider catalog`,
26
+ }
27
+ modelProbes.set(`${provider}:${model}`, {
28
+ latencyMs: 0,
29
+ ok: false,
30
+ status: 'model_not_found',
31
+ timestamp: Date.now(),
32
+ })
33
+ return probeResult
34
+ }
35
+
36
+ const start = Date.now()
37
+ if (typeof probeFn === 'function') {
38
+ try {
39
+ const probeRes = await probeFn({ provider, model, signal })
40
+ const latencyMs = Math.max(1, Date.now() - start)
41
+ const isOk = probeRes?.ok !== false
42
+ const status = isOk ? 'ok' : (probeRes?.status || 'error')
43
+ const errorMsg = isOk ? undefined : (probeRes?.error || 'inference probe failed')
44
+ const probeResult = {
45
+ provider,
46
+ model,
47
+ ok: isOk,
48
+ latencyMs,
49
+ status,
50
+ ...(errorMsg ? { error: errorMsg } : {}),
51
+ }
52
+ modelProbes.set(`${provider}:${model}`, {
53
+ latencyMs,
54
+ ok: isOk,
55
+ status,
56
+ timestamp: Date.now(),
57
+ })
58
+ return probeResult
59
+ } catch (err) {
60
+ const latencyMs = Math.max(1, Date.now() - start)
61
+ const status = err?.status ? `http_${err.status}` : (err?.code || 'error')
62
+ const probeResult = {
63
+ provider,
64
+ model,
65
+ ok: false,
66
+ latencyMs,
67
+ status,
68
+ error: err?.message || 'inference probe failed',
69
+ }
70
+ modelProbes.set(`${provider}:${model}`, {
71
+ latencyMs,
72
+ ok: false,
73
+ status,
74
+ timestamp: Date.now(),
75
+ })
76
+ return probeResult
77
+ }
78
+ }
16
79
 
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)
80
+ // Fallback if no probeFn provided: check provider health
81
+ const healthResult = await health?.({ provider, model, signal })
82
+ const entry = healthResult?.results?.find((r) => r.provider === provider)
83
+ const providerOk = entry?.status === 'ok'
84
+ const latencyMs = Math.max(1, Date.now() - start)
85
+ const status = !providerOk ? (entry?.status ?? 'error') : 'ok'
86
+ const errorMsg = !providerOk ? (entry?.message || entry?.status || 'probe failed') : undefined
23
87
 
24
88
  const probeResult = {
25
89
  provider,
26
90
  model,
27
- ok,
91
+ ok: providerOk,
28
92
  latencyMs,
29
93
  status,
30
94
  ...(errorMsg ? { error: errorMsg } : {}),
@@ -32,7 +96,7 @@ export function createModelProber({ health, getProviderCatalog = () => [] } = {}
32
96
 
33
97
  modelProbes.set(`${provider}:${model}`, {
34
98
  latencyMs,
35
- ok,
99
+ ok: providerOk,
36
100
  status,
37
101
  timestamp: Date.now(),
38
102
  })
@@ -40,58 +104,74 @@ export function createModelProber({ health, getProviderCatalog = () => [] } = {}
40
104
  return probeResult
41
105
  }
42
106
 
43
- async function batchTryModels({ provider, models } = {}) {
107
+ async function batchTryModels({ provider, models, signal, concurrency = 4 } = {}) {
44
108
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
45
109
  if (!Array.isArray(models)) throw new Error('models must be an array')
46
110
  const list = [...new Set(models.filter((m) => typeof m === 'string' && m.trim()).map((m) => m.trim()))].slice(0, 50)
47
111
 
48
- // Probe provider health ONCE for the entire batch rather than per-model
112
+ if (typeof probeFn === 'function') {
113
+ // Execute true model-specific probes with bounded concurrency
114
+ const limit = Math.max(1, Math.min(10, Math.floor(Number(concurrency) || 4)))
115
+ const results = new Array(list.length)
116
+ let cursor = 0
117
+
118
+ await Promise.all(Array.from({ length: Math.min(limit, list.length || 1) }, async () => {
119
+ while (cursor < list.length) {
120
+ if (signal?.aborted) break
121
+ const index = cursor++
122
+ results[index] = await tryModel({ provider, model: list[index], signal })
123
+ }
124
+ }))
125
+
126
+ const reachable = results.filter((r) => r?.ok).length
127
+ return {
128
+ provider,
129
+ results,
130
+ summary: { total: results.length, reachable, unreachable: results.length - reachable },
131
+ }
132
+ }
133
+
134
+ // If no probeFn provided, use provider health once (backwards compatibility)
49
135
  const start = Date.now()
50
- const healthResult = await health({ provider })
136
+ const healthResult = await health?.({ provider, signal })
51
137
  const entry = healthResult?.results?.find((r) => r.provider === provider)
52
138
  const providerOk = entry?.status === 'ok'
53
139
  const catalog = typeof getProviderCatalog === 'function' ? getProviderCatalog(provider) : []
54
140
  const hasCatalog = Array.isArray(catalog) && catalog.length > 0
55
- const baseLatency = Date.now() - start
56
-
57
- const results = []
58
- const concurrency = 5
59
- for (let i = 0; i < list.length; i += concurrency) {
60
- const chunk = list.slice(i, i + concurrency)
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 = {
70
- provider,
71
- model,
72
- ok,
73
- latencyMs: baseLatency,
74
- status,
75
- ...(errorMsg ? { error: errorMsg } : {}),
76
- }
141
+ const baseLatency = Math.max(1, Date.now() - start)
77
142
 
78
- modelProbes.set(`${provider}:${model}`, {
79
- latencyMs: baseLatency,
80
- ok,
81
- status,
82
- timestamp: Date.now(),
83
- })
143
+ const results = list.map((model) => {
144
+ const inCatalog = !hasCatalog || catalog.some((m) => (typeof m === 'string' ? m : m?.id) === model)
145
+ const ok = Boolean(providerOk && inCatalog)
146
+ const status = !providerOk ? (entry?.status ?? 'error') : (!inCatalog ? 'model_not_found' : 'ok')
147
+ const errorMsg = !providerOk
148
+ ? (entry?.message || entry?.status || 'probe failed')
149
+ : (!inCatalog ? `model "${model}" not found in provider catalog` : undefined)
150
+
151
+ const itemResult = {
152
+ provider,
153
+ model,
154
+ ok,
155
+ latencyMs: baseLatency,
156
+ status,
157
+ ...(errorMsg ? { error: errorMsg } : {}),
158
+ }
84
159
 
85
- return itemResult
160
+ modelProbes.set(`${provider}:${model}`, {
161
+ latencyMs: baseLatency,
162
+ ok,
163
+ status,
164
+ timestamp: Date.now(),
86
165
  })
87
- results.push(...chunkResults)
88
- }
166
+
167
+ return itemResult
168
+ })
89
169
 
90
170
  const reachable = results.filter((r) => r.ok).length
91
171
  return {
92
172
  provider,
93
173
  results,
94
- summary: { total: results.length, reachable, unreachable: results.length - reachable }
174
+ summary: { total: results.length, reachable, unreachable: results.length - reachable },
95
175
  }
96
176
  }
97
177
 
@@ -104,4 +184,4 @@ export function createModelProber({ health, getProviderCatalog = () => [] } = {}
104
184
  batchTryModels,
105
185
  getModelProbes,
106
186
  }
107
- }
187
+ }
@@ -31,7 +31,7 @@ import {
31
31
  import { createConfigTransfer } from './synchronizer-transfer.js'
32
32
  import { createModelProber } from './synchronizer-prober.js'
33
33
 
34
- export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {} } = {}) {
34
+ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfig = async () => {}, fetchImpl = fetch, adapterImplementations = {}, probeModel } = {}) {
35
35
  const resolveCredential = createCredentialResolver(ctx)
36
36
  const registry = createProviderAdapterRegistry({ resolveCredential, fetchImpl, adapters: adapterImplementations })
37
37
  let running = null
@@ -326,6 +326,19 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
326
326
  const current = profileOf(settings, row.provider).models ?? row.models
327
327
  const fullModels = normalizeModels(row.provider, discovered.models)
328
328
  const lifecycleBefore = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? current)
329
+ const existingMap = new Map(lifecycleBefore.map((m) => [m.id, m]))
330
+ const nowIso = new Date(startedAt).toISOString()
331
+ for (const model of fullModels) {
332
+ const prev = existingMap.get(model.id)
333
+ if (prev?.firstSeenAt) {
334
+ model.firstSeenAt = prev.firstSeenAt
335
+ } else if (!model.firstSeenAt) {
336
+ model.firstSeenAt = nowIso
337
+ }
338
+ if (prev?.createdAt && !model.createdAt) {
339
+ model.createdAt = prev.createdAt
340
+ }
341
+ }
329
342
  const lifecycle = updateLifecycle(config.modelLifecycle?.[row.provider], lifecycleBefore, fullModels, {
330
343
  runNumber: lifecycleRun,
331
344
  ...lifecycleConfig,
@@ -467,6 +480,35 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
467
480
  const row = providers().find((item) => item.provider === p)
468
481
  return row?.availableModels || row?.models || []
469
482
  },
483
+ probeModel: probeModel ?? (async ({ provider, model, signal }) => {
484
+ const llm = llmOf(ctx)
485
+ if (typeof llm?.stream === "function") {
486
+ const stream = await llm.stream({
487
+ provider,
488
+ model,
489
+ messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
490
+ }, { signal })
491
+ if (stream && typeof stream[Symbol.asyncIterator] === "function") {
492
+ for await (const _chunk of stream) {
493
+ break
494
+ }
495
+ }
496
+ return { ok: true }
497
+ }
498
+ const profile = profileOf(settingsOf(ctx), provider)
499
+ const adapter = selectAdapter(profile)
500
+ if (typeof adapter?.probeModel === "function") {
501
+ return adapter.probeModel(profile, model, { signal, fetchImpl })
502
+ }
503
+ const h = await health({ provider, model, signal })
504
+ const entry = h?.results?.find((r) => r.provider === provider)
505
+ if (entry?.status !== "ok") {
506
+ const err = new Error(entry?.message || "provider health failed: " + entry?.status)
507
+ if (entry?.status === "circuit-open") err.status = 503
508
+ throw err
509
+ }
510
+ return { ok: true }
511
+ }),
470
512
  })
471
513
 
472
514
  const rollbackHistory = (opts) => performHistoryRollback({ ctx, config: getConfig() ?? {}, providers: providers(), ...opts })
package/lib/updater.js CHANGED
@@ -200,7 +200,12 @@ export async function latestVersion(packageName, registry = 'https://registry.np
200
200
  const response = await fetch(`${registry.replace(/\/$/, '')}/${encodeURIComponent(packageName)}/latest`, {
201
201
  signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(8000) : undefined,
202
202
  })
203
- if (!response.ok) return undefined
203
+ if (!response.ok) {
204
+ if (typeof response.body?.cancel === "function") {
205
+ try { await response.body.cancel() } catch (_err) { /* best-effort cancel */ }
206
+ }
207
+ return undefined
208
+ }
204
209
  const value = await response.json()
205
210
  if (typeof value?.version !== 'string' || value.version === '') return undefined
206
211
  latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",