@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.
- package/README.md +17 -9
- package/README.ru.md +17 -9
- package/README.zh.md +17 -9
- package/lib/adapter-registry.js +6 -0
- package/lib/alias-resolver.js +16 -2
- package/lib/client.js +416 -32
- package/lib/diff-notifier.js +12 -1
- package/lib/generic-adapter.js +9 -1
- package/lib/inventory.js +16 -8
- package/lib/models.js +28 -3
- package/lib/reconcile.js +2 -0
- package/lib/reliability.js +86 -14
- package/lib/synchronizer-prober.js +130 -50
- package/lib/synchronizer.js +43 -1
- package/lib/updater.js +6 -1
- package/package.json +1 -1
package/lib/generic-adapter.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
} : {}),
|
package/lib/reliability.js
CHANGED
|
@@ -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([
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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)
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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({
|
|
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
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
160
|
+
modelProbes.set(`${provider}:${model}`, {
|
|
161
|
+
latencyMs: baseLatency,
|
|
162
|
+
ok,
|
|
163
|
+
status,
|
|
164
|
+
timestamp: Date.now(),
|
|
86
165
|
})
|
|
87
|
-
|
|
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
|
+
}
|
package/lib/synchronizer.js
CHANGED
|
@@ -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)
|
|
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 }
|