@sligqoer/dsh-usage-stats 0.1.2 → 0.2.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +135 -1
  2. package/NOTICE +39 -0
  3. package/README.en.md +187 -0
  4. package/README.md +93 -224
  5. package/lib/bridge.js +33 -0
  6. package/lib/client.js +2587 -1159
  7. package/lib/index.js +420 -150
  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-detection-history.js +86 -0
  12. package/lib/model-detection.js +255 -0
  13. package/lib/provider-quotas.js +26 -5
  14. package/lib/quota-controls.js +112 -30
  15. package/lib/supplemental-usage.js +77 -0
  16. package/lib/usage-preferences.js +51 -0
  17. package/locale/en.json +6 -0
  18. package/locale/ja.json +6 -0
  19. package/locale/zh.json +6 -0
  20. package/package.json +28 -18
  21. package/screenshots/controls.png +0 -0
  22. package/screenshots/detection.png +0 -0
  23. package/screenshots/quotas.png +0 -0
  24. package/screenshots/usage.png +0 -0
  25. package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
  26. package/third_party/brand-icons/LICENSE +59 -0
  27. package/third_party/brand-icons/NOTICE.txt +5 -0
  28. package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
  29. package/third_party/brand-icons/channel-icons/LICENSE +21 -0
  30. package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
  31. package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
  32. package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
  33. package/third_party/brand-icons/react-icons/LICENSE +13 -0
  34. package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
  35. package/third_party/lm-fingerprint/LICENSE +21 -0
  36. package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
  37. package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
  38. package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
  39. package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
  40. package/third_party/lm-fingerprint/core/positional.mjs +64 -0
  41. package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
  42. package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
  43. package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
  44. package/README.zh-CN.md +0 -318
@@ -0,0 +1,166 @@
1
+ /** Upstream releases are immutable. A running quote always keeps its version. */
2
+ import { Worker } from 'node:worker_threads'
3
+ import { readFile, writeFile, mkdir, rename, rm } from 'node:fs/promises'
4
+ import { existsSync, readFileSync } from 'node:fs'
5
+ import { resolve, join, relative, isAbsolute, dirname } from 'node:path'
6
+ import { fileURLToPath } from 'node:url'
7
+ import { randomUUID } from 'node:crypto'
8
+ import { hash, publishedRelease, buildRelease, closeDownloadTransport } from './lm-fingerprint-source.js'
9
+
10
+ const bundled = [new URL('../third_party/lm-fingerprint/', import.meta.url), new URL('./third_party/lm-fingerprint/', import.meta.url)]
11
+ .map(url => fileURLToPath(url)).find(path => existsSync(join(path, 'UPSTREAM.json')))
12
+ export async function verifyEngine(directory) {
13
+ const manifest = JSON.parse(await readFile(join(directory, 'UPSTREAM.json'), 'utf8'))
14
+ if (manifest.schema !== 1 || manifest.name !== 'lmfpd' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(manifest.version || '') ||
15
+ !/^[a-f0-9]{40}$/.test(manifest.revision || '') || !manifest.files || Object.keys(manifest.files).length > 40) throw new Error('指纹引擎清单无效。')
16
+ const required = ['LICENSE', 'data/unified_bank.json', 'data/shared_detector.bin',
17
+ ...['challenge-browser', 'fingerprint-core', 'shared-detector', 'detector-codec'].map(name => 'core/' + name + '.mjs')]
18
+ if (required.some(name => !manifest.files[name])) throw new Error('指纹引擎文件缺失。')
19
+ for (const [name, digest] of Object.entries(manifest.files)) {
20
+ if (!/^(?:LICENSE|data\/(?:unified_bank\.json|shared_detector\.bin)|core\/[a-z0-9-]+\.mjs)$/.test(name) ||
21
+ !/^[a-f0-9]{64}$/.test(digest) || hash(await readFile(join(directory, name))) !== digest) throw new Error('指纹引擎文件校验失败。')
22
+ }
23
+ return manifest
24
+ }
25
+ export function openEngine(directory, manifest, options = {}) {
26
+ const worker = new Worker(new URL('./lm-fingerprint-worker.js', import.meta.url), {
27
+ workerData: { directory }, resourceLimits: { maxOldGenerationSizeMb: 256 }, execArgv: []
28
+ })
29
+ const pending = new Map()
30
+ let sequence = 0, closed = false, metadata
31
+ let resolveReady, rejectReady
32
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject })
33
+ const timeoutMs = options.timeoutMs || 30_000
34
+ const startup = setTimeout(() => fail(new Error('指纹引擎启动超时。')), timeoutMs)
35
+ const dispose = () => { if (!closed) { closed = true; clearTimeout(startup); rejectReady(new Error('指纹引擎已停止。')); void worker.terminate(); for (const p of pending.values()) p.reject(new Error('指纹引擎已停止。')); pending.clear() } }
36
+ function fail(error) { clearTimeout(startup); rejectReady(error); dispose() }
37
+ worker.on('message', message => {
38
+ if (message.ready) { clearTimeout(startup); metadata = message; resolveReady(message); return }
39
+ const item = pending.get(message.id)
40
+ if (!item) return
41
+ pending.delete(message.id)
42
+ message.error ? item.reject(new Error(message.error)) : item.resolve(message.result)
43
+ })
44
+ worker.on('error', error => fail(new Error('指纹引擎无法启动,请检查宿主运行环境。', { cause: error })))
45
+ worker.on('exit', () => { if (!closed) fail(new Error('指纹引擎意外退出。')) })
46
+ return {
47
+ manifest, directory, ready, dispose,
48
+ get models() { return metadata?.models || [] },
49
+ async call(operation, value, signal) {
50
+ await ready
51
+ if (closed || signal?.aborted) throw new Error('指纹检测已停止。')
52
+ return new Promise((resolve, reject) => {
53
+ const id = ++sequence
54
+ const finish = handler => result => { clearTimeout(timer); signal?.removeEventListener('abort', abort); pending.delete(id); handler(result) }
55
+ const abort = () => pending.get(id)?.reject(new Error('指纹检测已取消。'))
56
+ const timer = setTimeout(() => { pending.get(id)?.reject(new Error('指纹引擎计算超时。')); dispose() }, timeoutMs)
57
+ pending.set(id, { resolve: finish(resolve), reject: finish(reject) })
58
+ signal?.addEventListener('abort', abort, { once: true })
59
+ worker.postMessage({ id, operation, value })
60
+ })
61
+ }
62
+ }
63
+ }
64
+ export function createFingerprintEngine(options = {}) {
65
+ const cache = options.path && resolve(options.path), fallback = options.bundledPath || bundled
66
+ let active = fallback, activeManifest, warning = '', checkedAt = 0, checking, initialized, disposed = false, autoTimer
67
+ const lifecycle = new AbortController()
68
+ const sessions = new Set()
69
+ try { activeManifest = JSON.parse(readFileSync(join(fallback, 'UPSTREAM.json'), 'utf8')) } catch { warning = '内置指纹引擎不可用,请重新安装插件。' }
70
+ function status() { return { name: 'lmfpd', version: activeManifest?.version || '', revision: activeManifest?.revision || '',
71
+ builtAt: activeManifest?.builtAt || '', modelCount: activeManifest?.modelCount || 0, source: active === fallback ? 'bundled' : 'updated',
72
+ checkedAt, updating: !!checking, warning, autoUpdate: !!cache } }
73
+ function cacheDirectory(version) {
74
+ if (!cache || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(version || '') || version.length > 64) throw new Error('指纹引擎缓存路径无效。')
75
+ const target = resolve(cache, 'versions', version), rel = relative(cache, target)
76
+ if (rel.startsWith('..') || isAbsolute(rel)) throw new Error('指纹引擎缓存路径无效。')
77
+ return target
78
+ }
79
+ async function init() {
80
+ if (!initialized) initialized = (async () => {
81
+ if (cache) {
82
+ try {
83
+ const pointer = JSON.parse(await readFile(join(cache, 'current.json'), 'utf8'))
84
+ const directory = cacheDirectory(pointer.version), manifest = await verifyEngine(directory)
85
+ if (pointer.manifestHash !== hash(await readFile(join(directory, 'UPSTREAM.json')))) throw new Error('缓存清单不一致。')
86
+ active = directory; activeManifest = manifest; checkedAt = Number(pointer.checkedAt) || 0
87
+ } catch (error) { if (error.code !== 'ENOENT') warning = '缓存引擎校验失败,已保留内置版本。' }
88
+ try {
89
+ const check = JSON.parse(await readFile(join(cache, 'check.json'), 'utf8'))
90
+ if (Number.isFinite(check.checkedAt)) checkedAt = Math.min(Date.now(), Math.max(0, check.checkedAt))
91
+ if (!warning && typeof check.warning === 'string' && check.warning === '上游更新暂不可用,继续使用已验证的指纹引擎。') warning = check.warning
92
+ } catch {}
93
+ }
94
+ activeManifest = await verifyEngine(active)
95
+ })()
96
+ await initialized
97
+ if (disposed) throw new Error('指纹引擎已停止。')
98
+ }
99
+ async function update(force = false) {
100
+ await init()
101
+ if (!cache || disposed) return status()
102
+ if (checking) return checking
103
+ if (!force && Date.now() - checkedAt < (options.checkIntervalMs ?? 86_400_000)) return status()
104
+ checking = (async () => {
105
+ let staging, trial
106
+ try {
107
+ checkedAt = Date.now()
108
+ const release = await publishedRelease({ fetch: options.fetch, signal: AbortSignal.any([lifecycle.signal, AbortSignal.timeout(15_000)]) })
109
+ if (release.version === activeManifest.version || Number(release.version.split('.').pop()) < Number(activeManifest.version.split('.').pop())) { warning = ''; return }
110
+ staging = join(cache, '.stage-' + randomUUID())
111
+ const manifest = await buildRelease(release, staging, { fetch: options.fetch, signal: AbortSignal.any([lifecycle.signal, AbortSignal.timeout(60_000)]) })
112
+ trial = openEngine(staging, manifest, options); await trial.ready
113
+ trial.dispose(); trial = null
114
+ if (disposed) throw new Error('指纹引擎已停止。')
115
+ const destination = cacheDirectory(manifest.version)
116
+ if (existsSync(destination)) {
117
+ const existing = await verifyEngine(destination)
118
+ if (existing.revision !== manifest.revision || existing.integrity !== manifest.integrity ||
119
+ JSON.stringify(Object.entries(existing.files).sort()) !== JSON.stringify(Object.entries(manifest.files).sort())) throw new Error('同版本缓存内容不同。')
120
+ } else { await mkdir(dirname(destination), { recursive: true }); await rename(staging, destination) }
121
+ const pointer = { version: manifest.version, checkedAt, manifestHash: hash(await readFile(join(destination, 'UPSTREAM.json'))) }
122
+ await mkdir(cache, { recursive: true })
123
+ const temporary = join(cache, 'current-' + randomUUID() + '.tmp')
124
+ await writePointer(temporary, pointer)
125
+ await rename(temporary, join(cache, 'current.json'))
126
+ active = destination; activeManifest = manifest; warning = ''
127
+ } catch { warning = '上游更新暂不可用,继续使用已验证的指纹引擎。' }
128
+ finally {
129
+ trial?.dispose()
130
+ const rel = staging && relative(cache, resolve(staging))
131
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) await rm(staging, { recursive: true, force: true }).catch(() => {})
132
+ if (!disposed) {
133
+ const temporary = join(cache, 'check-' + randomUUID() + '.tmp')
134
+ try { await mkdir(cache, { recursive: true }); await writePointer(temporary, { checkedAt, warning }); await rename(temporary, join(cache, 'check.json')) }
135
+ catch { await rm(temporary, { force: true }).catch(() => {}) }
136
+ }
137
+ }
138
+ })().finally(() => { checking = null })
139
+ await checking
140
+ return status()
141
+ }
142
+ async function writePointer(path, value) { await writeFile(path, JSON.stringify(value) + '\n') }
143
+ return {
144
+ status, update,
145
+ async acquire() {
146
+ await init()
147
+ const manifest = await verifyEngine(active), engine = openEngine(active, manifest, options)
148
+ sessions.add(engine)
149
+ const close = engine.dispose
150
+ engine.dispose = () => { sessions.delete(engine); close() }
151
+ try { await engine.ready } catch (error) { engine.dispose(); throw error }
152
+ return engine
153
+ },
154
+ async catalog() {
155
+ const engine = await this.acquire()
156
+ try {
157
+ if (cache && !autoTimer) { autoTimer = setInterval(() => { void update().catch(() => {}) }, 3_600_000); autoTimer.unref?.() }
158
+ void update().catch(() => {})
159
+ // Let the background check enter its downloading state for the UI.
160
+ await new Promise(resolve => setImmediate(resolve))
161
+ return { ...status(), version: engine.manifest.version, builtAt: engine.manifest.builtAt, modelCount: engine.models.length, models: engine.models }
162
+ } finally { engine.dispose() }
163
+ },
164
+ dispose() { disposed = true; lifecycle.abort(); clearInterval(autoTimer); for (const session of sessions) session.dispose(); void closeDownloadTransport() }
165
+ }
166
+ }
@@ -0,0 +1,134 @@
1
+ /** Fetch one published upstream revision. Never runs npm scripts or the CLI. */
2
+ import { createHash } from 'node:crypto'
3
+ import { gunzipSync } from 'node:zlib'
4
+ import { mkdir, writeFile } from 'node:fs/promises'
5
+ import { join, posix } from 'node:path'
6
+ import { EnvHttpProxyAgent, fetch as undiciFetch } from 'undici'
7
+
8
+ export const REGISTRY = 'https://registry.npmjs.org/lmfpd/latest'
9
+ export const SOURCE_ROOT = 'https://raw.githubusercontent.com/Ikaleio/lm-detector/'
10
+ const roots = ['challenge-browser.js', 'fingerprint-core.js', 'shared-detector.ts', 'detector-codec.ts']
11
+ let downloadAgent
12
+ function defaultFetch(url, init) {
13
+ if (process.env.HTTPS_PROXY || process.env.HTTP_PROXY || process.env.https_proxy || process.env.http_proxy) {
14
+ downloadAgent ||= new EnvHttpProxyAgent()
15
+ return undiciFetch(url, { ...init, dispatcher: downloadAgent })
16
+ }
17
+ return globalThis.fetch(url, init)
18
+ }
19
+ export async function closeDownloadTransport() { const agent = downloadAgent; downloadAgent = undefined; await agent?.destroy() }
20
+ export const hash = value => createHash('sha256').update(value).digest('hex')
21
+ export function validateRelease(value) {
22
+ const version = value?.version, revision = value?.gitHead
23
+ if (value?.name !== 'lmfpd' || !/^\d+\.\d+\.\d+(?:-[a-z0-9.-]+)?$/.test(version || '') || version.length > 64 ||
24
+ !/^[a-f0-9]{40}$/.test(revision || '') || !/^sha512-[A-Za-z0-9+/]{86}==$/.test(value?.dist?.integrity || '')) {
25
+ throw new Error('上游版本信息无效。')
26
+ }
27
+ const expected = 'https://registry.npmjs.org/lmfpd/-/lmfpd-' + version + '.tgz'
28
+ if (value.dist.tarball !== expected) throw new Error('上游发布包地址不受支持。')
29
+ return { version, revision, tarball: expected, integrity: value.dist.integrity }
30
+ }
31
+ export async function readRemote(url, { fetch: request = defaultFetch, signal, limit = 1_048_576 } = {}) {
32
+ const response = await request(url, { signal: signal || AbortSignal.timeout(30_000), redirect: 'error' })
33
+ if (!response.ok) throw new Error('上游文件下载失败(HTTP ' + response.status + ')。')
34
+ if (Number(response.headers.get('content-length')) > limit) throw new Error('上游文件超过大小限制。')
35
+ const reader = response.body.getReader(), chunks = []
36
+ let size = 0
37
+ try {
38
+ for (;;) {
39
+ const { done, value } = await reader.read()
40
+ if (done) break
41
+ size += value.length
42
+ if (size > limit) throw new Error('上游文件超过大小限制。')
43
+ chunks.push(Buffer.from(value))
44
+ }
45
+ } finally { await reader.cancel().catch(() => {}) }
46
+ return Buffer.concat(chunks)
47
+ }
48
+ export async function publishedRelease(options = {}) {
49
+ return validateRelease(JSON.parse((await readRemote(REGISTRY, options)).toString('utf8')))
50
+ }
51
+ /** Select literal members from a bounded archive; never extract archive paths. */
52
+ export function releaseFiles(compressed, integrity) {
53
+ if ('sha512-' + createHash('sha512').update(compressed).digest('base64') !== integrity) throw new Error('上游发布包完整性校验失败。')
54
+ const tar = gunzipSync(compressed, { maxOutputLength: 96 * 1024 * 1024 })
55
+ const wanted = new Set(['package/package.json', 'package/LICENSE', 'package/data/unified_bank.json', 'package/data/shared_detector.bin'])
56
+ const files = new Map()
57
+ for (let offset = 0; offset + 512 <= tar.length;) {
58
+ const header = tar.subarray(offset, offset + 512)
59
+ if (header.every(v => v === 0)) break
60
+ const field = (a, b) => header.subarray(a, b).toString('utf8').split('\0')[0]
61
+ const size = Number.parseInt(field(124, 136).trim(), 8)
62
+ const checksum = Number.parseInt(field(148, 156).trim(), 8)
63
+ const actual = header.reduce((sum, v, i) => sum + (i >= 148 && i < 156 ? 32 : v), 0)
64
+ if (!Number.isSafeInteger(size) || size < 0 || offset + 512 + size > tar.length || actual !== checksum) throw new Error('上游发布包归档无效。')
65
+ const prefix = field(345, 500), name = (prefix ? prefix + '/' : '') + field(0, 100)
66
+ if (wanted.has(name)) {
67
+ if (![0, 48].includes(header[156]) || files.has(name.slice(8))) throw new Error('上游发布包含无效文件。')
68
+ files.set(name.slice(8), Buffer.from(tar.subarray(offset + 512, offset + 512 + size)))
69
+ }
70
+ offset += 512 + Math.ceil(size / 512) * 512
71
+ }
72
+ if (files.size !== wanted.size) throw new Error('上游发布包缺少检测文件,请更新插件适配。')
73
+ return files
74
+ }
75
+ export async function buildRelease(release, directory, options = {}) {
76
+ // Types are removed without rewriting the upstream numerical algorithm.
77
+ const ts = (await import('typescript')).default
78
+ const files = releaseFiles(await readRemote(release.tarball, { ...options, limit: 24 * 1024 * 1024 }), release.integrity)
79
+ const pkg = JSON.parse(files.get('package.json').toString('utf8'))
80
+ if (pkg.name !== 'lmfpd' || pkg.version !== release.version || pkg.gitHead !== release.revision) throw new Error('上游代码与发布版本不一致。')
81
+ const bank = JSON.parse(files.get('data/unified_bank.json').toString('utf8'))
82
+ if (!Array.isArray(bank.models) || !bank.models.length || bank.models.length > 1000) throw new Error('上游参考库无效。')
83
+ const modules = new Map(), sourceHashes = {}
84
+ async function moduleFor(name) {
85
+ if (!/^[a-z0-9-]+\.(?:ts|js)$/.test(name)) throw new Error('上游模块路径不受支持。')
86
+ if (modules.has(name)) return
87
+ if (modules.size >= 32) throw new Error('上游模块数量超过限制。')
88
+ modules.set(name, null)
89
+ const raw = await readRemote(SOURCE_ROOT + release.revision + '/shared/' + name, options)
90
+ sourceHashes[name] = hash(raw)
91
+ const compiled = ts.transpileModule(raw.toString('utf8'), {
92
+ fileName: name, compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }
93
+ }).outputText
94
+ const parsed = ts.createSourceFile(name, compiled, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS)
95
+ const replacements = [], dependencies = []
96
+ for (const statement of parsed.statements) {
97
+ if (!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) continue
98
+ const specifier = statement.moduleSpecifier
99
+ if (!specifier) continue
100
+ const path = specifier.text
101
+ if (typeof path !== 'string' || !/^\.\/[a-z0-9-]+(?:\.(?:js|ts))?$/.test(path)) throw new Error('上游模块依赖不受支持,请更新插件适配。')
102
+ const child = path.slice(2).includes('.') ? path.slice(2) : path.slice(2) + '.ts'
103
+ dependencies.push(child)
104
+ replacements.push({ start: specifier.getStart(parsed), end: specifier.getEnd(), value: JSON.stringify('./' + child.replace(/\.(ts|js)$/, '.mjs')) })
105
+ }
106
+ let dynamic = false
107
+ function visit(node) { if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) dynamic = true; ts.forEachChild(node, visit) }
108
+ visit(parsed)
109
+ if (dynamic) throw new Error('上游动态模块依赖不受支持,请更新插件适配。')
110
+ let code = compiled
111
+ for (const change of replacements.sort((a, b) => b.start - a.start)) code = code.slice(0, change.start) + change.value + code.slice(change.end)
112
+ modules.set(name, code)
113
+ await Promise.all(dependencies.map(moduleFor))
114
+ }
115
+ await Promise.all(roots.map(moduleFor))
116
+ const outputs = new Map([
117
+ ['LICENSE', files.get('LICENSE')],
118
+ ['data/unified_bank.json', files.get('data/unified_bank.json')],
119
+ ['data/shared_detector.bin', files.get('data/shared_detector.bin')],
120
+ ...[...modules].map(([name, code]) => ['core/' + name.replace(/\.(ts|js)$/, '.mjs'), Buffer.from(code)])
121
+ ])
122
+ const manifest = { schema: 1, name: 'lmfpd', version: release.version, revision: release.revision, integrity: release.integrity,
123
+ builtAt: bank.built_at, modelCount: bank.models.length, source: 'https://github.com/Ikaleio/lm-detector/tree/' + release.revision,
124
+ upstream: { url: 'https://github.com/Ikaleio/lm-detector', commit: release.revision },
125
+ compiler: 'TypeScript 5.9.3; type erasure and relative module extensions only', sourceHashes: Object.fromEntries(Object.entries(sourceHashes).sort()),
126
+ files: Object.fromEntries([...outputs].sort(([a], [b]) => a.localeCompare(b)).map(([name, bytes]) => [name, hash(bytes)])) }
127
+ for (const [name, bytes] of outputs) {
128
+ const parts = posix.dirname(name)
129
+ await mkdir(join(directory, parts), { recursive: true })
130
+ await writeFile(join(directory, name), bytes)
131
+ }
132
+ await writeFile(join(directory, 'UPSTREAM.json'), JSON.stringify(manifest, null, 2) + '\n')
133
+ return manifest
134
+ }
@@ -0,0 +1,31 @@
1
+ /** The worker loads upstream code and data together; no network or credentials. */
2
+ import { parentPort, workerData } from 'node:worker_threads'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { pathToFileURL } from 'node:url'
5
+ import { join } from 'node:path'
6
+
7
+ const root = workerData.directory
8
+ const [challenge, fingerprint, scorer, codec] = await Promise.all(
9
+ ['challenge-browser', 'fingerprint-core', 'shared-detector', 'detector-codec'].map(name => import(pathToFileURL(join(root, 'core', name + '.mjs')).href)))
10
+ const bank = JSON.parse(await readFile(join(root, 'data', 'unified_bank.json'), 'utf8'))
11
+ const detector = codec.decodeDetector(await readFile(join(root, 'data', 'shared_detector.bin')))
12
+ if (!scorer.supportsSharedDetector(bank, detector)) throw new Error('参考库与检测器不匹配。')
13
+ const models = bank.models.map(m => ({ id: m.id, name: m.display_name, family: m.family }))
14
+ // Exercise the numerical interface before activating an update. No model calls.
15
+ const fixture = Array.from({ length: 300 }, (_, i) => (i * 17) % 355 + 1).join(',')
16
+ const check = scorer.analyzeSharedOutputs(Array.from({ length: 3 }, () => ({ text: fixture, expected_count: 300 })), bank, detector)
17
+ if (!check.results?.length || check.results.some(r => !Number.isFinite(r.score)) ||
18
+ check.probability !== null && (!Number.isFinite(check.probability) || check.probability < 0 || check.probability > 1)) throw new Error('上游检测接口不兼容。')
19
+ const challenges = challenge.generateChallenges(3)
20
+ if (challenges.length !== 3 || challenges.some(c => typeof c.prompt !== 'string' || !Number.isInteger(c.expected_count) || c.expected_count < 80 || c.expected_count > 500)) throw new Error('上游挑战接口不兼容。')
21
+ parentPort.postMessage({ ready: true, models, builtAt: bank.built_at })
22
+ parentPort.on('message', ({ id, operation, value }) => {
23
+ try {
24
+ let result
25
+ if (operation === 'challenges') result = challenge.generateChallenges(value)
26
+ else if (operation === 'parse') result = fingerprint.parseNumbers(value)
27
+ else if (operation === 'score') result = scorer.analyzeSharedOutputs(value, bank, detector, { allowPartial: true })
28
+ else throw new Error('Unsupported engine operation')
29
+ parentPort.postMessage({ id, result })
30
+ } catch { parentPort.postMessage({ id, error: '指纹评分失败,请检查引擎版本或有效样本。' }) }
31
+ })
@@ -0,0 +1,86 @@
1
+ /** Reported detection usage and recent fingerprints. No prompts, answers or credentials. */
2
+ import { readFileSync } from 'node:fs'
3
+ import { mkdir, writeFile, rename } from 'node:fs/promises'
4
+ import { dirname } from 'node:path'
5
+ import { modelSelection, safeUsage, summarizeModelDetection, normalizeFingerprintIdentity } from './model-detection.js'
6
+
7
+ const clean = v => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 256) : ''
8
+ const number = v => Number.isFinite(v) && v >= 0 ? v : 0
9
+ const timing = v => Number.isFinite(v) && v >= 0 ? v : null
10
+ const states = ['running', 'complete', 'failed', 'cancelled', 'budget-exhausted', 'interrupted']
11
+ const outcomes = ['scored', 'unparseable', 'unavailable', 'timeout', 'incomplete', 'cancelled']
12
+ const fields = (value, keys) => Object.fromEntries(keys.filter(k => Number.isFinite(value?.[k]) && value[k] >= 0).map(k => [k, value[k]]))
13
+ function result(raw, job) {
14
+ const selection = modelSelection(raw.selection) || (job.testSelection || job.selection)
15
+ return { time: number(raw.time) || job.finishedAt || job.startedAt, selection,
16
+ phase: 'fingerprint', outcome: outcomes.includes(raw.outcome) ? raw.outcome : 'unavailable', stop: clean(raw.stop),
17
+ usage: safeUsage(raw.usage), usageReported: raw.usageReported === true,
18
+ valid: number(raw.valid), expected: number(raw.expected), ttftMs: timing(raw.ttftMs), decodeMs: timing(raw.decodeMs) }
19
+ }
20
+ function jobSnapshot(raw) {
21
+ const selection = modelSelection(raw?.selection)
22
+ if (!selection || !clean(raw.id) || !states.includes(raw.status)) return null
23
+ const job = { id: clean(raw.id), sessionId: clean(raw.sessionId), selection, testSelection: modelSelection(raw.testSelection),
24
+ status: raw.status, phase: clean(raw.phase), startedAt: number(raw.startedAt), finishedAt: number(raw.finishedAt),
25
+ plan: fields(raw.plan, ['requests', 'samples', 'maxTokens', 'maxOutputTokens', 'estimatedInputTokens', 'budgetTokens', 'timeoutMs']),
26
+ usage: safeUsage(raw.usage), usageReported: raw.usageReported === true, usageIncomplete: raw.usageIncomplete === true, usageSaved: true,
27
+ message: clean(raw.message), channel: { tps: timing(raw.channel?.tps), ttftMs: timing(raw.channel?.ttftMs) } }
28
+ job.plan.numbers = (Array.isArray(raw.plan?.numbers) ? raw.plan.numbers : []).slice(0, 3).map(number)
29
+ if (raw.engine) job.engine = { name: 'lmfpd', version: clean(raw.engine.version), revision: clean(raw.engine.revision), builtAt: clean(raw.engine.builtAt), modelCount: number(raw.engine.modelCount) }
30
+ job.identity = normalizeFingerprintIdentity(raw.identity)
31
+ job.results = (Array.isArray(raw.results) ? raw.results : []).map(r => result(r, job))
32
+ job.progress = job.results.length
33
+ job.conclusion = summarizeModelDetection(job)
34
+ return job
35
+ }
36
+ export function createModelDetectionHistory(path) {
37
+ const usage = new Map(), jobs = new Map()
38
+ let queue = Promise.resolve(), warning = ''
39
+ try {
40
+ const document = JSON.parse(readFileSync(path, 'utf8'))
41
+ if (document.version !== 1 || !Array.isArray(document.usage) || !Array.isArray(document.jobs)) throw new Error('invalid')
42
+ for (const raw of document.usage) {
43
+ const job = { selection: modelSelection(raw.selection), startedAt: number(raw.startedAt), finishedAt: number(raw.finishedAt) }
44
+ if (!clean(raw.id) || !job.selection || !Array.isArray(raw.results)) throw new Error('invalid')
45
+ usage.set(clean(raw.id), { id: clean(raw.id), ...job, results: raw.results.map(r => result(r, job)) })
46
+ }
47
+ for (const raw of document.jobs.slice(-10)) {
48
+ const job = jobSnapshot(raw)
49
+ if (!job) continue
50
+ if (job.status === 'running') {
51
+ job.status = 'interrupted'; job.finishedAt = Date.now(); job.message = ''
52
+ job.identity = null
53
+ job.conclusion = summarizeModelDetection(job)
54
+ }
55
+ jobs.set(job.id, job)
56
+ }
57
+ } catch (error) { if (error.code !== 'ENOENT') warning = '检测记录无法读取,请检查本地记录后重新加载插件。' }
58
+ return {
59
+ warning: () => warning,
60
+ flush: () => queue.catch(() => {}),
61
+ latest(sessionId) { return [...jobs.values()].reverse().find(j => !sessionId || j.sessionId === sessionId) || null },
62
+ get(id) { return jobs.get(id) || null },
63
+ usage() { return [...usage.values()].flatMap(j => j.results) },
64
+ // Stable sample IDs let the statistics plugin replace repeated snapshots without double counting.
65
+ usageRecords() { return [...usage.values()].flatMap(j => j.results.map((row, index) => ({ ...row, id: `${j.id}:${index}` }))) },
66
+ async record(raw) {
67
+ if (warning) throw new Error(warning)
68
+ const job = jobSnapshot(raw)
69
+ if (!job) throw new Error('invalid fingerprint record')
70
+ const prior = usage.get(job.id)
71
+ // A late poll or repeated save must never shrink or duplicate spend.
72
+ if (prior && job.results.length < prior.results.length) return
73
+ usage.set(job.id, { id: job.id, selection: job.selection, startedAt: job.startedAt, finishedAt: job.finishedAt, results: job.results })
74
+ jobs.set(job.id, job)
75
+ while (jobs.size > 10) jobs.delete(jobs.keys().next().value)
76
+ const document = JSON.stringify({ version: 1, usage: [...usage.values()], jobs: [...jobs.values()] })
77
+ const next = queue.catch(() => {}).then(async () => {
78
+ await mkdir(dirname(path), { recursive: true })
79
+ await writeFile(path + '.tmp', document, { mode: 0o600 })
80
+ await rename(path + '.tmp', path)
81
+ })
82
+ queue = next
83
+ await next
84
+ },
85
+ }
86
+ }