@sligqoer/dsh-usage-stats 0.2.0 → 0.2.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.
Files changed (51) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/NOTICE +39 -0
  3. package/README.en.md +187 -0
  4. package/README.md +93 -263
  5. package/lib/bridge.js +33 -0
  6. package/lib/client.js +2321 -1579
  7. package/lib/index.js +173 -197
  8. package/lib/lm-fingerprint-engine.js +166 -0
  9. package/lib/lm-fingerprint-source.js +134 -0
  10. package/lib/lm-fingerprint-worker.js +31 -0
  11. package/lib/{model-test-history.js → model-detection-history.js} +23 -30
  12. package/lib/model-detection.js +255 -0
  13. package/lib/quota-controls.js +64 -23
  14. package/lib/supplemental-usage.js +77 -0
  15. package/lib/usage-preferences.js +51 -0
  16. package/locale/en.json +6 -0
  17. package/locale/ja.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +28 -25
  20. package/screenshots/controls.png +0 -0
  21. package/screenshots/detection.png +0 -0
  22. package/screenshots/quotas.png +0 -0
  23. package/screenshots/usage.png +0 -0
  24. package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
  25. package/third_party/brand-icons/LICENSE +59 -0
  26. package/third_party/brand-icons/NOTICE.txt +5 -0
  27. package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
  28. package/third_party/brand-icons/channel-icons/LICENSE +21 -0
  29. package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
  30. package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
  31. package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
  32. package/third_party/brand-icons/react-icons/LICENSE +13 -0
  33. package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
  34. package/third_party/lm-fingerprint/LICENSE +21 -0
  35. package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
  36. package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
  37. package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
  38. package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
  39. package/third_party/lm-fingerprint/core/positional.mjs +64 -0
  40. package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
  41. package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
  42. package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
  43. package/README.zh-CN.md +0 -357
  44. package/lib/fingerprints/KBF-LICENSE.txt +0 -201
  45. package/lib/fingerprints/NOTICE.txt +0 -6
  46. package/lib/fingerprints/kbf-202609.json +0 -1
  47. package/lib/model-capabilities.js +0 -41
  48. package/lib/model-fingerprints.js +0 -146
  49. package/lib/model-match.js +0 -407
  50. package/screenshots/match.png +0 -0
  51. package/screenshots/token-speed.png +0 -0
@@ -1,41 +0,0 @@
1
- /** Seeded, locally graded tasks. Scores describe these tasks only, never model identity. */
2
- import { binomialTail } from './model-fingerprints.js'
3
- export function capabilityTasks(seed) {
4
- let state = seed >>> 0
5
- const rand = max => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state % max }
6
- const tasks = []
7
- const add = (domain, prompt, answer) => tasks.push({ id: `${domain}-${tasks.length}`, domain,
8
- prompt: prompt + '\nReturn only JSON with exactly one key "answer". Do not use tools.',
9
- check(text) { const value = JSON.parse(text.trim()); return Object.keys(value).length === 1 && JSON.stringify(value.answer) === JSON.stringify(answer) } })
10
- for (let i = 0; i < 4; i++) {
11
- const a = rand(17) + 2, b = rand(23) + 1, turns = rand(16) + 30
12
- let x = rand(300), initial = x, sum = 0
13
- for (let j = 0; j < turns; j++) { x = (x * a + b + j) % 997; sum += j % 3 === 0 ? -x : x }
14
- add('code', `Evaluate this JavaScript without executing it. What is sum?\nlet x=${initial}, sum=0; for(let j=0;j<${turns};j++){x=(x*${a}+${b}+j)%997;sum+=j%3===0?-x:x;}`, sum)
15
- const edges = [], costs = Array(11).fill(Infinity); costs[0] = 0
16
- for (let u = 0; u < 10; u++) for (let v = u + 1; v <= Math.min(10, u + 4); v++) edges.push([u, v, rand(31) + 1])
17
- for (let u = 0; u < 10; u++) for (const [from, to, weight] of edges) if (from === u) costs[to] = Math.min(costs[to], costs[from] + weight)
18
- add('reasoning', `Find the minimum total weight of a directed path from node 0 to node 10. Edges [from,to,weight]: ${JSON.stringify(edges)}`, costs[10])
19
- const entries = Array.from({ length: 160 }, (_, n) => ({ key: `record_${n}`, priority: rand(1000), value: rand(100_000) }))
20
- const chosen = rand(150) + 5, key = entries[chosen].key
21
- add('context', `Read the following records. For key ${key}, return the value in the LAST occurrence. Ignore priorities.\n${JSON.stringify(entries.concat({ key, priority: 9999, value: entries[chosen].value + 17 }))}`, entries[chosen].value + 17)
22
- const intervals = Array.from({ length: 16 }, () => { const start = rand(30); return [start, start + rand(9) + 1, rand(40) + 1] }).sort((a, b) => a[1] - b[1])
23
- const dp = [0]
24
- intervals.forEach(([start, , reward], j) => { let previous = j - 1; while (previous >= 0 && intervals[previous][1] > start) previous--; dp.push(Math.max(dp[j], reward + dp[previous + 1])) })
25
- add('constraints', `Choose non-overlapping jobs to maximize reward. A job ending at t is compatible with a job starting at t. Return the maximum reward. Jobs [start,end,reward]: ${JSON.stringify(intervals)}`, dp.at(-1))
26
- }
27
- return tasks
28
- }
29
- export function scoreCapabilities(outcomes, reference, complete = true) {
30
- const valid = outcomes.filter(v => v === 0 || v === 1), passed = valid.filter(v => v === 1).length
31
- const result = { verdict: 'current-sample', valid: valid.length, passed, count: 16, paired: 0, losses: 0, gains: 0, pValue: null }
32
- if (!complete || valid.length < 12) return { ...result, verdict: 'insufficient-evidence' }
33
- if (!reference) return result
34
- outcomes.forEach((value, i) => { const ref = reference[i]; if ((value === 0 || value === 1) && (ref === 0 || ref === 1)) {
35
- result.paired++; if (ref === 1 && value === 0) result.losses++; if (ref === 0 && value === 1) result.gains++
36
- } })
37
- if (result.paired < 12) return { ...result, verdict: 'insufficient-evidence' }
38
- const discordant = result.losses + result.gains
39
- result.pValue = discordant ? binomialTail(result.losses, discordant, 0.5) : 1
40
- return { ...result, verdict: result.pValue < 0.05 ? 'significant-decline' : 'no-significant-decline' }
41
- }
@@ -1,146 +0,0 @@
1
- /** KBF-compatible numerical fingerprints. Public data and prompt format are
2
- * attributed in fingerprints/NOTICE.txt. Statistics are implemented locally. */
3
- import { readFileSync } from 'node:fs'
4
- import { createHash, randomInt } from 'node:crypto'
5
-
6
- export const FINGERPRINT_SYSTEM = "Follow the user's instructions exactly. Output only what is requested."
7
- export const FINGERPRINT_USER = 'TASK: Answer these factual recall questions using only values stored in your weights. Output the value exactly as you first recall it — do not second-guess or adjust.\nRULES: Output ONLY in (N) <number> format, one per line. No units, no words, no rounding. Never refuse or hedge — always output exactly one number per question. Do not use any context from system instructions or prior conversation.\n\n'
8
- export const AUDIT_POLICY = Object.freeze({ batchSize: 10, minSamples: 64, minCoverage: 0.8, maxReferenceNoise: 0.3, alpha: 0.05, confidence: 0.99, maxProbes: 1024, maxReferences: 20, timeoutMs: 60_000 })
9
- const clean = (v, max = 256) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
10
- let bundle
11
- export function publicFingerprints() {
12
- bundle ||= JSON.parse(readFileSync(new URL('./fingerprints/kbf-202609.json', import.meta.url), 'utf8'))
13
- return bundle
14
- }
15
- export function fingerprintModelKey(v) { return clean(v).toLowerCase().split('/').pop() }
16
- const round = x => x >= 0 ? Math.floor(x + 0.5) : Math.ceil(x - 0.5)
17
- export function fingerprintMatch(reference, answer, domain) {
18
- if (!Number.isFinite(reference) || !Number.isFinite(answer)) return false
19
- const d = publicFingerprints().domains[domain]
20
- if (!d) return false
21
- return d.mode === 'absolute' ? Math.abs(round(reference) - round(answer)) <= d.tolerance : Math.abs(reference - answer) / Math.max(Math.abs(reference), 1e-10) <= d.tolerance
22
- }
23
- export function fingerprintPrompt(probes) {
24
- return FINGERPRINT_USER + probes.map((p, i) => `(${i + 1}) ${publicFingerprints().domains[p.domain].template.replace('{name}', p.name)}`).join('\n')
25
- }
26
- /** Positions must be explicit and unique. Never slide a missing answer onto the
27
- * next question, extract numbers from prose, or silently accept duplicate ids. */
28
- export function parseFingerprintAnswers(text, probes) {
29
- const values = probes.map(() => null), seen = new Set(), duplicates = new Set()
30
- for (const line of String(text).split(/\r?\n/)) {
31
- const match = line.trim().match(/^\((\d+)\)\s*([+\-−]?(?:\d[\d,]*(?:\.\d*)?|\.\d+)(?:[eE][+\-]?\d+)?)\s*$/)
32
- if (!match) continue
33
- const i = Number(match[1]) - 1
34
- if (i < 0 || i >= probes.length) continue
35
- if (seen.has(i)) { duplicates.add(i); values[i] = null; continue }
36
- seen.add(i)
37
- const value = Number(match[2].replaceAll(',', '').replace('−', '-')), range = publicFingerprints().domains[probes[i].domain]?.range
38
- if (range && Number.isFinite(value) && value >= range[0] && value <= range[1] && !duplicates.has(i)) values[i] = value
39
- }
40
- return values
41
- }
42
- const logs = [0]
43
- function logFactorial(n) { while (logs.length <= n) logs.push(logs.at(-1) + Math.log(logs.length)); return logs[n] }
44
- function binomialSum(lo, hi, n, p) {
45
- if (lo > hi) return 0
46
- if (p === 0) return lo === 0 ? 1 : 0
47
- if (p === 1) return hi === n ? 1 : 0
48
- const terms = [], base = logFactorial(n)
49
- for (let k = lo; k <= hi; k++) terms.push(base - logFactorial(k) - logFactorial(n - k) + k * Math.log(p) + (n - k) * Math.log1p(-p))
50
- const largest = Math.max(...terms)
51
- return Math.min(1, Math.exp(largest) * terms.reduce((sum, x) => sum + Math.exp(x - largest), 0))
52
- }
53
- export function binomialTail(k, n, p) {
54
- if (!Number.isInteger(k) || !Number.isInteger(n) || k < 0 || k > n || n < 1 || !Number.isFinite(p) || p < 0 || p > 1) throw new Error('Invalid binomial counts')
55
- return binomialSum(k, n, n, p)
56
- }
57
- export function clopperPearsonUpper(k, n) {
58
- if (!Number.isInteger(k) || !Number.isInteger(n) || n < 1 || k < 0 || k > n) throw new Error('Invalid calibration counts')
59
- if (k === n) return 1
60
- let lo = 0, hi = 1
61
- for (let i = 0; i < 60; i++) { const p = (lo + hi) / 2; if (binomialSum(0, k, n, p) > 0.01) lo = p; else hi = p }
62
- return (lo + hi) / 2
63
- }
64
- export function scoreFingerprint(reference, outcomes, complete = true) {
65
- const referenceValues = reference.probes.map(p => p.calibration), calibration = referenceValues.filter(v => v === 0 || v === 1)
66
- const valid = outcomes.filter(v => v === 0 || v === 1), mismatches = valid.filter(v => v === 0).length
67
- const self = reference.calibration || { total: calibration.length, mismatches: calibration.filter(v => v === 0).length }
68
- const count = reference.probes.length, coverage = count ? valid.length / count : 0, selfCoverage = count ? self.total / count : 0
69
- const result = { verdict: 'insufficient-evidence', reason: 'coverage', count, valid: valid.length, mismatches, coverage,
70
- selfTotal: self.total, selfMismatches: self.mismatches, selfCoverage, alpha: AUDIT_POLICY.alpha, confidence: AUDIT_POLICY.confidence, p0: null, pValue: null }
71
- if (!complete) return { ...result, reason: 'unfinished' }
72
- if (count < AUDIT_POLICY.minSamples || valid.length < AUDIT_POLICY.minSamples || self.total < AUDIT_POLICY.minSamples) return { ...result, reason: 'sample-size' }
73
- if (coverage < AUDIT_POLICY.minCoverage || selfCoverage < AUDIT_POLICY.minCoverage) return result
74
- const p0 = clopperPearsonUpper(self.mismatches, self.total), pValue = binomialTail(mismatches, valid.length, p0)
75
- return { ...result, verdict: pValue < AUDIT_POLICY.alpha ? 'significant-difference' : 'no-significant-difference', reason: 'statistical-test', p0, pValue }
76
- }
77
- export function normalizeFingerprint(value, origin = 'imported') {
78
- if (!value || typeof value !== 'object') throw new Error('基线内容不是有效对象。')
79
- const model = clean(value.model || value.reference_model), input = value.probes
80
- if (!model || !Array.isArray(input) || input.length < AUDIT_POLICY.minSamples || input.length > AUDIT_POLICY.maxProbes) throw new Error('基线需要模型名称和 64–1,024 个探针。')
81
- const self = value.target_results?.[model.split('/').pop()], vector = self?.match_vector
82
- const domains = publicFingerprints().domains, seen = new Set()
83
- const probes = input.map((p, i) => {
84
- const domain = clean(p?.domain, 64), name = clean(p?.name, 512), answer = p?.cloze_consensus ?? p?.value, key = domain + '\0' + name
85
- if (!domains[domain] || !name || !Number.isFinite(answer) || seen.has(key)) throw new Error('基线含未知领域、重复探针或无效参考答案。')
86
- seen.add(key)
87
- const range = domains[domain].range
88
- if (answer < range[0] || answer > range[1]) throw new Error('参考答案超出探针允许范围。')
89
- const calibration = p.calibration ?? vector?.[i]
90
- return { domain, name, value: answer, calibration: calibration === 0 || calibration === 1 ? calibration : null }
91
- })
92
- const total = value.calibration?.total ?? self?.total, mismatches = value.calibration?.mismatches ?? self?.hamming
93
- let calibration
94
- if (!probes.some(p => p.calibration !== null)) {
95
- if (!Number.isInteger(total) || total < AUDIT_POLICY.minSamples || total > probes.length || !Number.isInteger(mismatches) || mismatches < 0 || mismatches > total) throw new Error('基线缺少独立自测校准计数。')
96
- calibration = { total, mismatches }
97
- }
98
- const config = value.config || self?.test_config || {}
99
- const temperature = config.temperature ?? config.temp ?? 0
100
- if (temperature !== 0) throw new Error('基线自测温度必须为 0。')
101
- const system = clean(config.system || config.sys || FINGERPRINT_SYSTEM, 512)
102
- if (system !== FINGERPRINT_SYSTEM) throw new Error('基线使用了不同的系统提示词,请重新校准。')
103
- const reasoningEffort = clean(config.reasoningEffort || 'off', 64)
104
- const selection = value.selection && { provider: clean(value.selection.provider), model: clean(value.selection.model), ...(reasoningEffort !== 'off' ? { reasoningEffort } : {}) }
105
- const payload = { version: 2, model, provider: clean(value.provider || value.provider_map?.[model] || selection?.provider || '未标明'),
106
- origin, date: clean(value.date || value.timestamp || new Date().toISOString(), 64), config: { system, temperature: 0, reasoningEffort }, probes,
107
- ...(calibration ? { calibration } : {}), ...(selection?.provider && selection.model ? { selection } : {}) }
108
- // Retain public provenance, never imported endpoint URLs, headers or secrets.
109
- if (origin === 'public') { payload.source = value.source; payload.sha256 = value.sha256 }
110
- if (value.capabilities?.version === 1 && Number.isInteger(value.capabilities.seed) && Array.isArray(value.capabilities.outcomes) && value.capabilities.outcomes.length === 16) {
111
- payload.capabilities = { version: 1, seed: value.capabilities.seed >>> 0, effort: clean(value.capabilities.effort, 64),
112
- outcomes: value.capabilities.outcomes.map(v => v === 0 || v === 1 ? v : null) }
113
- }
114
- payload.id = origin === 'public' ? value.id : 'local/' + createHash('sha256').update(JSON.stringify(payload)).digest('hex').slice(0, 24)
115
- return payload
116
- }
117
- export function fingerprintSummary(ref) {
118
- const scored = scoreFingerprint(ref, [])
119
- return { id: ref.id, model: ref.model, provider: ref.provider, origin: ref.origin, date: ref.date, source: ref.source,
120
- count: ref.probes.length, selfTotal: scored.selfTotal, selfMismatches: scored.selfMismatches,
121
- effort: ref.config.reasoningEffort, hasCapabilities: !!ref.capabilities, subsetAvailable: !ref.calibration }
122
- }
123
- export function subsetFingerprint(ref, count) {
124
- if (count >= ref.probes.length || ref.calibration) return ref
125
- const indices = ref.probes.map((_, i) => i)
126
- for (let i = indices.length - 1; i > 0; i--) { const j = randomInt(i + 1); [indices[i], indices[j]] = [indices[j], indices[i]] }
127
- const selected = indices.slice(0, count).sort((a, b) => a - b)
128
- return { ...ref, probes: selected.map(i => ref.probes[i]) }
129
- }
130
- export function calibrationPool(count) {
131
- const groups = new Map(), seen = new Set()
132
- for (const ref of publicFingerprints().references) for (const p of ref.probes) {
133
- const key = p.domain + '\0' + p.name
134
- if (seen.has(key)) continue
135
- seen.add(key)
136
- if (!groups.has(p.domain)) groups.set(p.domain, [])
137
- groups.get(p.domain).push({ domain: p.domain, name: p.name })
138
- }
139
- const pool = []
140
- while (pool.length < count && groups.size) for (const [domain, entries] of groups) {
141
- if (pool.length >= count) break
142
- if (!entries.length) { groups.delete(domain); continue }
143
- pool.push(entries.splice(randomInt(entries.length), 1)[0])
144
- }
145
- return pool
146
- }
@@ -1,407 +0,0 @@
1
- /** Local, bounded evidence analysis. Response-declared names are observations,
2
- * never proof of a provider's real model identity. No conversation text is kept. */
3
- import { randomUUID, randomInt } from 'node:crypto'
4
- import { readFileSync } from 'node:fs'
5
- import { mkdir, writeFile, rename } from 'node:fs/promises'
6
- import { dirname } from 'node:path'
7
- import { publicFingerprints, normalizeFingerprint, fingerprintSummary, fingerprintModelKey, subsetFingerprint,
8
- calibrationPool, fingerprintPrompt, parseFingerprintAnswers, fingerprintMatch, scoreFingerprint, FINGERPRINT_SYSTEM, AUDIT_POLICY } from './model-fingerprints.js'
9
- import { capabilityTasks, scoreCapabilities } from './model-capabilities.js'
10
-
11
- const LIMIT = 30
12
- const usageKeys = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
13
- const safeText = (value, max = 256) => typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
14
- const nonnegative = value => Number.isFinite(value) && value >= 0 ? value : 0
15
- export function safeUsage(value) {
16
- return Object.fromEntries(usageKeys.map(key => [key, nonnegative(value?.[key])]))
17
- }
18
- export function modelSelection(value) {
19
- const provider = safeText(value?.provider), model = safeText(value?.model)
20
- if (!provider || !model) return null
21
- const reasoningEffort = safeText(value.reasoningEffort, 64)
22
- return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }
23
- }
24
- function record(value) {
25
- const selection = modelSelection(value)
26
- if (!selection) return null
27
- const responseModel = safeText(value.responseModel)
28
- const responseEffort = safeText(value.responseEffort, 64)
29
- const stop = ['stop', 'max-tokens', 'tool-calls', 'error', 'aborted', 'unknown'].includes(value.stop) ? value.stop : 'unknown'
30
- return { ...selection, time: nonnegative(value.time), requestHeader: value.requestHeader === true,
31
- ...(responseModel ? { responseModel } : {}), ...(responseEffort ? { responseEffort } : {}),
32
- stop, usage: safeUsage(value.usage) }
33
- }
34
- export function normalizeEvidence(value) {
35
- if (!value || value.version !== 1) return null
36
- return { version: 1, latestRequest: modelSelection(value.latestRequest),
37
- records: (Array.isArray(value.records) ? value.records : []).slice(-LIMIT).map(record).filter(Boolean) }
38
- }
39
-
40
- export function captureModelEvidence(events, inheritedEventCount = 0) {
41
- let latestRequest = null
42
- const records = []
43
- const inherited = Math.max(0, Math.floor(Number(inheritedEventCount) || 0))
44
- for (const [index, event] of (events || []).entries()) {
45
- if (event?.type === 'request/header') latestRequest = modelSelection(event.data?.header?.config) || latestRequest
46
- if (event?.type !== 'assistant/message' || index < inherited) continue
47
- const source = event.data?.message?.source
48
- const selection = modelSelection(source)
49
- if (!selection) continue
50
- const paired = latestRequest?.provider === selection.provider && latestRequest?.model === selection.model
51
- const response = source.replayState?.response
52
- records.push(record({ ...(paired ? latestRequest : selection), time: event.time,
53
- requestHeader: paired, responseModel: response?.responseModel,
54
- responseEffort: response?.providerThinkingLevel,
55
- stop: response?.stopReason === 'length' ? 'max-tokens' : response?.stopReason === 'toolUse' ? 'tool-calls' : response?.stopReason,
56
- usage: event.data.usage }))
57
- }
58
- return { version: 1, latestRequest, records: records.slice(-LIMIT) }
59
- }
60
-
61
- /** Only obvious provider-prefix/date-snapshot variations are comparable. No
62
- * aliases used by usage charts are used to decide identity or compatibility. */
63
- function comparableModel(value) {
64
- return safeText(value).toLowerCase().split('/').pop().replace(/[-_]\d{4}[-_]\d{2}[-_]\d{2}$/, '')
65
- }
66
- export function analyzeModelEvidence(value, requested) {
67
- const evidence = normalizeEvidence(value)
68
- const selection = modelSelection(requested) || evidence?.latestRequest || evidence?.records.at(-1) || null
69
- const samples = (evidence?.records || []).filter(item => selection && item.provider === selection.provider && item.model === selection.model &&
70
- (!selection.reasoningEffort || item.reasoningEffort === selection.reasoningEffort))
71
- let exact = 0, compatible = 0, different = 0, missing = 0
72
- for (const item of samples) {
73
- if (!item.responseModel) missing++
74
- else if (item.responseModel === item.model) exact++
75
- else if (comparableModel(item.responseModel) === comparableModel(item.model)) compatible++
76
- else different++
77
- }
78
- // Metadata describes declarations only. Identity remains untested even when
79
- // every response repeats the requested name or every name differs.
80
- return { selection: modelSelection(selection), verdict: 'insufficient-evidence', strength: 'weak',
81
- declarationStatus: different ? 'name-difference' : exact + compatible ? 'names-consistent' : 'not-reported',
82
- counts: { samples: samples.length, exact, compatible, different, missing },
83
- // Newest first, deliberately excludes replay blocks, ids, headers and text.
84
- records: samples.slice(-10).reverse(),
85
- missingReasoningUsage: samples.filter(item => item.reasoningEffort && item.usage.reasoningTokens === 0).length }
86
- }
87
-
88
- export class ModelMatchError extends Error {}
89
- export const PROBE_PLAN = Object.freeze({ maxTokens: 800, capabilityMaxTokens: 1024, timeoutMs: AUDIT_POLICY.timeoutMs })
90
- function abortable(promise, signal) {
91
- return new Promise((resolve, reject) => {
92
- const abort = () => reject(new Error('aborted'))
93
- if (signal.aborted) { abort(); return }
94
- signal.addEventListener('abort', abort, { once: true })
95
- Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
96
- })
97
- }
98
-
99
- const batches = values => Array.from({ length: Math.ceil(values.length / 10) }, (_, i) => values.slice(i * 10, i * 10 + 10))
100
- const sumTokens = usage => usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
101
- /** Answer agreement, not the statistical test's p-value or identity probability. */
102
- export function modelTestScore(job) {
103
- const identity = job?.identity
104
- if (job?.status !== 'complete' || job.mode !== 'audit' || !['significant-difference', 'no-significant-difference'].includes(identity?.verdict)) return null
105
- const { count, valid, mismatches } = identity
106
- if (![count, valid, mismatches].every(Number.isInteger) || valid < AUDIT_POLICY.minSamples || valid > count || mismatches < 0 || mismatches > valid || valid / count < AUDIT_POLICY.minCoverage) return null
107
- return { value: Math.round((valid - mismatches) * 1000 / valid) / 10, matched: valid - mismatches, valid, count, method: 'answer-agreement' }
108
- }
109
- export function summarizeModelTest(job) {
110
- if (job.status === 'running') return { verdict: 'running', reason: 'running' }
111
- if (job.mode === 'calibrate' && job.status === 'complete' && job.baselineId) return { verdict: 'reference-created', reason: 'reference-created' }
112
- if (job.status === 'complete') {
113
- if (job.identity?.verdict === 'significant-difference') return { verdict: 'suspected-mismatch', reason: 'behavior-difference' }
114
- if (job.capability?.verdict === 'significant-decline') return { verdict: 'suspected-decline', reason: 'capability-decline' }
115
- if (job.identity?.verdict === 'no-significant-difference') return { verdict: 'no-anomaly', reason: 'behavior-consistent' }
116
- if (job.mode === 'capability' && job.capability?.verdict === 'no-significant-decline') return { verdict: 'no-decline', reason: 'capability-consistent' }
117
- if (job.mode === 'capability' && job.capability?.verdict === 'current-sample') return { verdict: 'capability-complete', reason: 'no-capability-reference' }
118
- }
119
- const results = job.results || []
120
- const invalid = Object.fromEntries(['incomplete', 'unparseable', 'unavailable', 'timeout', 'parameters-ignored'].map(code => [code, results.filter(r => r.outcome === code).length]))
121
- const reason = job.status === 'cancelled' ? 'cancelled' : job.status === 'interrupted' ? 'interrupted' : job.status === 'budget-exhausted' ? 'budget' :
122
- invalid['parameters-ignored'] ? 'parameters' : invalid.timeout ? 'timeout' : invalid.unavailable ? 'request-failed' : invalid.incomplete ? 'truncated' :
123
- invalid.unparseable ? 'format' : job.identity?.reason === 'coverage' || job.identity?.reason === 'sample-size' ? 'coverage' : job.mode === 'calibrate' ? 'reference-failed' : 'unfinished'
124
- return { verdict: 'inconclusive', reason, invalid }
125
- }
126
- const jobView = job => ({ id: job.id, sessionId: job.sessionId, selection: job.selection, testSelection: job.testSelection, capabilitySelection: job.capabilitySelection,
127
- mode: job.mode, status: job.status, phase: job.phase, startedAt: job.startedAt, finishedAt: job.finishedAt, progress: job.results.length,
128
- plan: job.plan, reference: job.reference && fingerprintSummary(job.reference), baselineId: job.baselineId || null,
129
- results: job.results.map(({ time, selection, phase, outcome, stop, usage, usageReported, valid, mismatches, ttftMs, decodeMs }) => ({ time, selection, phase, outcome, stop, usage, usageReported, valid, mismatches, ttftMs, decodeMs })),
130
- usage: safeUsage(job.usage), usageReported: job.results.some(r => r.usageReported),
131
- usageIncomplete: job.status === 'running' || job.results.some(r => !r.usageReported),
132
- identity: job.identity || null, capability: job.capability || null, message: job.message || '', conclusion: summarizeModelTest(job), score: modelTestScore(job), usageSaved: job.usageSaved === true,
133
- channel: { ttftMs: job.results.some(r => r.ttftMs !== null) ? job.results.filter(r => r.ttftMs !== null).reduce((n, r) => n + r.ttftMs, 0) / job.results.filter(r => r.ttftMs !== null).length : null,
134
- tps: job.results.some(r => r.decodeMs > 0 && r.usage.outputTokens > 0) ? job.results.filter(r => r.decodeMs > 0 && r.usage.outputTokens > 0).reduce((n, r) => n + r.usage.outputTokens, 0) * 1000 / job.results.filter(r => r.decodeMs > 0 && r.usage.outputTokens > 0).reduce((n, r) => n + r.decodeMs, 0) : null } })
135
-
136
- /** No inference happens in catalog, import, export or plan. A one-use quote
137
- * freezes reference, parameters and maximum workload before the manual start. */
138
- export function createModelMatchRunner(ctx, options = {}) {
139
- const jobs = new Map(), quotes = new Map(), references = new Map()
140
- let disposed = false, loaded = false, storageWarning = '', saveQueue = Promise.resolve()
141
- async function checkpoint(job, patch = {}) {
142
- if (!options.onUpdate) return
143
- try { await options.onUpdate({ ...jobView({ ...job, ...patch }), usageSaved: true }); job.usageSaved = true }
144
- catch { job.usageSaved = false; job.halt = true; job.message = '检测用量保存失败,已停止后续请求;请检查本地存储。' }
145
- }
146
- function load() {
147
- if (loaded) return
148
- loaded = true
149
- for (const raw of publicFingerprints().references) { const ref = normalizeFingerprint(raw, 'public'); references.set(ref.id, ref) }
150
- if (!options.path) return
151
- try {
152
- const data = JSON.parse(readFileSync(options.path, 'utf8'))
153
- for (const raw of (data.references || []).slice(0, AUDIT_POLICY.maxReferences)) {
154
- try { const ref = normalizeFingerprint(raw, raw.origin === 'calibrated' ? 'calibrated' : 'imported'); references.set(ref.id, ref) } catch { storageWarning = '部分本地基线无效,请重新导入或校准。' }
155
- }
156
- } catch (error) { if (error.code !== 'ENOENT') storageWarning = '本地基线文件无法读取,请检查文件后重新加载插件。' }
157
- }
158
- function save() {
159
- if (!options.path) return Promise.resolve()
160
- const document = JSON.stringify({ version: 2, references: [...references.values()].filter(r => r.origin !== 'public') })
161
- const next = saveQueue.catch(() => {}).then(async () => {
162
- await mkdir(dirname(options.path), { recursive: true })
163
- const temporary = options.path + '.tmp'
164
- await writeFile(temporary, document, { mode: 0o600 })
165
- await rename(temporary, options.path)
166
- })
167
- saveQueue = next
168
- return next
169
- }
170
- function getReference(id) { load(); const ref = references.get(id); if (!ref) throw new ModelMatchError('参考基线已不可用,请重新选择。'); return ref }
171
- function ensureLocalCapacity() { if ([...references.values()].filter(r => r.origin !== 'public').length >= AUDIT_POLICY.maxReferences) throw new ModelMatchError('本地基线已达 20 份,请先移除旧基线。') }
172
- async function resolveTestSelection(selection, effort) {
173
- const llm = ctx.get('llm')
174
- if (typeof llm?.stream !== 'function' || typeof llm?.resolveModelInfo !== 'function') throw new ModelMatchError('当前宿主不支持模型参数校验,请更新 DSH。')
175
- let info
176
- try { info = await llm.resolveModelInfo(selection.provider, selection.model, AbortSignal.timeout(10_000)) }
177
- catch { throw new ModelMatchError('模型信息无法读取,请检查供应商配置后重试。') }
178
- const efforts = info.reasoning?.efforts?.map(e => e.id)
179
- if (effort === 'off') {
180
- if (efforts && !efforts.includes('off')) throw new ModelMatchError('该模型无法关闭推理,不能使用关闭推理的公开基线。请建立相同推理强度的本地参考。')
181
- return { provider: selection.provider, model: selection.model, ...(efforts ? { reasoningEffort: 'off' } : {}) }
182
- }
183
- const actual = effort || info.reasoning?.defaultEffort
184
- if (actual && !efforts?.includes(actual)) throw new ModelMatchError('参考与目标的推理强度不兼容,请重新校准。')
185
- return { provider: selection.provider, model: selection.model, ...(actual ? { reasoningEffort: actual } : {}) }
186
- }
187
- async function request(job, task, phase, config) {
188
- const timeout = new AbortController(), timer = setTimeout(() => timeout.abort(), options.timeoutMs || PROBE_PLAN.timeoutMs)
189
- timer.unref?.()
190
- const signal = AbortSignal.any([job.controller.signal, timeout.signal])
191
- const item = { time: 0, selection: modelSelection(config.selection), phase, outcome: 'unavailable', stop: 'unknown', usage: safeUsage(null), usageReported: false, valid: 0, mismatches: 0, ttftMs: null, decodeMs: null }
192
- let iterator, text = '', firstToken = 0, finish, values = task.probes?.map(() => null)
193
- const started = Date.now()
194
- try {
195
- iterator = ctx.get('llm').stream({ ...config.selection, temperature: config.temperature, maxTokens: task.probes ? task.probes.length * 80 : PROBE_PLAN.capabilityMaxTokens,
196
- messages: [{ role: 'system', content: [{ type: 'text', text: FINGERPRINT_SYSTEM }] }, { role: 'user', content: [{ type: 'text', text: task.prompt }] }], signal })[Symbol.asyncIterator]()
197
- for (;;) {
198
- const next = await abortable(iterator.next(), signal)
199
- if (signal.aborted) throw new Error('aborted')
200
- if (next.done) break
201
- const chunk = next.value
202
- if (chunk?.type === 'text-delta') {
203
- if (chunk.text && !firstToken) firstToken = Date.now()
204
- text += String(chunk.text || '')
205
- if (text.length > 32_768) { item.stop = 'max-tokens'; break }
206
- }
207
- if (chunk?.type === 'usage') { item.usage = safeUsage(chunk.usage); item.usageReported = usageKeys.some(k => Number.isFinite(chunk.usage?.[k])) }
208
- if (chunk?.type === 'finish') { finish = chunk; break }
209
- }
210
- if (finish) item.stop = record({ ...job.selection, stop: finish.reason?.kind })?.stop || 'unknown'
211
- if (item.stop === 'stop') {
212
- // Public KBF references suppress reasoning. A relay reporting reasoning
213
- // despite off invalidates comparability, rather than receiving a pass.
214
- if ((config.noReasoning || config.selection.reasoningEffort === 'off') && item.usage.reasoningTokens > 0) item.outcome = 'parameters-ignored'
215
- else if (task.probes) {
216
- values = parseFingerprintAnswers(text, task.probes)
217
- item.valid = values.filter(Number.isFinite).length
218
- item.mismatches = task.probes.filter((p, i) => Number.isFinite(p.value) && Number.isFinite(values[i]) && !fingerprintMatch(p.value, values[i], p.domain)).length
219
- item.outcome = item.valid ? 'scored' : 'unparseable'
220
- } else {
221
- try { item.outcome = task.check(text) ? 'pass' : 'fail' } catch { item.outcome = 'unparseable' }
222
- item.valid = ['pass', 'fail'].includes(item.outcome) ? 1 : 0
223
- }
224
- if (firstToken) { item.ttftMs = firstToken - started; item.decodeMs = Date.now() - firstToken }
225
- } else item.outcome = item.stop === 'max-tokens' ? 'incomplete' : 'unavailable'
226
- } catch {
227
- item.outcome = job.controller.signal.aborted ? 'cancelled' : timeout.signal.aborted ? 'timeout' : 'unavailable'
228
- item.stop = signal.aborted ? 'aborted' : 'error'
229
- } finally {
230
- clearTimeout(timer); timeout.abort()
231
- try { Promise.resolve(iterator?.return?.()).catch(() => {}) } catch { /* broken adapters cannot block cancellation */ }
232
- text = ''
233
- }
234
- job.results.push(item)
235
- item.time = Date.now()
236
- for (const key of usageKeys) job.usage[key] += item.usage[key]
237
- if (['unavailable', 'timeout', 'parameters-ignored'].includes(item.outcome)) { job.halt = true; job.message = item.outcome === 'parameters-ignored' ? '通道未遵守关闭推理的测试参数,请重新校准。' : '请求未完成,已停止后续请求;检查通道后可重新测试。' }
238
- if (sumTokens(job.usage) >= job.plan.budgetTokens) { job.halt = true; job.budgetExhausted = true; job.message = '已达到已上报 Token 预算,后续请求已停止。' }
239
- await checkpoint(job)
240
- return { values: task.probes ? values : undefined, outcome: item.outcome, score: item.outcome === 'pass' ? 1 : item.outcome === 'fail' ? 0 : null }
241
- }
242
- const stopped = job => disposed || job.halt || job.controller.signal.aborted
243
- async function runFingerprints(job, probes, phase, temperature) {
244
- const values = []
245
- job.phase = phase
246
- for (const batch of batches(probes)) {
247
- if (stopped(job)) break
248
- const response = await request(job, { probes: batch, prompt: fingerprintPrompt(batch) }, phase, { selection: job.testSelection, temperature, noReasoning: job.reference?.config.reasoningEffort === 'off' || (job.mode === 'calibrate' && !job.testSelection.reasoningEffort) })
249
- values.push(...(response.values || batch.map(() => null)))
250
- }
251
- while (values.length < probes.length) values.push(null)
252
- return values
253
- }
254
- async function runCapabilities(job, seed, selection) {
255
- job.phase = 'capabilities'
256
- const outcomes = []
257
- for (const task of capabilityTasks(seed)) { if (stopped(job)) break; outcomes.push((await request(job, task, 'capabilities', { selection, temperature: 0 })).score) }
258
- while (outcomes.length < 16) outcomes.push(null)
259
- return outcomes
260
- }
261
- async function run(job, quote) {
262
- await checkpoint(job)
263
- let referenceToSave
264
- if (job.mode === 'calibrate') {
265
- const passes = []
266
- for (const [i, temp] of [0, 0.7, 0.7].entries()) { if (stopped(job)) break; passes.push(await runFingerprints(job, quote.pool, 'consensus-' + (i + 1), temp)) }
267
- if (!stopped(job) && passes.length === 3) {
268
- const stable = quote.pool.flatMap((p, i) => passes.every(v => Number.isFinite(v[i]) && fingerprintMatch(passes[0][i], v[i], p.domain)) ? [{ ...p, value: passes[0][i] }] : [])
269
- if (stable.length < AUDIT_POLICY.minSamples) { job.halt = true; job.message = '稳定探针少于 64 个,未保存基线;可提高校准题量后重试。' }
270
- else {
271
- const holdout = await runFingerprints(job, stable, 'calibration', 0)
272
- const probes = stable.map((p, i) => ({ ...p, calibration: Number.isFinite(holdout[i]) ? (fingerprintMatch(p.value, holdout[i], p.domain) ? 1 : 0) : null }))
273
- referenceToSave = normalizeFingerprint({ model: quote.claimedModel, provider: job.selection.provider, date: new Date().toISOString(), selection: job.testSelection,
274
- config: { system: FINGERPRINT_SYSTEM, temperature: 0, reasoningEffort: job.testSelection.reasoningEffort || 'off' }, probes }, 'calibrated')
275
- const calibrationCheck = scoreFingerprint(referenceToSave, probes.map(p => p.calibration))
276
- if (calibrationCheck.verdict === 'insufficient-evidence' || calibrationCheck.p0 > AUDIT_POLICY.maxReferenceNoise) { job.halt = true; job.message = '参考自测覆盖不足或波动过大,未保存基线。' }
277
- }
278
- }
279
- } else if (job.mode === 'audit') {
280
- const values = await runFingerprints(job, job.reference.probes, 'fingerprint', 0)
281
- const outcomes = job.reference.probes.map((p, i) => Number.isFinite(values[i]) ? (fingerprintMatch(p.value, values[i], p.domain) ? 1 : 0) : null)
282
- job.identity = scoreFingerprint(job.reference, outcomes, !stopped(job))
283
- }
284
- if (!stopped(job) && (job.mode === 'capability' || quote.includeCapabilities)) {
285
- const seed = quote.capabilitySeed, outcomes = await runCapabilities(job, seed, quote.capabilitySelection)
286
- const reference = job.reference?.capabilities
287
- job.capability = scoreCapabilities(outcomes, reference?.outcomes, !stopped(job))
288
- if (referenceToSave && outcomes.filter(v => v === 0 || v === 1).length >= 12) referenceToSave.capabilities = { version: 1, seed, effort: quote.capabilitySelection.reasoningEffort || '', outcomes }
289
- }
290
- let status = job.controller.signal.aborted || disposed ? 'cancelled' : job.budgetExhausted ? 'budget-exhausted' : job.halt ? 'failed' : 'complete'
291
- if (status === 'complete' && referenceToSave) {
292
- ensureLocalCapacity()
293
- const normalized = normalizeFingerprint(referenceToSave, 'calibrated')
294
- references.set(normalized.id, normalized)
295
- try {
296
- await save()
297
- if (disposed || job.controller.signal.aborted) { references.delete(normalized.id); await save(); status = 'cancelled' }
298
- else { job.reference = normalized; job.baselineId = normalized.id; job.identity = scoreFingerprint(normalized, normalized.probes.map(p => p.calibration)); job.message = '参考基线已保存,可用于同模型的后续检测。' }
299
- }
300
- catch { references.delete(normalized.id); status = 'failed'; job.message = '基线保存失败,请检查本地存储后重新校准。' }
301
- }
302
- if (status !== 'complete') {
303
- if (job.identity) job.identity = { ...job.identity, verdict: 'insufficient-evidence', reason: 'unfinished', pValue: null }
304
- if (job.capability) job.capability = { ...job.capability, verdict: 'insufficient-evidence', pValue: null }
305
- }
306
- job.finishedAt = Date.now()
307
- await checkpoint(job, { status })
308
- if ((disposed || job.controller.signal.aborted) && status !== 'cancelled') {
309
- status = 'cancelled'
310
- if (job.baselineId) { references.delete(job.baselineId); job.baselineId = null; await save() }
311
- if (job.identity) job.identity = { ...job.identity, verdict: 'insufficient-evidence', reason: 'unfinished', pValue: null }
312
- if (job.capability) job.capability = { ...job.capability, verdict: 'insufficient-evidence', pValue: null }
313
- await checkpoint(job, { status })
314
- }
315
- if (options.onUpdate && job.usageSaved === false && status === 'complete') {
316
- status = 'failed'
317
- if (job.identity) job.identity = { ...job.identity, verdict: 'insufficient-evidence', reason: 'unfinished', pValue: null }
318
- if (job.capability) job.capability = { ...job.capability, verdict: 'insufficient-evidence', pValue: null }
319
- }
320
- job.status = status
321
- }
322
- return {
323
- catalog() { load(); return { references: [...references.values()].map(fingerprintSummary), policy: AUDIT_POLICY, storageWarning } },
324
- exportReference(id) { return getReference(id) },
325
- async importReference(raw) {
326
- load()
327
- let ref
328
- try { ref = normalizeFingerprint(raw) } catch (error) { throw new ModelMatchError(error.message) }
329
- if (!references.has(ref.id)) ensureLocalCapacity()
330
- const previous = references.get(ref.id); references.set(ref.id, ref)
331
- try { await save() } catch { if (previous) references.set(ref.id, previous); else references.delete(ref.id); throw new ModelMatchError('基线保存失败,请检查本地存储。') }
332
- return fingerprintSummary(ref)
333
- },
334
- async removeReference(id) {
335
- const ref = getReference(id)
336
- if (ref.origin === 'public') throw new ModelMatchError('公开参考随插件提供,无法移除。')
337
- if ([...jobs.values()].some(j => j.status === 'running' && j.reference?.id === id)) throw new ModelMatchError('该基线正在使用,请先取消测试。')
338
- references.delete(id)
339
- try { await save() } catch { references.set(id, ref); throw new ModelMatchError('基线移除失败,请重试。') }
340
- },
341
- async plan(body) {
342
- if (disposed) throw new ModelMatchError('匹配服务已停止。')
343
- load()
344
- const mode = ['audit', 'calibrate', 'capability'].includes(body?.mode) ? body.mode : 'audit'
345
- const selection = modelSelection(body?.selection), sessionId = safeText(body?.sessionId)
346
- if (!selection || !sessionId) throw new ModelMatchError('请先选择当前会话与模型。')
347
- let reference, pool, count = 0, includeCapabilities = mode === 'calibrate' || body.includeCapabilities === true
348
- let testSelection, capabilitySelection, capabilitySeed = randomInt(0x100000000), claimedModel = selection.model
349
- if (mode === 'calibrate') {
350
- ensureLocalCapacity()
351
- if (body.ackReference !== true) throw new ModelMatchError('请确认所选通道可作为可信参考。')
352
- claimedModel = safeText(body.claimedModel)
353
- if (!claimedModel) throw new ModelMatchError('请选择该参考对应的目标模型。')
354
- count = body.probeCount === 256 ? 256 : 128; pool = calibrationPool(count)
355
- testSelection = await resolveTestSelection(selection, selection.reasoningEffort || 'off')
356
- } else if (mode === 'audit' || body.referenceId) {
357
- reference = getReference(body.referenceId)
358
- if (fingerprintModelKey(reference.model) !== fingerprintModelKey(selection.model)) throw new ModelMatchError('参考基线与当前模型不同,请选择对应基线或建立本地参考。')
359
- if (mode === 'audit') {
360
- const selfCheck = scoreFingerprint(reference, reference.probes.map(() => 1))
361
- if (selfCheck.verdict === 'insufficient-evidence' || selfCheck.p0 > AUDIT_POLICY.maxReferenceNoise) throw new ModelMatchError('参考自测覆盖不足或波动过大,请重新校准后检测。')
362
- }
363
- count = body.probeCount === 128 && !reference.calibration ? Math.min(128, reference.probes.length) : reference.probes.length
364
- testSelection = await resolveTestSelection(selection, mode === 'capability' ? selection.reasoningEffort || '' : reference.config.reasoningEffort)
365
- if (reference.capabilities) capabilitySeed = reference.capabilities.seed
366
- }
367
- testSelection ||= await resolveTestSelection(selection, selection.reasoningEffort || (mode === 'capability' ? '' : 'off'))
368
- if (mode === 'capability' || includeCapabilities) {
369
- capabilitySelection = await resolveTestSelection(selection, selection.reasoningEffort || '')
370
- if (reference?.capabilities && reference.capabilities.effort !== (capabilitySelection.reasoningEffort || '')) throw new ModelMatchError('能力基线的推理强度与当前模型不同,请选择相同档位或重新校准。')
371
- }
372
- const requests = (mode === 'calibrate' ? 4 : mode === 'audit' ? 1 : 0) * Math.ceil(count / 10) + (mode === 'capability' || includeCapabilities ? 16 : 0)
373
- const maxOutputTokens = (mode === 'calibrate' ? 4 : mode === 'audit' ? 1 : 0) * count * 80 + (mode === 'capability' || includeCapabilities ? 16 * 1024 : 0)
374
- const fingerprintChars = mode === 'capability' ? 0 : batches(pool || reference.probes.slice(0, count)).reduce((n, batch) => n + fingerprintPrompt(batch).length + FINGERPRINT_SYSTEM.length, 0) * (mode === 'calibrate' ? 4 : 1)
375
- const capabilityChars = mode === 'capability' || includeCapabilities ? capabilityTasks(capabilitySeed).reduce((n, task) => n + task.prompt.length + FINGERPRINT_SYSTEM.length, 0) : 0
376
- if (body.budgetTokens !== undefined && (!Number.isInteger(body.budgetTokens) || body.budgetTokens < 1024 || body.budgetTokens > 500_000)) throw new ModelMatchError('Token 预算应为 1,024–500,000 的整数。')
377
- const budgetTokens = body.budgetTokens ?? 120_000
378
- const plan = { requests, probes: count, maxOutputTokens, estimatedInputTokens: Math.ceil((fingerprintChars + capabilityChars) / 3), budgetTokens,
379
- batchSize: 10, temperature: 0, timeoutMs: PROBE_PLAN.timeoutMs, capabilities: mode === 'capability' || includeCapabilities, capabilityTasks: mode === 'capability' || includeCapabilities ? 16 : 0 }
380
- while (quotes.size >= 10) quotes.delete(quotes.keys().next().value)
381
- const id = randomUUID()
382
- quotes.set(id, { id, mode, selection, sessionId, reference, pool, count, includeCapabilities, testSelection, capabilitySelection, capabilitySeed, claimedModel, plan, createdAt: Date.now() })
383
- return { id, mode, selection, testSelection, capabilitySelection, reference: reference && fingerprintSummary(reference), claimedModel, plan }
384
- },
385
- start(body) {
386
- if (disposed) throw new ModelMatchError('匹配服务已停止。')
387
- if (body?.ackCost !== true) throw new ModelMatchError('请确认测试会消耗 Token 后再开始。')
388
- const quote = quotes.get(body.planId)
389
- if (!quote || Date.now() - quote.createdAt > 5 * 60_000) throw new ModelMatchError('测试预算已过期,请刷新预算后开始。')
390
- if (body.sessionId !== quote.sessionId) throw new ModelMatchError('当前会话已变化,请重新生成预算。')
391
- if ([...jobs.values()].some(j => j.status === 'running')) throw new ModelMatchError('已有测试正在运行,请先等待或取消。')
392
- if (quote.reference && references.get(quote.reference.id) !== quote.reference) throw new ModelMatchError('参考基线已变化,请刷新预算。')
393
- quotes.delete(quote.id)
394
- while (jobs.size >= 10) jobs.delete(jobs.keys().next().value)
395
- const job = { id: randomUUID(), sessionId: quote.sessionId, selection: quote.selection, testSelection: quote.testSelection, mode: quote.mode,
396
- capabilitySelection: quote.capabilitySelection,
397
- reference: quote.reference && subsetFingerprint(quote.reference, quote.count), plan: quote.plan, status: 'running', phase: 'queued',
398
- startedAt: Date.now(), finishedAt: 0, controller: new AbortController(), results: [], usage: safeUsage(null) }
399
- jobs.set(job.id, job)
400
- queueMicrotask(() => { void run(job, quote).catch(async () => { job.status = 'failed'; job.message = '测试未完成,请检查通道或重新生成预算。'; job.finishedAt = Date.now(); await checkpoint(job) }) })
401
- return jobView(job)
402
- },
403
- status(id, sessionId) { const job = id ? jobs.get(id) : [...jobs.values()].reverse().find(j => !sessionId || j.sessionId === sessionId); if (id && !job) throw new ModelMatchError('测试记录已过期。'); return job ? jobView(job) : null },
404
- cancel(id) { const job = jobs.get(id); if (!job) throw new ModelMatchError('测试记录已过期。'); if (job.status === 'running') job.controller.abort(); return jobView(job) },
405
- dispose() { disposed = true; quotes.clear(); for (const job of jobs.values()) job.controller.abort() },
406
- }
407
- }
Binary file
Binary file