@ljwei-stak/dsh-model-router 0.15.0 → 0.16.1

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.
@@ -23,7 +23,7 @@ const TASK_ALIASES = Object.freeze({
23
23
  const CATEGORY_TO_TASK = Object.freeze({
24
24
  reasoning: 'reasoning',
25
25
  coding: 'code',
26
- 'agentic coding': 'code',
26
+ // Agentic Coding is a different task family, not silently a Coding score.
27
27
  mathematics: 'math',
28
28
  'data analysis': 'research',
29
29
  language: 'writing',
@@ -38,10 +38,12 @@ function normalized(value) {
38
38
  return String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '')
39
39
  }
40
40
 
41
- function asScore(value) {
41
+ function asScore(value, scale) {
42
+ if (value === null || value === undefined || String(value).trim() === '') return undefined
42
43
  const number = Number(value)
43
44
  if (!Number.isFinite(number)) return undefined
44
- return clamp(number > 1 ? number / 100 : number)
45
+ if (number < 0 || number > (scale ?? 100)) return undefined
46
+ return scale === undefined ? (number > 1 ? number / 100 : number) : number / scale
45
47
  }
46
48
 
47
49
  function modelRows(payload) {
@@ -59,13 +61,13 @@ function rowName(row) {
59
61
  return String(row.model ?? row.model_name ?? row.name ?? row.id ?? row.slug ?? '')
60
62
  }
61
63
 
62
- function rowScores(row) {
64
+ function rowScores(row, scale) {
63
65
  const source = row?.scores ?? row?.categories ?? row?.benchmark ?? row
64
66
  const scores = {}
65
67
  if (source === null || typeof source !== 'object') return scores
66
68
  for (const [task, aliases] of Object.entries(TASK_ALIASES)) {
67
69
  for (const alias of aliases) {
68
- const score = asScore(source[alias])
70
+ const score = asScore(source[alias], scale)
69
71
  if (score !== undefined) {
70
72
  scores[task] = score
71
73
  break
@@ -77,22 +79,27 @@ function rowScores(row) {
77
79
 
78
80
  /** Normalize a provider response into `{ models: Record<normalizedId, row> }`. */
79
81
  export function normalizeLiveBenchPayload(payload, fetchedAt = Date.now(), source = 'livebench') {
82
+ const scale = payload?.scoreScale === 1 || payload?.scoreScale === 100 ? payload.scoreScale : undefined
80
83
  const models = {}
81
84
  for (const row of modelRows(payload)) {
82
85
  const id = normalized(rowName(row))
83
86
  if (id === '') continue
84
- const scores = rowScores(row)
87
+ const scores = rowScores(row, scale)
85
88
  const values = Object.values(scores)
86
- const overall = asScore(row?.overall ?? row?.score ?? row?.livebench_score)
89
+ const suppliedOverall = asScore(row?.overall ?? row?.score ?? row?.livebench_score, scale)
90
+ const overall = suppliedOverall
87
91
  ?? (values.length > 0 ? values.reduce((sum, value) => sum + value, 0) / values.length : undefined)
88
92
  if (overall === undefined) continue
93
+ if (models[id]) throw new Error('LiveBench model identifiers collide after normalization')
89
94
  models[id] = {
95
+ model: rowName(row),
90
96
  overall: Number(clamp(overall).toFixed(4)),
97
+ overallSource: suppliedOverall !== undefined && row?.overallSource !== 'derived' ? 'explicit' : 'derived',
91
98
  scores,
92
99
  rank: Number.isFinite(Number(row?.rank)) ? Number(row.rank) : undefined,
93
100
  }
94
101
  }
95
- return { source, fetchedAt, models }
102
+ return { source, fetchedAt, models, ...(payload?.publishedAt === undefined ? {} : { publishedAt: payload.publishedAt }) }
96
103
  }
97
104
 
98
105
  /** Parse a small RFC-4180-compatible CSV without adding a runtime dependency. */
@@ -134,13 +141,22 @@ export function parseCsv(text) {
134
141
  return rows.slice(1).map(cells => Object.fromEntries(headers.map((header, index) => [header, cells[index] ?? ''])))
135
142
  }
136
143
 
137
- function mean(values) {
138
- const numbers = values.map(asScore).filter(value => value !== undefined)
144
+ function mean(values, percent = false) {
145
+ const numbers = values.map(value => percent
146
+ ? (String(value ?? '').trim() !== '' && Number.isFinite(Number(value)) && Number(value) >= 0 && Number(value) <= 100 ? Number(value) / 100 : undefined)
147
+ : asScore(value)).filter(value => value !== undefined)
139
148
  return numbers.length === 0 ? undefined : numbers.reduce((sum, value) => sum + value, 0) / numbers.length
140
149
  }
141
150
 
142
- function officialCsvPayload(csv, categories = {}) {
151
+ function officialCsvPayload(csv, categories = {}, percent = false, strict = false) {
143
152
  const rows = parseCsv(csv)
153
+ if (strict) for (const row of rows) {
154
+ if (!row.model) throw new Error('LiveBench row has no model')
155
+ for (const [column, value] of Object.entries(row)) {
156
+ if (column === 'model' || String(value).trim() === '') continue
157
+ if (!Number.isFinite(Number(value)) || Number(value) < 0 || Number(value) > 100) throw new Error('LiveBench CSV score is outside 0..100')
158
+ }
159
+ }
144
160
  const categoryColumns = new Map()
145
161
  for (const [label, columns] of Object.entries(categories ?? {})) {
146
162
  const task = CATEGORY_TO_TASK[String(label).trim().toLowerCase()]
@@ -149,16 +165,18 @@ function officialCsvPayload(csv, categories = {}) {
149
165
  const models = rows.map(row => {
150
166
  const scores = {}
151
167
  for (const [task, columns] of categoryColumns.entries()) {
152
- const score = mean(columns.map(column => row[column]))
168
+ const score = mean(columns.map(column => row[column]), percent)
153
169
  if (score !== undefined) scores[task] = score
154
170
  }
155
171
  // A mirror may already provide normalized task columns, so preserve them.
156
172
  for (const [task, aliases] of Object.entries(TASK_ALIASES)) {
157
173
  if (scores[task] !== undefined) continue
158
- const score = mean(aliases.map(alias => row[alias]))
174
+ const score = mean(aliases.map(alias => row[alias]), percent)
159
175
  if (score !== undefined) scores[task] = score
160
176
  }
161
- return { model: row.model, scores, overall: mean(Object.values(row).slice(1)) }
177
+ const suppliedOverall = mean([row.overall ?? row.score ?? row.livebench_score], percent)
178
+ return { model: row.model, scores, overall: suppliedOverall ?? mean(Object.values(row).slice(1), percent),
179
+ overallSource: suppliedOverall === undefined ? 'derived' : 'explicit' }
162
180
  })
163
181
  return { models }
164
182
  }
@@ -194,7 +212,7 @@ async function fetchWithTimeout(fetchImpl, url, signal) {
194
212
  return response
195
213
  }
196
214
 
197
- async function officialSnapshot({ endpoint, fetchImpl, signal, fetchedAt }) {
215
+ async function officialSnapshot({ endpoint, fetchImpl, signal, fetchedAt, strict }) {
198
216
  const base = new URL(endpoint).origin
199
217
  let releases = [DEFAULT_RELEASE]
200
218
  try {
@@ -219,7 +237,7 @@ async function officialSnapshot({ endpoint, fetchImpl, signal, fetchedAt }) {
219
237
  const categoryResponse = await fetchWithTimeout(fetchImpl, absoluteUrl(base, `categories_${token}.json`), signal)
220
238
  const table = (await readResponse(tableResponse)).text
221
239
  const categories = JSON.parse((await readResponse(categoryResponse)).text)
222
- return normalizeLiveBenchPayload(officialCsvPayload(table, categories), fetchedAt, `livebench:${release}`)
240
+ return normalizeLiveBenchPayload(officialCsvPayload(table, categories, true, strict), fetchedAt, `livebench:${release}`)
223
241
  } catch (error) {
224
242
  lastError = error
225
243
  }
@@ -235,6 +253,7 @@ export async function fetchLiveBenchSnapshot({
235
253
  endpoint = 'https://livebench.ai',
236
254
  fetchImpl = globalThis.fetch,
237
255
  timeoutMs = 8000,
256
+ strict = false,
238
257
  } = {}) {
239
258
  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
240
259
  const controller = new AbortController()
@@ -245,14 +264,29 @@ export async function fetchLiveBenchSnapshot({
245
264
  const parsed = new URL(rawEndpoint)
246
265
  if ((parsed.hostname === 'livebench.ai' || parsed.hostname === 'www.livebench.ai')
247
266
  && (parsed.pathname === '' || parsed.pathname === '/')) {
248
- return await officialSnapshot({ endpoint: parsed.toString(), fetchImpl, signal: controller.signal, fetchedAt })
267
+ return await officialSnapshot({ endpoint: parsed.toString(), fetchImpl, signal: controller.signal, fetchedAt, strict })
249
268
  }
250
269
  const url = parsed.toString().replace('{release}', DEFAULT_RELEASE)
251
270
  const response = await fetchWithTimeout(fetchImpl, url, controller.signal)
252
271
  const { type, text } = await readResponse(response)
253
272
  const isCsv = type.includes('csv') || /\.csv(?:$|\?)/i.test(parsed.pathname)
254
- if (isCsv) return normalizeLiveBenchPayload(officialCsvPayload(text), fetchedAt, 'livebench-csv-mirror')
255
- return normalizeLiveBenchPayload(JSON.parse(text), fetchedAt, 'livebench-json-mirror')
273
+ if (isCsv) {
274
+ if (strict) throw new Error('Dynamic custom LiveBench feeds require JSON with explicit scoreScale')
275
+ return normalizeLiveBenchPayload(officialCsvPayload(text), fetchedAt, 'livebench-csv-mirror')
276
+ }
277
+ const payload = JSON.parse(text)
278
+ if (strict) {
279
+ if (![1, 100].includes(payload?.scoreScale) || modelRows(payload).length === 0) throw new Error('LiveBench feed needs explicit scoreScale and nonempty models')
280
+ for (const row of modelRows(payload)) {
281
+ if (!rowName(row) || rowName(row).trim() !== rowName(row)) throw new Error('LiveBench model identifier is invalid')
282
+ const scores = row?.scores ?? row?.categories ?? row?.benchmark ?? row
283
+ const values = [row.overall ?? row.score ?? row.livebench_score,
284
+ ...Object.values(TASK_ALIASES).flatMap(aliases => aliases.map(alias => scores?.[alias]))]
285
+ .filter(value => value !== undefined && value !== null && String(value).trim() !== '')
286
+ if (!values.length || values.some(value => typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > payload.scoreScale)) throw new Error('LiveBench score is invalid for declared scale')
287
+ }
288
+ }
289
+ return normalizeLiveBenchPayload(payload, fetchedAt, 'livebench-json-mirror')
256
290
  } finally {
257
291
  clearTimeout(timer)
258
292
  }
@@ -260,5 +294,8 @@ export async function fetchLiveBenchSnapshot({
260
294
 
261
295
  /** Return a model's benchmark row using the same normalization as the adapter. */
262
296
  export function liveBenchRow(snapshot, model) {
263
- return snapshot?.models?.[normalized(model)] ?? null
297
+ const row = snapshot?.models?.[normalized(model)] ?? null
298
+ // Dynamic snapshots require explicit model identity. Old programmatic
299
+ // snapshots retain their previous normalization-only compatibility.
300
+ return snapshot?.exactModelMatch === true && row?.model !== model ? null : row
264
301
  }
@@ -22,7 +22,7 @@ function nonnegative(value, label) {
22
22
  function normalizeProfile(entry, index) {
23
23
  const label = `第 ${index + 1} 个模型`
24
24
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) throw new Error(`${label} 必须是对象`)
25
- const allowed = new Set(['provider', 'model', 'quality', 'pricing', 'specialties', 'cliModel', 'execution', 'billing', 'subscription', 'apiRoute'])
25
+ const allowed = new Set(['provider', 'model', 'benchmarkModel', 'quality', 'pricing', 'specialties', 'cliModel', 'execution', 'billing', 'subscription', 'apiRoute'])
26
26
  const unknown = Object.keys(entry).find(key => !allowed.has(key))
27
27
  if (unknown) throw new Error(`${label} 含不支持的字段 ${unknown};不要在这里填写密钥或命令`)
28
28
  const provider = id(entry.provider)
@@ -31,6 +31,11 @@ function normalizeProfile(entry, index) {
31
31
  throw new Error(`${label} 需要模型目录中的准确 provider 和 model`)
32
32
  }
33
33
  const profile = { provider, model }
34
+ if (entry.benchmarkModel !== undefined) {
35
+ const benchmarkModel = id(entry.benchmarkModel)
36
+ if (!benchmarkModel || benchmarkModel.length > 240 || /[\u0000-\u001f]/u.test(benchmarkModel)) throw new Error(`${label} 的 benchmarkModel 需要准确的基准模型名称`)
37
+ profile.benchmarkModel = benchmarkModel
38
+ }
34
39
  if (entry.quality !== undefined) {
35
40
  const value = nonnegative(entry.quality, `${label} 的 quality`)
36
41
  if (value > 100) throw new Error(`${label} 的 quality 应在 0 到 100 之间`)
@@ -129,6 +134,7 @@ export function applyModelProfiles(routes, profiles) {
129
134
  if (!profile) return route
130
135
  return {
131
136
  ...route,
137
+ ...(profile.benchmarkModel === undefined ? {} : { benchmarkModel: profile.benchmarkModel }),
132
138
  ...(profile.quality === undefined ? {} : { quality: profile.quality, qualitySource: 'user' }),
133
139
  ...(profile.pricing === undefined ? {} : { pricing: { ...profile.pricing }, pricingSource: 'user' }),
134
140
  ...(profile.specialties === undefined ? {} : { specialties: [...profile.specialties] }),
@@ -78,7 +78,9 @@ const optionalRouteText = (value, subject) => {
78
78
  const rateRequestCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#RateRequest`, value => {
79
79
  const request = plainObject(value, 'rate request')
80
80
  if (!['up', 'down', 'clear'].includes(request.rating)) throw new TypeError('rating must be up, down or clear')
81
- return { runId: idText(request.runId, 'runId'), packageId: idText(request.packageId, 'packageId'), rating: request.rating }
81
+ if (request.expectedFinishedAt !== undefined && (!Number.isFinite(request.expectedFinishedAt) || request.expectedFinishedAt < 0)) throw new TypeError('expectedFinishedAt must be a non-negative timestamp')
82
+ return { runId: idText(request.runId, 'runId'), packageId: idText(request.packageId, 'packageId'), rating: request.rating,
83
+ ...(request.expectedFinishedAt === undefined ? {} : { expectedFinishedAt: request.expectedFinishedAt }) }
82
84
  })
83
85
 
84
86
  const rerunRequestCodec = strictCodec(`${OFFICIAL_TOOLS_REMOTE_PACKAGE}#RerunRequest`, value => {
@@ -4,13 +4,15 @@
4
4
  * errors, cost, review and user ratings). One JSON file under the DSH home. Never stores keys.
5
5
  *
6
6
  * Several Host processes may share one DSH home, so every update takes a
7
- * lock file (`state.json.lock`, exclusive create; stale locks are broken),
7
+ * lock file (`state.json.lock`, exclusive create; only stale dead-owner locks
8
+ * are recovered). A busy live owner causes a bounded, explicit timeout,
8
9
  * re-reads the file, applies the change to the fresh copy and writes it back
9
10
  * atomically (temp file + rename). Reads re-read the file when its mtime or
10
11
  * size changed. A file that cannot be parsed is kept as
11
12
  * `state.json.corrupt-<timestamp>` and a notice is recorded instead of
12
13
  * silently discarding the history.
13
14
  */
15
+ import { randomUUID } from 'node:crypto'
14
16
  import { mkdir, open, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'
15
17
  import { homedir } from 'node:os'
16
18
  import { dirname, isAbsolute, join, resolve } from 'node:path'
@@ -39,7 +41,7 @@ const LOCK_STALE_MS = 15_000
39
41
  const LOCK_TIMEOUT_MS = 10_000
40
42
 
41
43
  function emptyState() {
42
- return { version: STATE_VERSION, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
44
+ return { version: STATE_VERSION, historyIncomplete: false, onboarding: { completedAt: null }, health: null, quota: {}, authFailures: {}, runs: [], dynamicData: { liveBench: null, pricing: null, status: {}, revision: '' }, archivedSpending: sanitizeArchivedSpending(), notices: [], terminalSessions: [] }
43
45
  }
44
46
 
45
47
  function sanitizeNotices(value) {
@@ -55,16 +57,63 @@ function retainRuns(state, maxRuns) {
55
57
  return state
56
58
  }
57
59
 
60
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
61
+ const nonnegative = value => typeof value === 'number' && Number.isFinite(value) && value >= 0
62
+ function invalidArchivedSpending(value) {
63
+ if (value === undefined || value === null) return false // old v1 files omit it
64
+ if (!record(value)) return true
65
+ for (const [field, pattern] of [['days', /^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/u],
66
+ ['months', /^\d{4}-(?:0[1-9]|1[0-2])$/u]]) {
67
+ if (value[field] === undefined) continue
68
+ if (!record(value[field])) return true
69
+ for (const [key, totals] of Object.entries(value[field])) {
70
+ if (!pattern.test(key) || !record(totals)) return true
71
+ if (['costUsd', 'unknown', 'subscriptionUsd', 'subscriptionRuns'].some(name => totals[name] !== undefined
72
+ && (!nonnegative(totals[name]) || (['unknown', 'subscriptionRuns'].includes(name) && !Number.isInteger(totals[name]))))) return true
73
+ }
74
+ }
75
+ return false
76
+ }
77
+
78
+ function invalidRunSpending(run) {
79
+ if (!record(run) || invalidArchivedSpending(run.priorAttemptSpending)) return true
80
+ for (const field of ['packages', 'reviews']) {
81
+ if (run[field] === undefined) continue // missing legacy fields are not fabricated
82
+ if (!Array.isArray(run[field])) return true
83
+ for (const item of run[field]) {
84
+ if (!record(item) || (item.ran !== undefined && typeof item.ran !== 'boolean')) return true
85
+ if (['costUsd', 'referenceCostUsd'].some(name => item[name] !== undefined && item[name] !== null && !nonnegative(item[name]))) return true
86
+ if (item.ran === true || nonnegative(item.costUsd)) {
87
+ const at = item.finishedAt ?? run.createdAt
88
+ if (!nonnegative(at) || !Number.isFinite(new Date(at).getTime())) return true
89
+ }
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
58
95
  function sanitize(value, maxRuns) {
59
- if (!value || typeof value !== 'object' || value.version !== STATE_VERSION) return emptyState()
96
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value.version !== STATE_VERSION
97
+ || !Array.isArray(value.runs)) {
98
+ const error = new Error('模型路由状态版本或结构不受支持;原文件未修改,请先恢复或升级兼容版本。')
99
+ error.code = 'ROUTER_STATE_UNSUPPORTED'
100
+ throw error
101
+ }
102
+ const runs = value.runs.filter(run => run && typeof run.id === 'string')
60
103
  return retainRuns({
61
104
  version: STATE_VERSION,
105
+ // Unlike bounded notices, an incomplete-history marker cannot silently
106
+ // expire. Restoring a verified state file is required to recover budgets.
107
+ historyIncomplete: value.historyIncomplete === true || runs.length !== value.runs.length
108
+ || invalidArchivedSpending(value.archivedSpending) || runs.some(invalidRunSpending)
109
+ || sanitizeNotices(value.notices).some(notice => notice.kind === 'state-corrupt'),
62
110
  onboarding: { completedAt: Number.isFinite(value.onboarding?.completedAt) ? value.onboarding.completedAt : null },
63
111
  health: value.health && Array.isArray(value.health.tools) ? value.health : null,
64
112
  quota: value.quota && typeof value.quota === 'object' && !Array.isArray(value.quota) ? value.quota : {},
65
113
  authFailures: Object.fromEntries(Object.entries(value.authFailures && typeof value.authFailures === 'object' && !Array.isArray(value.authFailures) ? value.authFailures : {})
66
114
  .filter(([, entry]) => Number.isFinite(entry?.at))),
67
- runs: Array.isArray(value.runs) ? value.runs.filter(run => run && typeof run.id === 'string') : [],
115
+ runs,
116
+ dynamicData: value.dynamicData && typeof value.dynamicData === 'object' && !Array.isArray(value.dynamicData) ? value.dynamicData : { liveBench: null, pricing: null, status: {}, revision: '' },
68
117
  archivedSpending: sanitizeArchivedSpending(value.archivedSpending),
69
118
  notices: sanitizeNotices(value.notices),
70
119
  terminalSessions: sanitizeTerminalSessions(value.terminalSessions),
@@ -92,36 +141,66 @@ export function terminalSessionRecord(record) {
92
141
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
93
142
  const stamp = at => new Date(at).toISOString().replace(/[:.]/g, '-')
94
143
 
95
- /** Exclusive lock file; a lock older than `staleMs` (crashed holder) is broken. */
144
+ function ownerPid(raw) {
145
+ try {
146
+ const pid = JSON.parse(raw).pid
147
+ if (Number.isSafeInteger(pid) && pid > 0) return pid
148
+ } catch { /* support the previous pid/timestamp format */ }
149
+ const pid = Number(/^(\d+)\s+\d+\s*$/u.exec(raw)?.[1])
150
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null
151
+ }
152
+
153
+ function ownerIsDead(pid) {
154
+ if (pid === null) return false
155
+ try { process.kill(pid, 0); return false }
156
+ catch (error) { return error?.code === 'ESRCH' }
157
+ }
158
+
159
+ /** Never steal a live or unknown owner's lock, even after the wait timeout. */
96
160
  async function acquireLock(lockFile, { staleMs = LOCK_STALE_MS, timeoutMs = LOCK_TIMEOUT_MS, now = Date.now } = {}) {
97
- const started = now()
161
+ // Elapsed waits must not freeze or jump when wall-clock time changes.
162
+ const started = performance.now()
98
163
  for (let attempt = 0; ; attempt += 1) {
99
164
  try {
100
165
  const handle = await open(lockFile, 'wx')
101
- await handle.writeFile(`${process.pid} ${now()}\n`, 'utf8').catch(() => {})
166
+ const owner = `${JSON.stringify({ pid: process.pid, token: randomUUID(), at: now() })}\n`
167
+ try { await handle.writeFile(owner, 'utf8') }
168
+ catch (error) { await handle.close(); await unlink(lockFile).catch(() => {}); throw error }
102
169
  await handle.close()
103
- return async () => { await unlink(lockFile).catch(() => {}) }
170
+ return async () => {
171
+ if (await readFile(lockFile, 'utf8').catch(() => null) === owner) await unlink(lockFile).catch(() => {})
172
+ }
104
173
  } catch (error) {
105
174
  if (error?.code !== 'EEXIST') throw error
106
175
  const info = await stat(lockFile).catch(() => null)
107
- if (info && now() - info.mtimeMs > staleMs) {
108
- await unlink(lockFile).catch(() => {})
109
- continue
176
+ const owner = await readFile(lockFile, 'utf8').catch(() => null)
177
+ if (info && owner !== null && now() - info.mtimeMs > staleMs && ownerIsDead(ownerPid(owner))) {
178
+ const currentInfo = await stat(lockFile).catch(() => null)
179
+ if (currentInfo?.ino === info.ino && currentInfo?.mtimeMs === info.mtimeMs
180
+ && await readFile(lockFile, 'utf8').catch(() => null) === owner) {
181
+ await unlink(lockFile).catch(() => {})
182
+ continue
183
+ }
110
184
  }
111
- if (now() - started > timeoutMs) {
112
- // A live holder that never releases: break it rather than lose the write.
113
- await unlink(lockFile).catch(() => {})
114
- continue
185
+ if (performance.now() - started >= timeoutMs) {
186
+ const failure = new Error('模型路由状态正在被其他操作使用,保存未完成;请稍后重试。')
187
+ failure.code = 'ROUTER_STATE_LOCK_TIMEOUT'
188
+ throw failure
115
189
  }
116
190
  await sleep(Math.min(5 + attempt * 5, 50) + Math.floor(Math.random() * 10))
117
191
  }
118
192
  }
119
193
  }
120
194
 
121
- export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now } = {}) {
195
+ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUNS, now = Date.now,
196
+ lockTimeoutMs = LOCK_TIMEOUT_MS, lockStaleMs = LOCK_STALE_MS } = {}) {
197
+ if (![lockTimeoutMs, lockStaleMs].every(value => Number.isFinite(value) && value >= 1 && value <= 3_600_000)) {
198
+ throw new RangeError('Lock timing must be between 1 and 3600000 milliseconds')
199
+ }
122
200
  const historyLimit = Number.isInteger(maxRuns) && maxRuns > 0 ? Math.min(maxRuns, MAX_RUNS) : MAX_RUNS
123
201
  let cache = null
124
202
  let cacheKey = ''
203
+ let cacheCorrupt = false
125
204
  let chain = Promise.resolve()
126
205
  let pendingNotices = []
127
206
  const lockFile = `${file}.lock`
@@ -130,43 +209,53 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
130
209
  return info ? `${info.mtimeMs}:${info.size}:${info.ino}` : 'missing'
131
210
  }
132
211
  /** Parse the file; keep an unparseable file as a backup and record a notice. */
133
- const readFresh = async () => {
212
+ const readFresh = async ({ recoverCorrupt = false } = {}) => {
134
213
  let raw
135
214
  try { raw = await readFile(file, 'utf8') }
136
215
  catch (error) {
137
216
  if (error?.code === 'ENOENT') return { state: emptyState(), corrupt: false }
138
217
  throw error
139
218
  }
140
- try { return { state: sanitize(JSON.parse(raw), historyLimit), corrupt: false } }
219
+ let parsed
220
+ try { parsed = JSON.parse(raw) }
141
221
  catch {
222
+ // Readers do not rename files outside the writer lock. Recheck under the
223
+ // lock so a concurrent valid replacement cannot be discarded as corrupt.
224
+ if (!recoverCorrupt) return { state: emptyState(), corrupt: true }
142
225
  const at = now()
143
- const backup = `${file}.corrupt-${stamp(at)}`
144
- let saved = true
145
- try { await rename(file, backup) } catch { saved = false }
226
+ const backup = `${file}.corrupt-${stamp(at)}-${randomUUID()}`
227
+ await rename(file, backup)
146
228
  pendingNotices.push({
147
- kind: 'state-corrupt', at, backup: saved ? backup : null,
148
- message: saved
149
- ? `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`
150
- : '模型路由状态文件已损坏,无法读取,备份失败;已重新开始记录。',
229
+ kind: 'state-corrupt', at, backup,
230
+ message: `模型路由状态文件已损坏,无法读取,已备份为 ${backup} 并重新开始记录。执行历史、评价和订阅冷却信息需要从备份中人工恢复。`,
151
231
  })
152
- return { state: emptyState(), corrupt: true }
232
+ return { state: { ...emptyState(), historyIncomplete: true }, corrupt: true }
153
233
  }
234
+ // Valid JSON with an unknown schema is not corrupt: preserve it exactly,
235
+ // and refuse both reads-as-empty and writes that would downgrade it.
236
+ return { state: sanitize(parsed, historyLimit), corrupt: false }
154
237
  }
155
238
  const load = async () => {
156
239
  const key = await fileKey()
157
240
  if (cache && key === cacheKey) return cache
158
241
  const fresh = await readFresh()
159
242
  cache = fresh.state
160
- cacheKey = fresh.corrupt ? '' : key
243
+ cacheKey = key
244
+ cacheCorrupt = fresh.corrupt
161
245
  return cache
162
246
  }
163
247
  const persist = async state => {
164
248
  await mkdir(dirname(file), { recursive: true })
249
+ const committed = structuredClone(state)
250
+ const serialized = `${JSON.stringify(committed)}\n`
165
251
  const temp = `${file}.${process.pid}.${now()}.${Math.random().toString(36).slice(2, 8)}.tmp`
166
- await writeFile(temp, `${JSON.stringify(state)}\n`, 'utf8')
167
- await rename(temp, file)
168
- cache = state
252
+ try {
253
+ await writeFile(temp, serialized, 'utf8')
254
+ await rename(temp, file)
255
+ } finally { await unlink(temp).catch(() => {}) }
256
+ cache = committed
169
257
  cacheKey = await fileKey()
258
+ cacheCorrupt = false
170
259
  }
171
260
  /**
172
261
  * Serialize read-modify-write updates within this process and, through the
@@ -175,17 +264,26 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
175
264
  const update = change => {
176
265
  const next = chain.then(async () => {
177
266
  await mkdir(dirname(file), { recursive: true })
178
- const release = await acquireLock(lockFile, { now })
267
+ const release = await acquireLock(lockFile, { now, timeoutMs: lockTimeoutMs, staleMs: lockStaleMs })
179
268
  try {
180
269
  cache = null
181
- const state = await load()
182
- if (pendingNotices.length) { state.notices.push(...pendingNotices); pendingNotices = [] }
270
+ const { state } = await readFresh({ recoverCorrupt: true })
271
+ if (pendingNotices.length) state.notices.push(...pendingNotices)
183
272
  const result = await change(state)
273
+ const returned = result === undefined ? undefined : structuredClone(result)
184
274
  retainRuns(state, historyLimit)
185
275
  state.notices = sanitizeNotices(state.notices)
186
276
  state.terminalSessions = sanitizeTerminalSessions(state.terminalSessions)
187
277
  await persist(state)
188
- return result
278
+ pendingNotices = []
279
+ return returned
280
+ } catch (error) {
281
+ // A rejected callback or disk write must not leak uncommitted state
282
+ // into the reader cache, including partial rating/budget changes.
283
+ cache = null
284
+ cacheKey = ''
285
+ cacheCorrupt = false
286
+ throw error
189
287
  } finally {
190
288
  await release()
191
289
  }
@@ -197,9 +295,13 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
197
295
  const read = async () => {
198
296
  await chain
199
297
  const state = await load()
200
- if (pendingNotices.length) {
298
+ if (cacheCorrupt || pendingNotices.length) {
201
299
  const notices = [...pendingNotices]
202
- try { await update(() => {}) } catch { return structuredClone({ ...state, notices: [...state.notices, ...notices] }) }
300
+ try { await update(() => {}) }
301
+ catch {
302
+ return structuredClone({ ...state, notices: [...state.notices, ...notices,
303
+ { kind: 'state-recovery-pending', at: now(), message: '模型路由状态恢复未完成;原文件保留,请稍后重试。' }] })
304
+ }
203
305
  return structuredClone(cache)
204
306
  }
205
307
  return structuredClone(state)
@@ -232,10 +334,10 @@ export function createRouterState({ file = defaultStatePath(), maxRuns = MAX_RUN
232
334
  return entry
233
335
  }),
234
336
  appendRun: run => update(current => { current.runs.push(run); return run }),
235
- updateRun: (id, change) => update(current => {
337
+ updateRun: (id, change) => update(async current => {
236
338
  const run = current.runs.find(item => item.id === id)
237
339
  if (!run) throw new Error(`未找到运行记录 ${id}`)
238
- change(run)
340
+ await change(run)
239
341
  return structuredClone(run)
240
342
  }),
241
343
  /** Remove one notice (for example after the user read the corrupt-file warning). */