@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.
- package/.dsh-plugin/client.js +701 -180
- package/.dsh-plugin/index.mjs +126 -25
- package/.dsh-plugin/shared/adaptive-feedback.mjs +272 -0
- package/.dsh-plugin/shared/assignment-solver.mjs +86 -0
- package/.dsh-plugin/shared/dynamic-data-view.mjs +120 -0
- package/.dsh-plugin/shared/dynamic-data.mjs +372 -0
- package/.dsh-plugin/shared/harness-plan.mjs +5 -2
- package/.dsh-plugin/shared/livebench.mjs +57 -20
- package/.dsh-plugin/shared/model-profiles.mjs +7 -1
- package/.dsh-plugin/shared/official-tools-remote.mjs +3 -1
- package/.dsh-plugin/shared/router-state.mjs +140 -38
- package/.dsh-plugin/shared/router.mjs +115 -152
- package/.dsh-plugin/shared/run-ledger.mjs +37 -11
- package/CHANGELOG.md +29 -0
- package/INSTALLATION_GUIDE.zh.md +21 -15
- package/README.md +14 -6
- package/README.zh.md +14 -6
- package/docs/ROUTING_ADAPTIVE_LEARNING.zh.md +153 -0
- package/docs/ROUTING_DERIVATION_EXPERIMENTS.zh.md +684 -0
- package/docs/ROUTING_LIVEBENCH_DATA_SEARCH.zh.md +74 -0
- package/docs/ROUTING_LIVEBENCH_VALIDATION.zh.md +154 -0
- package/docs/ROUTING_MATURITY.zh.md +76 -0
- package/docs/ROUTING_RESEARCH.zh.md +59 -0
- package/docs/ROUTING_SELFTEST_STEPS.zh.md +192 -0
- package/docs/WORKBENCH_USER_GUIDE.zh.md +25 -3
- package/package.json +19 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
148
|
-
message:
|
|
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 =
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
182
|
-
if (pendingNotices.length)
|
|
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
|
-
|
|
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(() => {}) }
|
|
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). */
|