@goodandready/dsh-model-sync 0.5.10 → 0.5.11

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.
@@ -219,6 +219,9 @@ async function requestSpecific(profile, descriptor, { resolveCredential, fetchIm
219
219
  }
220
220
  if (response.status === 304) return response
221
221
  if (!response.ok) {
222
+ if (typeof response.body?.cancel === "function") {
223
+ try { await response.body.cancel() } catch (_err) { /* best-effort cancel */ }
224
+ }
222
225
  const error = catalogRequestError(response.status, response)
223
226
  error.adapterCode = [401, 403].includes(response.status) ? 'auth' : response.status === 429 ? 'rate-limit' : 'http'
224
227
  throw error
@@ -231,6 +234,9 @@ async function fetchSpecific(profile, descriptor, options) {
231
234
  try {
232
235
  const response = await requestSpecific(profile, descriptor, options)
233
236
  if (response.status === 304) {
237
+ if (typeof response.body?.cancel === "function") {
238
+ try { await response.body.cancel() } catch (_err) { /* best-effort cancel */ }
239
+ }
234
240
  return { notModified: true, etag: response.headers?.get?.('etag') || options.etag, lastModified: response.headers?.get?.('last-modified') || options.lastModified }
235
241
  }
236
242
  const respEtag = response.headers?.get?.('etag') || undefined
@@ -53,6 +53,16 @@ function estimateLatency(provider, model, probes = {}) {
53
53
  return Math.round(base)
54
54
  }
55
55
 
56
+ function isProviderEligible(provider, { healthResults = [], isCircuitOpen = () => false } = {}) {
57
+ if (isCircuitOpen(provider)) return false
58
+ const health = healthResults.find((h) => h.provider === provider)
59
+ if (health) {
60
+ if (health.ok === false) return false
61
+ if (health.status && health.status !== "ok") return false
62
+ }
63
+ return true
64
+ }
65
+
56
66
  export function resolveAlias(aliasName, {
57
67
  aliases = {},
58
68
  modelCatalogs = {},
@@ -72,7 +82,8 @@ export function resolveAlias(aliasName, {
72
82
  for (let i = 0; i < custom.targets.length; i++) {
73
83
  const target = custom.targets[i]
74
84
  const circuitBlocked = isCircuitOpen(target.provider)
75
- const healthFail = healthResults.find((h) => h.provider === target.provider && h.status === 'error')
85
+ const healthEntry = healthResults.find((h) => h.provider === target.provider)
86
+ const healthFail = Boolean(healthEntry && (healthEntry.status === "error" || healthEntry.status === "circuit-open" || healthEntry.ok === false || (healthEntry.status && healthEntry.status !== "ok")))
76
87
  if (circuitBlocked || healthFail) {
77
88
  skipped.push({ ...target, reason: circuitBlocked ? 'circuit_open' : 'health_failed' })
78
89
  continue
@@ -101,12 +112,15 @@ export function resolveAlias(aliasName, {
101
112
  // 2. Static explicit alias
102
113
  if (custom?.provider && custom?.model && !custom.strategy) {
103
114
  const circuitBlocked = isCircuitOpen(custom.provider)
115
+ const healthEntry = healthResults.find((h) => h.provider === custom.provider)
116
+ const healthFailed = Boolean(healthEntry && (healthEntry.status === "error" || healthEntry.status === "circuit-open" || healthEntry.ok === false || (healthEntry.status && healthEntry.status !== "ok")))
104
117
  return {
105
118
  alias: clean,
106
119
  provider: custom.provider,
107
120
  model: custom.model,
108
121
  fallbackUsed: false,
109
122
  ...(circuitBlocked ? { warning: 'circuit_open', circuitOpen: true } : {}),
123
+ ...(healthFailed ? { warning: 'health_failed', healthFailed: true } : {}),
110
124
  }
111
125
  }
112
126
 
@@ -125,7 +139,7 @@ export function resolveAlias(aliasName, {
125
139
  }
126
140
  const available = []
127
141
  for (const [provider, models] of Object.entries(modelCatalogs)) {
128
- if (isCircuitOpen(provider)) continue
142
+ if (!isProviderEligible(provider, { healthResults, isCircuitOpen })) continue
129
143
  if (!Array.isArray(models)) continue
130
144
  for (const m of models) {
131
145
  if (!m?.id) continue
@@ -177,18 +177,29 @@ export async function sendWebhookNotification(url, secret, payload, { fetchImpl
177
177
  const controller = typeof AbortController !== "undefined" ? new AbortController() : null
178
178
  const timer = controller ? setTimeout(() => controller.abort(new Error("Webhook request timed out")), timeoutMs) : null
179
179
 
180
+ let res = null
180
181
  try {
181
- const res = await fetchImpl(parsedUrl.toString(), {
182
+ res = await fetchImpl(parsedUrl.toString(), {
182
183
  method: "POST",
183
184
  headers,
184
185
  body,
185
186
  ...(controller ? { signal: controller.signal } : {}),
186
187
  })
188
+ if (typeof res?.body?.cancel === "function") {
189
+ try { await res.body.cancel() } catch (_err) { /* best-effort cancel */ }
190
+ } else if (typeof res?.arrayBuffer === "function") {
191
+ try { await res.arrayBuffer() } catch (_err) { /* best-effort cancel */ }
192
+ }
187
193
  if (timer) clearTimeout(timer)
188
194
  return { ok: res.ok, status: res.status }
189
195
  } catch (err) {
196
+ if (res && typeof res.body?.cancel === "function") {
197
+ try { await res.body.cancel() } catch (_err) { /* best-effort cancel */ }
198
+ }
190
199
  if (timer) clearTimeout(timer)
191
200
  return { ok: false, error: err?.message || String(err) }
201
+ } finally {
202
+ if (timer) clearTimeout(timer)
192
203
  }
193
204
  }
194
205
 
@@ -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
 
@@ -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
@@ -467,6 +467,35 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
467
467
  const row = providers().find((item) => item.provider === p)
468
468
  return row?.availableModels || row?.models || []
469
469
  },
470
+ probeModel: probeModel ?? (async ({ provider, model, signal }) => {
471
+ const llm = llmOf(ctx)
472
+ if (typeof llm?.stream === "function") {
473
+ const stream = await llm.stream({
474
+ provider,
475
+ model,
476
+ messages: [{ role: "user", content: [{ type: "text", text: "ping" }] }],
477
+ }, { signal })
478
+ if (stream && typeof stream[Symbol.asyncIterator] === "function") {
479
+ for await (const _chunk of stream) {
480
+ break
481
+ }
482
+ }
483
+ return { ok: true }
484
+ }
485
+ const profile = profileOf(settingsOf(ctx), provider)
486
+ const adapter = selectAdapter(profile)
487
+ if (typeof adapter?.probeModel === "function") {
488
+ return adapter.probeModel(profile, model, { signal, fetchImpl })
489
+ }
490
+ const h = await health({ provider, model, signal })
491
+ const entry = h?.results?.find((r) => r.provider === provider)
492
+ if (entry?.status !== "ok") {
493
+ const err = new Error(entry?.message || "provider health failed: " + entry?.status)
494
+ if (entry?.status === "circuit-open") err.status = 503
495
+ throw err
496
+ }
497
+ return { ok: true }
498
+ }),
470
499
  })
471
500
 
472
501
  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.11",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",