@ljwei-stak/dsh-model-router 0.16.0 → 0.16.2

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.
@@ -9,15 +9,16 @@
9
9
  * their own outbound network policy if untrusted users can configure endpoints.
10
10
  */
11
11
  import { createHash, randomUUID } from 'node:crypto'
12
- import { isIP } from 'node:net'
13
12
  import { fetchLiveBenchSnapshot } from './livebench.mjs'
14
- export { applyPricingSnapshot, dynamicDataSummary, routingData } from './dynamic-data-view.mjs'
13
+ import { projectDynamicData, publicDynamicEndpoint } from './dynamic-data-view.mjs'
14
+ export { applyPricingSnapshot, dynamicDataSummary, routingData, projectDynamicData, publicDynamicEndpoint } from './dynamic-data-view.mjs'
15
15
 
16
16
  const DEFAULT_TTL_MS = 86_400_000
17
17
  const TIMEOUT_MS = 8000
18
18
  const MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
19
19
  const MAX_ROWS = 2000
20
20
  const MAX_CLOCK_SKEW_MS = 60_000
21
+ const RESERVATION_GRACE_MS = 5000
21
22
  const ROW_FIELDS = new Set(['provider', 'model', 'input', 'output', 'cacheRead', 'cacheWrite', 'currency', 'unit', 'asOf', 'sourceUrl'])
22
23
  const PRICE_FIELDS = new Set(['schemaVersion', 'kind', 'version', 'publishedAt', 'rows'])
23
24
  const SCORE_FIELDS = new Set(['reasoning', 'code', 'math', 'research', 'writing', 'vision', 'summarization', 'classification'])
@@ -43,16 +44,7 @@ function revisionOf(data) {
43
44
  return hash({ liveBench: data.liveBench?.hash ?? null, pricing: data.pricing?.hash ?? null, status: data.status })
44
45
  }
45
46
 
46
- function publicUrl(value) {
47
- if (typeof value !== 'string' || value.length > 2048) throw new Error('invalid-public-url')
48
- const url = new URL(value)
49
- const host = url.hostname.toLowerCase()
50
- if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
51
- || (url.port && url.port !== '443') || isIP(host) || host.startsWith('[')
52
- || !/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}$/u.test(host)
53
- || /(?:^|\.)(?:localhost|local|internal|lan|home|test|invalid)$/u.test(host)) throw new Error('invalid-public-url')
54
- return url.toString()
55
- }
47
+ const publicUrl = publicDynamicEndpoint
56
48
 
57
49
  function identifier(value, max = 240) {
58
50
  if (typeof value !== 'string' || value.trim() !== value || !value || value.length > max
@@ -148,59 +140,94 @@ function mayPromote(previous, candidate) {
148
140
  return true
149
141
  }
150
142
 
151
- async function limitedText(response) {
143
+ function cancelBody(body) {
144
+ try { Promise.resolve(body?.cancel?.()).catch(() => {}) } catch { /* best effort; never block rejection */ }
145
+ }
146
+
147
+ async function limitedText(response, signal) {
152
148
  const length = Number(response.headers?.get?.('content-length'))
153
- if (Number.isFinite(length) && length > MAX_PAYLOAD_BYTES) throw new Error('payload-too-large')
149
+ if (Number.isFinite(length) && length > MAX_PAYLOAD_BYTES) {
150
+ cancelBody(response.body)
151
+ throw new Error('payload-too-large')
152
+ }
154
153
  if (!response.body?.getReader) {
155
154
  const text = await response.text()
156
155
  if (Buffer.byteLength(text, 'utf8') > MAX_PAYLOAD_BYTES) throw new Error('payload-too-large')
157
156
  return text
158
157
  }
159
158
  const reader = response.body.getReader()
160
- const decoder = new TextDecoder()
159
+ const decoder = new TextDecoder('utf-8', { fatal: true })
161
160
  let size = 0
162
161
  let text = ''
162
+ const cancelReader = () => { try { Promise.resolve(reader.cancel()).catch(() => {}) } catch {} }
163
+ signal?.addEventListener('abort', cancelReader, { once: true })
163
164
  try {
164
165
  for (;;) {
166
+ if (signal?.aborted) throw new Error('refresh-aborted')
165
167
  const chunk = await reader.read()
168
+ if (signal?.aborted) throw new Error('refresh-aborted')
166
169
  if (chunk.done) return text + decoder.decode()
167
170
  size += chunk.value.byteLength
168
171
  if (size > MAX_PAYLOAD_BYTES) throw new Error('payload-too-large')
169
172
  text += decoder.decode(chunk.value, { stream: true })
170
173
  }
171
174
  } catch (error) {
172
- await reader.cancel().catch(() => {})
175
+ cancelReader()
173
176
  throw error
174
- } finally { reader.releaseLock() }
177
+ } finally { signal?.removeEventListener('abort', cancelReader); reader.releaseLock() }
175
178
  }
176
179
 
177
- function publicFetch(fetchImpl) {
180
+ function publicFetch(fetchImpl, outerSignal) {
178
181
  return async (input, options = {}) => {
179
182
  const endpoint = publicUrl(String(input))
180
- const response = await fetchImpl(endpoint, {
181
- method: 'GET', headers: { accept: 'application/json,text/csv,text/html' },
182
- credentials: 'omit', redirect: 'error', signal: options.signal,
183
- })
184
- if (!response?.ok || response.redirected) throw new Error('public-fetch-failed')
185
- if (response.url && publicUrl(response.url) !== endpoint) throw new Error('public-fetch-redirected')
186
- return { ok: true, status: response.status, headers: response.headers,
187
- text: () => limitedText(response) }
183
+ const controller = new AbortController()
184
+ const signals = [...new Set([outerSignal, options.signal].filter(Boolean))]
185
+ const abort = () => controller.abort(new Error('refresh-aborted'))
186
+ const cleanup = () => { for (const signal of signals) signal.removeEventListener('abort', abort) }
187
+ for (const signal of signals) {
188
+ if (signal.aborted) abort()
189
+ else signal.addEventListener('abort', abort, { once: true })
190
+ }
191
+ controller.signal.addEventListener('abort', cleanup, { once: true })
192
+ let response
193
+ try {
194
+ if (controller.signal.aborted) throw new Error('refresh-aborted')
195
+ response = await fetchImpl(endpoint, {
196
+ method: 'GET', headers: { accept: 'application/json,text/csv,text/html' },
197
+ credentials: 'omit', redirect: 'error', signal: controller.signal,
198
+ })
199
+ if (controller.signal.aborted || !response?.ok || response.redirected) {
200
+ throw new Error('public-fetch-failed')
201
+ }
202
+ if (response.url && publicUrl(response.url) !== endpoint) {
203
+ throw new Error('public-fetch-redirected')
204
+ }
205
+ return { ok: true, status: response.status, headers: response.headers,
206
+ text: async () => { try { return await limitedText(response, controller.signal) } finally { cleanup() } } }
207
+ } catch (error) { cancelBody(response?.body); cleanup(); throw error }
188
208
  }
189
209
  }
190
210
 
191
- async function withTimeout(run) {
192
- const controller = new AbortController()
193
- let timer
194
- let rejectTimeout
195
- const timeout = new Promise((_, reject) => { rejectTimeout = reject })
196
- timer = setTimeout(() => { controller.abort(); rejectTimeout(new Error('refresh-timeout')) }, TIMEOUT_MS)
197
- try { return await Promise.race([run(controller.signal), timeout]) }
198
- finally { clearTimeout(timer) }
211
+ async function withTimeout(run, controller, timeoutMs) {
212
+ let rejectAborted
213
+ const aborted = new Promise((_, reject) => { rejectAborted = reject })
214
+ const onAbort = () => rejectAborted(new Error('refresh-aborted'))
215
+ controller.signal.addEventListener('abort', onAbort, { once: true })
216
+ const timer = setTimeout(() => controller.abort(new Error('refresh-timeout')), timeoutMs)
217
+ try {
218
+ if (controller.signal.aborted) throw new Error('refresh-aborted')
219
+ return await Promise.race([run(controller.signal), aborted])
220
+ } finally { clearTimeout(timer); controller.signal.removeEventListener('abort', onAbort) }
199
221
  }
200
222
 
201
223
  function due(data, kind, endpoint, ttlMs, at, force) {
202
224
  const status = data.status?.[kind]
203
225
  if (force || status?.endpoint !== endpoint || !finite(status?.lastAttemptAt)) return true
226
+ if (at < status.lastAttemptAt) return true // a corrected clock must not freeze refresh forever
227
+ if (status.requestId) {
228
+ const deadline = finite(status.requestDeadlineAt) ? status.requestDeadlineAt : status.lastAttemptAt + TIMEOUT_MS + RESERVATION_GRACE_MS
229
+ return at >= deadline
230
+ }
204
231
  const delay = status.error ? Math.min(ttlMs, 300_000) : ttlMs
205
232
  return at - status.lastAttemptAt >= delay
206
233
  }
@@ -210,11 +237,32 @@ function due(data, kind, endpoint, ttlMs, at, force) {
210
237
  * mutates the fresh state under the caller's interprocess/atomic write lock.
211
238
  */
212
239
  export function createDynamicDataRefresher({ store, fetchImpl = globalThis.fetch, now = Date.now,
213
- fetchBenchmark = fetchLiveBenchSnapshot } = {}) {
240
+ fetchBenchmark = fetchLiveBenchSnapshot, timeoutMs = TIMEOUT_MS } = {}) {
214
241
  if (typeof store?.read !== 'function' || typeof store?.update !== 'function') throw new Error('dynamic-data-store-required')
242
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > TIMEOUT_MS) throw new Error('invalid-refresh-timeout')
215
243
  const flights = new Map()
244
+ const active = new Map()
245
+ const sourceStates = new Map()
246
+ const generationOf = kind => sourceStates.get(kind)?.generation ?? 0
216
247
  const refresh = async (options = {}) => {
217
- if (options.enabled !== true) return emptyData()
248
+ if (options.enabled !== true) {
249
+ for (const kind of ['liveBench', 'pricing']) sourceStates.set(kind, { fingerprint: '', generation: generationOf(kind) + 1 })
250
+ const pending = [...active.values()]
251
+ for (const request of pending) request.controller.abort()
252
+ if (pending.length) {
253
+ try { await store.update(state => {
254
+ const data = dataOf(state)
255
+ for (const request of pending) if (data.status[request.kind]?.requestId === request.requestId) {
256
+ data.status[request.kind] = { ...data.status[request.kind], disabled: true, endpoint: '', error: '' }
257
+ delete data.status[request.kind].requestId
258
+ delete data.status[request.kind].requestDeadlineAt
259
+ }
260
+ data.revision = revisionOf(data)
261
+ state.dynamicData = data
262
+ }) } catch { /* disabled projection is safe even if cancellation cannot be persisted */ }
263
+ }
264
+ return emptyData()
265
+ }
218
266
  const ttlMs = finite(options.ttlMs) && options.ttlMs >= 0 ? options.ttlMs : DEFAULT_TTL_MS
219
267
  const liveBenchEndpoint = options.liveBenchEndpoint ?? 'https://livebench.ai'
220
268
  const pricingSnapshotEndpoint = options.pricingSnapshotEndpoint ?? ''
@@ -225,66 +273,96 @@ export function createDynamicDataRefresher({ store, fetchImpl = globalThis.fetch
225
273
  try { return { kind, endpoint: publicUrl(raw), disabled: false, invalid: false } }
226
274
  catch { return { kind, endpoint: '', disabled: false, invalid: true } }
227
275
  })
228
- const key = stable([sources, ttlMs, options.force === true])
276
+ for (const source of sources) {
277
+ const fingerprint = stable(source)
278
+ if (fingerprint !== sourceStates.get(source.kind)?.fingerprint) {
279
+ sourceStates.set(source.kind, { fingerprint, generation: generationOf(source.kind) + 1 })
280
+ for (const request of active.values()) if (request.kind === source.kind) request.controller.abort()
281
+ }
282
+ }
283
+ const generations = Object.fromEntries(sources.map(source => [source.kind, generationOf(source.kind)]))
284
+ const key = stable([sources, generations, ttlMs, options.force === true])
229
285
  if (flights.has(key)) return flights.get(key)
230
286
  const operation = (async () => {
231
287
  const initial = dataOf(await store.read())
288
+ const storageFailures = new Set()
289
+ await Promise.all(sources.filter(source => source.disabled && (!initial.status[source.kind]?.disabled || initial.status[source.kind]?.requestId))
290
+ .map(async ({ kind }) => {
291
+ try { await store.update(state => {
292
+ if (generationOf(kind) !== generations[kind]) return
293
+ const data = dataOf(state)
294
+ data.status[kind] = { ...(data.status[kind] ?? {}), disabled: true, endpoint: '', error: '' }
295
+ delete data.status[kind].requestId
296
+ delete data.status[kind].requestDeadlineAt
297
+ data.revision = revisionOf(data)
298
+ state.dynamicData = data
299
+ }) } catch { storageFailures.add(kind) }
300
+ }))
232
301
  const requests = sources.filter(source => !source.disabled)
233
302
  .filter(({ kind, endpoint }) => due(initial, kind, endpoint, ttlMs, now(), options.force === true))
234
- await Promise.all(requests.map(async ({ kind, endpoint, invalid }) => {
235
- const at = now()
303
+ const settled = await Promise.allSettled(requests.map(async ({ kind, endpoint, invalid }) => {
236
304
  const requestId = randomUUID()
237
- // Reserve before network I/O. Another process or source change wins by
238
- // replacing this token; a late completion then cannot mutate its data.
239
- const reserved = await store.update(state => {
240
- const data = dataOf(state)
241
- if (!due(data, kind, endpoint, ttlMs, at, options.force === true)) return false
242
- data.status[kind] = { ...(data.status[kind] ?? {}), endpoint,
243
- lastAttemptAt: at, requestId, error: '' }
244
- data.revision = revisionOf(data)
245
- state.dynamicData = data
246
- return true
247
- })
248
- if (!reserved) return
249
- let candidate
250
- let error = ''
305
+ const controller = new AbortController()
306
+ const request = { kind, endpoint, requestId, controller }
307
+ active.set(requestId, request)
251
308
  try {
252
- if (invalid) throw new Error('invalid-public-url')
253
- if (typeof fetchImpl !== 'function') throw new Error('fetch-unavailable')
254
- const fetchPublic = publicFetch(fetchImpl)
255
- candidate = await withTimeout(async signal => {
256
- if (kind === 'liveBench') {
257
- const payload = await fetchBenchmark({ endpoint, fetchImpl: fetchPublic, timeoutMs: TIMEOUT_MS, strict: true })
258
- return benchmarkSnapshot(payload, endpoint, now())
259
- }
260
- const response = await fetchPublic(endpoint, { signal })
261
- return pricingSnapshot(JSON.parse(await response.text()), endpoint, now())
309
+ // Reserve before network I/O. Another process or source change wins by
310
+ // replacing this token; a late completion then cannot mutate its data.
311
+ const reserved = await store.update(state => {
312
+ if (generationOf(kind) !== generations[kind] || controller.signal.aborted) return false
313
+ const data = dataOf(state)
314
+ const at = now()
315
+ if (!due(data, kind, endpoint, ttlMs, at, options.force === true)) return false
316
+ data.status[kind] = { ...(data.status[kind] ?? {}), disabled: false, endpoint,
317
+ lastAttemptAt: at, requestId, requestDeadlineAt: at + timeoutMs + RESERVATION_GRACE_MS }
318
+ data.revision = revisionOf(data)
319
+ state.dynamicData = data
320
+ return true
262
321
  })
263
- } catch { error = 'refresh-failed' }
264
- await store.update(state => {
265
- const data = dataOf(state)
266
- if (data.status[kind]?.requestId !== requestId) return false
267
- if (candidate && !mayPromote(data[kind], candidate)) error = 'snapshot-rollback-rejected'
268
- if (candidate && !error) data[kind] = candidate
269
- data.status[kind] = { ...data.status[kind], error,
270
- ...(candidate && !error ? { lastSuccessAt: now() } : {}) }
271
- delete data.status[kind].requestId
272
- data.revision = revisionOf(data)
273
- state.dynamicData = data
274
- return true
275
- })
322
+ if (!reserved) return
323
+ let candidate
324
+ let error = ''
325
+ try {
326
+ if (invalid) throw new Error('invalid-public-url')
327
+ if (typeof fetchImpl !== 'function') throw new Error('fetch-unavailable')
328
+ candidate = await withTimeout(async signal => {
329
+ const fetchPublic = publicFetch(fetchImpl, signal)
330
+ if (kind === 'liveBench') {
331
+ const payload = await fetchBenchmark({ endpoint, fetchImpl: fetchPublic, timeoutMs, signal, strict: true })
332
+ return benchmarkSnapshot(payload, endpoint, now())
333
+ }
334
+ const response = await fetchPublic(endpoint, { signal })
335
+ return pricingSnapshot(JSON.parse(await response.text()), endpoint, now())
336
+ }, controller, timeoutMs)
337
+ } catch { error = 'refresh-failed' }
338
+ await store.update(state => {
339
+ const data = dataOf(state)
340
+ if (data.status[kind]?.requestId !== requestId) return false
341
+ if (candidate && !mayPromote(data[kind], candidate)) error = 'snapshot-rollback-rejected'
342
+ if (candidate && !error) data[kind] = candidate
343
+ data.status[kind] = { ...data.status[kind], error,
344
+ ...(candidate && !error ? { lastSuccessAt: now() } : {}) }
345
+ delete data.status[kind].requestId
346
+ delete data.status[kind].requestDeadlineAt
347
+ data.revision = revisionOf(data)
348
+ state.dynamicData = data
349
+ return true
350
+ })
351
+ } finally { active.delete(requestId) }
276
352
  }))
353
+ for (let index = 0; index < settled.length; index += 1) if (settled[index].status === 'rejected') storageFailures.add(requests[index].kind)
277
354
  const result = dataOf(await store.read())
355
+ for (const kind of storageFailures) {
356
+ const endpoint = sources.find(source => source.kind === kind)?.endpoint ?? ''
357
+ result.status[kind] = { ...(result.status[kind]?.endpoint === endpoint ? result.status[kind] : {}),
358
+ endpoint, error: 'storage-failed' }
359
+ delete result.status[kind].requestId
360
+ delete result.status[kind].requestDeadlineAt
361
+ }
278
362
  // A user clearing a source disables its use immediately, without erasing
279
363
  // recoverable last-good snapshots from disk.
280
- for (const { kind, disabled } of sources) {
281
- if (disabled) {
282
- result[kind] = null
283
- result.status[kind] = { disabled: true, endpoint: '', error: '' }
284
- }
285
- }
286
364
  result.revision = revisionOf(result)
287
- return result
365
+ return projectDynamicData(result, options)
288
366
  })()
289
367
  flights.set(key, operation)
290
368
  try { return await operation }
@@ -12,6 +12,9 @@ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
12
12
  import { dirname, join } from 'node:path'
13
13
  import { resolveStateHome } from './router-state.mjs'
14
14
  import { OFFICIAL_TOOLS } from './official-tool-registry.mjs'
15
+ import { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
16
+
17
+ export { compareReleaseVersions, isReleaseVersion } from './version-order.mjs'
15
18
 
16
19
  export const LATEST_CACHE_MS = 12 * 60 * 60_000
17
20
  export const LATEST_FAILURE_RETRY_MS = 30 * 60_000
@@ -19,27 +22,6 @@ export const LATEST_TIMEOUT_MS = 5_000
19
22
  export const NPM_REGISTRY = 'https://registry.npmjs.org'
20
23
  export const ZCODE_DOWNLOAD_PAGE = 'https://zcode.z.ai/en/docs/install'
21
24
  const MAX_DOCUMENT_BYTES = 2_000_000
22
- const VERSION = /^(\d{1,6})\.(\d{1,6})\.(\d{1,6})(?:-[0-9A-Za-z.-]{1,64})?$/
23
-
24
- export function isReleaseVersion(value) {
25
- return typeof value === 'string' && VERSION.test(value)
26
- }
27
-
28
- /** Numeric comparison of x.y.z[-pre]; a prerelease sorts below its release. */
29
- export function compareReleaseVersions(left, right) {
30
- const parse = value => {
31
- const match = VERSION.exec(String(value ?? ''))
32
- return match ? { parts: match.slice(1, 4).map(Number), pre: String(value).includes('-') } : null
33
- }
34
- const a = parse(left)
35
- const b = parse(right)
36
- if (!a || !b) return null
37
- for (let index = 0; index < 3; index += 1) {
38
- if (a.parts[index] !== b.parts[index]) return a.parts[index] > b.parts[index] ? 1 : -1
39
- }
40
- if (a.pre !== b.pre) return a.pre ? -1 : 1
41
- return 0
42
- }
43
25
 
44
26
  /** True only when both versions parse and the latest is strictly newer. */
45
27
  export function updateAvailable(installed, latest) {