@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
@@ -0,0 +1,255 @@
1
+ /** Bounded LM Fingerprint detection. No conversation text is sent or retained. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import { createFingerprintEngine } from './lm-fingerprint-engine.js'
4
+
5
+ const usageKeys = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
6
+ const safeText = (value, max = 256) => typeof value === 'string' ? value.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, max) : ''
7
+ const nonnegative = value => Number.isFinite(value) && value >= 0 ? value : 0
8
+ export function safeUsage(value) {
9
+ return Object.fromEntries(usageKeys.map(key => [key, nonnegative(value?.[key])]))
10
+ }
11
+ export function modelSelection(value) {
12
+ const provider = safeText(value?.provider), model = safeText(value?.model)
13
+ if (!provider || !model) return null
14
+ const reasoningEffort = safeText(value.reasoningEffort, 64)
15
+ return { provider, model, ...(reasoningEffort ? { reasoningEffort } : {}) }
16
+ }
17
+ const stopReason = value => ['stop', 'max-tokens', 'tool-calls', 'error', 'aborted', 'unknown'].includes(value) ? value : 'unknown'
18
+
19
+ export class ModelDetectionError extends Error {}
20
+ export async function readDetectionModels(ctx, provider = '') {
21
+ const llm = ctx.get('llm')
22
+ if (typeof llm?.listProviders !== 'function' || typeof llm?.listModels !== 'function') throw new ModelDetectionError('当前宿主不支持读取模型列表,请更新 DSH。')
23
+ try {
24
+ const signal = AbortSignal.timeout(10_000)
25
+ const registered = await abortable(llm.listProviders(), signal)
26
+ if (!Array.isArray(registered)) throw new Error('invalid providers')
27
+ const seen = new Set()
28
+ const providers = registered.flatMap(item => {
29
+ // listProviders() returns ProviderInfo { id, name }; configurable-provider
30
+ // declarations use { provider, displayName } on older host integrations.
31
+ const id = safeText(item?.id || item?.provider)
32
+ if (!id || seen.has(id)) return []
33
+ seen.add(id)
34
+ return [{ provider: id, name: safeText(item.name || item.displayName || id, 512) }]
35
+ })
36
+ provider = safeText(provider)
37
+ if (!provider) return { providers, provider: '', models: [] }
38
+ if (!seen.has(provider)) throw new ModelDetectionError('请选择已配置的供应商。')
39
+ const listed = await abortable(llm.listModels(provider), signal)
40
+ if (!Array.isArray(listed)) throw new Error('invalid models')
41
+ const ids = new Set()
42
+ const models = listed.flatMap(item => {
43
+ const id = safeText(item?.id)
44
+ if (!id || item.provider !== provider || ids.has(id)) return []
45
+ ids.add(id)
46
+ return [{ id, name: safeText(item.name || id, 512) }]
47
+ })
48
+ return { providers, provider, models }
49
+ } catch (error) {
50
+ if (error instanceof ModelDetectionError) throw error
51
+ throw new ModelDetectionError('模型列表无法读取,请检查供应商配置后重试。')
52
+ }
53
+ }
54
+ export const PROBE_PLAN = Object.freeze({ samples: 3, maxTokens: 4096, timeoutMs: 60_000, budgetTokens: 12_000 })
55
+ const sumTokens = usage => usage.inputTokens + usage.outputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
56
+ function modelKey(value) {
57
+ return safeText(value).toLowerCase().split('/').pop().replace(/[-_]20\d{6}$/, '').replace(/[-_]20\d{2}[-_]\d{2}[-_]\d{2}$/, '').replace(/(\d)-(\d)(?=$|[-.])/, '$1.$2')
58
+ }
59
+ function modelAliases(model) {
60
+ const first = modelKey(model.id), labels = String(model.name || '').split('/')
61
+ const prefix = first.match(/^\D+/)?.[0] || ''
62
+ return [...new Set([first, ...labels.map(v => modelKey(/^\d/.test(v) ? prefix + v : v))])]
63
+ }
64
+ export function normalizeFingerprintIdentity(raw) {
65
+ if (!raw || !Array.isArray(raw.candidates)) return null
66
+ const usedSamples = Number.isInteger(raw.usedSamples) && raw.usedSamples >= 0 && raw.usedSamples <= 3 ? raw.usedSamples : 0
67
+ const probability = v => usedSamples === 3 && raw.probabilityScope === 'reference-closed-set' && Number.isFinite(v) && v >= 0 && v <= 1 ? v : null
68
+ const candidates = raw.candidates.slice(0, 5).filter(c => safeText(c?.id) && Number.isFinite(c.score)).map(c => ({ id: safeText(c.id), name: safeText(c.name || c.id), family: safeText(c.family), score: c.score, probability: probability(c.probability) }))
69
+ return { method: safeText(raw.method), usedSamples, requestedSamples: raw.requestedSamples === 1 ? 1 : 3,
70
+ candidates, probability: probability(raw.probability), probabilityScope: 'reference-closed-set',
71
+ modelInBank: raw.modelInBank === true, sameCandidate: typeof raw.sameCandidate === 'boolean' ? raw.sameCandidate : null,
72
+ merged: raw.merged === true, unusualDistribution: raw.unusualDistribution === true }
73
+ }
74
+ export function summarizeModelDetection(job) {
75
+ if (job.status === 'running') return { verdict: 'running', reason: 'running' }
76
+ const identity = job.identity
77
+ const invalid = Object.fromEntries(['incomplete', 'unparseable', 'unavailable', 'timeout'].map(code => [code, (job.results || []).filter(r => r.outcome === code).length]))
78
+ const reason = job.status === 'cancelled' ? 'cancelled' : job.status === 'interrupted' ? 'interrupted' : job.status === 'budget-exhausted' ? 'budget' :
79
+ identity?.unusualDistribution ? 'distribution' : invalid.timeout ? 'timeout' : invalid.unavailable ? 'request-failed' : invalid.incomplete ? 'truncated' : invalid.unparseable ? 'format' : 'unfinished'
80
+ if (['failed', 'budget-exhausted'].includes(job.status) && identity?.usedSamples > 0 && identity.candidates.length && !identity.unusualDistribution) return { verdict: 'ranking-only', reason, invalid }
81
+ if (job.status === 'complete' && identity?.candidates?.length && !identity.unusualDistribution) {
82
+ if (identity.usedSamples < 3 || identity.probability === null) return { verdict: 'ranking-only', reason: 'partial-ranking' }
83
+ if (!identity.modelInBank) return { verdict: 'ranking-only', reason: 'model-not-enrolled' }
84
+ if (identity.probability < .75) return { verdict: 'ranking-only', reason: 'low-confidence' }
85
+ return { verdict: identity.sameCandidate ? 'candidate-consistent' : 'candidate-different', reason: identity.merged ? 'merged-class' : 'closed-set-ranking' }
86
+ }
87
+ return { verdict: 'inconclusive', reason, invalid }
88
+ }
89
+ function jobView(job) {
90
+ return { id: job.id, sessionId: job.sessionId, selection: job.selection, testSelection: job.testSelection,
91
+ status: job.status, phase: job.phase, startedAt: job.startedAt, finishedAt: job.finishedAt, progress: job.results.length,
92
+ engine: job.engineInfo, plan: job.plan, usage: safeUsage(job.usage), usageReported: job.results.some(r => r.usageReported),
93
+ usageIncomplete: job.status === 'running' || job.results.some(r => !r.usageReported), usageSaved: job.usageSaved === true,
94
+ results: job.results.map(({ time, selection, phase, outcome, stop, usage, usageReported, valid, expected, ttftMs, decodeMs }) => ({ time, selection, phase, outcome, stop, usage, usageReported, valid, expected, ttftMs, decodeMs })),
95
+ identity: job.identity || null, conclusion: summarizeModelDetection(job), message: job.message || '',
96
+ 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,
97
+ 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 } }
98
+ }
99
+ function abortable(promise, signal) {
100
+ return new Promise((resolve, reject) => {
101
+ const abort = () => reject(new Error('aborted'))
102
+ if (signal.aborted) { abort(); return }
103
+ signal.addEventListener('abort', abort, { once: true })
104
+ Promise.resolve(promise).then(resolve, reject).finally(() => signal.removeEventListener('abort', abort))
105
+ })
106
+ }
107
+ export function createModelDetectionRunner(ctx, options = {}) {
108
+ const engine = options.engine || createFingerprintEngine({ path: options.enginePath, fetch: options.fetch })
109
+ const jobs = new Map(), quotes = new Map()
110
+ let disposed = false
111
+ async function checkpoint(job, patch = {}) {
112
+ if (!options.onUpdate) return
113
+ try { await options.onUpdate({ ...jobView({ ...job, ...patch }), usageSaved: true }); job.usageSaved = true }
114
+ catch { job.usageSaved = false; job.halt = true; job.message = '检测用量保存失败,已停止后续请求;请检查本地存储。' }
115
+ }
116
+ function discardQuote(id) { const quote = quotes.get(id); if (quote) { quote.engine.dispose(); clearTimeout(quote.timer); quotes.delete(id) } }
117
+ async function request(job, challenge) {
118
+ const timeout = new AbortController(), timer = setTimeout(() => timeout.abort(), options.timeoutMs || PROBE_PLAN.timeoutMs)
119
+ const signal = AbortSignal.any([job.controller.signal, timeout.signal])
120
+ const item = { time: 0, selection: job.testSelection, phase: 'fingerprint', outcome: 'unavailable', stop: 'unknown',
121
+ usage: safeUsage(null), usageReported: false, valid: 0, expected: challenge.expected_count, ttftMs: null, decodeMs: null }
122
+ let iterator, text = '', firstToken = 0, output = null
123
+ const started = Date.now()
124
+ try {
125
+ const llm = ctx.get('llm')
126
+ iterator = llm.stream({ ...job.testSelection, maxTokens: job.plan.maxTokens,
127
+ messages: [{ role: 'user', content: [{ type: 'text', text: challenge.prompt }] }], signal })[Symbol.asyncIterator]()
128
+ for (;;) {
129
+ const next = await abortable(iterator.next(), signal)
130
+ if (signal.aborted) throw new Error('aborted')
131
+ if (next.done) break
132
+ const chunk = next.value
133
+ if (chunk?.type === 'text-delta') {
134
+ if (chunk.text && !firstToken) firstToken = Date.now()
135
+ text += String(chunk.text || '')
136
+ if (text.length > 32_768) { item.stop = 'max-tokens'; break }
137
+ }
138
+ if (chunk?.type === 'usage') {
139
+ for (const key of usageKeys) if (Number.isFinite(chunk.usage?.[key]) && chunk.usage[key] >= 0) { item.usage[key] = chunk.usage[key]; item.usageReported = true }
140
+ }
141
+ if (chunk?.type === 'finish') { item.stop = stopReason(chunk.reason?.kind); break }
142
+ }
143
+ if (['stop', 'max-tokens'].includes(item.stop)) {
144
+ const numbers = await job.engine.call('parse', text, signal)
145
+ item.valid = Math.min(numbers.length, challenge.expected_count)
146
+ if (item.stop === 'max-tokens' && numbers.length < challenge.expected_count) item.outcome = 'incomplete'
147
+ else if (numbers.length < Math.max(80, Math.ceil(challenge.expected_count * .55))) item.outcome = 'unparseable'
148
+ else { item.outcome = 'scored'; output = { text: numbers.slice(0, challenge.expected_count).join(','), expected_count: challenge.expected_count } }
149
+ if (firstToken) { item.ttftMs = firstToken - started; item.decodeMs = Date.now() - firstToken }
150
+ }
151
+ } catch { item.outcome = job.controller.signal.aborted ? 'cancelled' : timeout.signal.aborted ? 'timeout' : 'unavailable'; item.stop = signal.aborted ? 'aborted' : 'error' }
152
+ finally {
153
+ clearTimeout(timer); timeout.abort()
154
+ try { Promise.resolve(iterator?.return?.()).catch(() => {}) } catch {}
155
+ text = ''
156
+ item.time = Date.now(); job.results.push(item)
157
+ for (const key of usageKeys) job.usage[key] += item.usage[key]
158
+ if (item.outcome !== 'scored') job.halt = true
159
+ if (sumTokens(job.usage) >= job.plan.budgetTokens && job.results.length < job.plan.requests) { job.halt = true; job.budgetExhausted = true }
160
+ await checkpoint(job)
161
+ }
162
+ return output
163
+ }
164
+ async function run(job, quote) {
165
+ const outputs = []
166
+ try {
167
+ await checkpoint(job)
168
+ for (const challenge of quote.challenges) {
169
+ if (disposed || job.controller.signal.aborted || job.halt) break
170
+ job.phase = 'sampling'
171
+ const output = await request(job, challenge)
172
+ if (output) outputs.push(output)
173
+ }
174
+ // Score the valid samples even when a later call fails. The official
175
+ // scorer withholds calibrated probabilities for fewer than 3 samples.
176
+ if (!disposed && !job.controller.signal.aborted && job.usageSaved !== false && outputs.length > 0) {
177
+ job.phase = 'scoring'
178
+ const analysis = await job.engine.call('score', outputs, job.controller.signal)
179
+ const requested = modelKey(job.selection.model)
180
+ const claimed = job.engine.models.find(m => modelAliases(m).includes(requested))
181
+ job.identity = normalizeFingerprintIdentity({ method: analysis.method, usedSamples: analysis.used_outputs, requestedSamples: job.plan.samples,
182
+ candidates: (analysis.results || []).map(c => ({ id: c.model, name: c.display_name, family: c.family, score: c.score, probability: c.probability })),
183
+ probability: analysis.probability, probabilityScope: analysis.probability_scope,
184
+ modelInBank: !!claimed, sameCandidate: claimed ? claimed.id === analysis.prediction : null,
185
+ merged: !!claimed && modelAliases(claimed).length > 1,
186
+ unusualDistribution: outputs.some(o => Math.min(...o.text.split(',').map(Number)) >= 200) })
187
+ }
188
+ } catch { job.halt = true; job.message = '指纹检测未完成,请检查通道或引擎状态。' }
189
+ finally {
190
+ outputs.length = 0; quote.challenges.length = 0; job.engine.dispose()
191
+ const status = disposed || job.controller.signal.aborted ? 'cancelled' : job.budgetExhausted ? 'budget-exhausted' : job.halt ? 'failed' : 'complete'
192
+ if (status === 'cancelled' || job.usageSaved === false) job.identity = null
193
+ job.finishedAt = Date.now(); job.phase = 'finished'
194
+ await checkpoint(job, { status })
195
+ job.status = job.usageSaved === false && options.onUpdate ? 'failed' : status
196
+ if (job.usageSaved === false) job.identity = null
197
+ }
198
+ }
199
+ return {
200
+ catalog: () => engine.catalog(), updateEngine: () => engine.update(true),
201
+ async plan(body) {
202
+ if (disposed) throw new ModelDetectionError('检测服务已停止。')
203
+ const selection = modelSelection(body?.selection), sessionId = safeText(body?.sessionId)
204
+ if (!selection) throw new ModelDetectionError('请选择供应商与检测模型。')
205
+ const samples = body.sampleCount ?? 3
206
+ if (![1, 3].includes(samples)) throw new ModelDetectionError('请选择 1 次初筛或 3 次标准检测。')
207
+ const budgetTokens = body.budgetTokens ?? PROBE_PLAN.budgetTokens
208
+ if (!Number.isInteger(budgetTokens) || budgetTokens < 1024 || budgetTokens > 50_000) throw new ModelDetectionError('Token 预算应为 1,024–50,000 的整数。')
209
+ const maxTokens = body.maxTokens ?? PROBE_PLAN.maxTokens
210
+ if (!Number.isInteger(maxTokens) || maxTokens < 1024 || maxTokens > 16_384) throw new ModelDetectionError('单次输出上限应为 1,024–16,384 的整数。')
211
+ const llm = ctx.get('llm')
212
+ if (typeof llm?.stream !== 'function' || typeof llm?.resolveModelInfo !== 'function') throw new ModelDetectionError('当前宿主不支持模型参数校验,请更新 DSH。')
213
+ let info
214
+ try { info = await llm.resolveModelInfo(selection.provider, selection.model, AbortSignal.timeout(10_000)) }
215
+ catch { throw new ModelDetectionError('模型信息无法读取,请检查供应商配置后重试。') }
216
+ const efforts = info.reasoning?.efforts?.map(e => e.id)
217
+ const effort = efforts ? ['off', 'none', 'minimal', 'low'].find(e => efforts.includes(e)) || selection.reasoningEffort || info.reasoning.defaultEffort : undefined
218
+ if (effort && !efforts.includes(effort)) throw new ModelDetectionError('模型推理参数不受支持。')
219
+ const testSelection = { provider: selection.provider, model: selection.model, ...(effort ? { reasoningEffort: effort } : {}) }
220
+ let lease
221
+ try {
222
+ lease = await engine.acquire()
223
+ const challenges = await lease.call('challenges', samples)
224
+ const plan = { requests: samples, samples, maxTokens, numbers: challenges.map(c => c.expected_count), maxOutputTokens: samples * maxTokens,
225
+ estimatedInputTokens: Math.ceil(challenges.reduce((n, c) => n + c.prompt.length, 0) / 2), budgetTokens, timeoutMs: options.timeoutMs || PROBE_PLAN.timeoutMs }
226
+ for (const [id, quote] of quotes) if (Date.now() - quote.createdAt > 5 * 60_000) discardQuote(id)
227
+ // Only the newest budget can start detection; keep one idle worker.
228
+ for (const id of quotes.keys()) discardQuote(id)
229
+ const id = randomUUID(), timer = setTimeout(() => discardQuote(id), 5 * 60_000); timer.unref?.()
230
+ const engineInfo = { name: 'lmfpd', version: lease.manifest.version, revision: lease.manifest.revision, builtAt: lease.manifest.builtAt, modelCount: lease.models.length }
231
+ quotes.set(id, { id, timer, sessionId, selection, testSelection, plan, engineInfo, engine: lease, challenges, createdAt: Date.now() })
232
+ return { id, selection, testSelection, plan, engine: engineInfo }
233
+ } catch { lease?.dispose(); throw new ModelDetectionError('指纹引擎不可用,请检查更新状态。') }
234
+ },
235
+ start(body) {
236
+ if (disposed) throw new ModelDetectionError('检测服务已停止。')
237
+ if (body?.ackCost !== true) throw new ModelDetectionError('请确认检测会消耗 Token 后再开始。')
238
+ const quote = quotes.get(body.planId)
239
+ if (!quote || Date.now() - quote.createdAt > 5 * 60_000) throw new ModelDetectionError('检测预算已过期,请刷新预算后开始。')
240
+ if (safeText(body.sessionId) !== quote.sessionId) throw new ModelDetectionError('当前会话已变化,请重新生成预算。')
241
+ if ([...jobs.values()].some(j => j.status === 'running')) throw new ModelDetectionError('已有检测正在运行,请先等待或取消。')
242
+ quotes.delete(quote.id); clearTimeout(quote.timer)
243
+ while (jobs.size >= 10) jobs.delete(jobs.keys().next().value)
244
+ const job = { id: randomUUID(), sessionId: quote.sessionId, selection: quote.selection, testSelection: quote.testSelection,
245
+ plan: quote.plan, engineInfo: quote.engineInfo, engine: quote.engine, status: 'running', phase: 'queued', startedAt: Date.now(), finishedAt: 0,
246
+ controller: new AbortController(), results: [], usage: safeUsage(null), identity: null }
247
+ jobs.set(job.id, job)
248
+ queueMicrotask(() => { void run(job, quote) })
249
+ return jobView(job)
250
+ },
251
+ status(id, sessionId) { const job = id ? jobs.get(id) : [...jobs.values()].reverse().find(j => !sessionId || j.sessionId === sessionId); if (id && !job) throw new ModelDetectionError('检测记录已过期。'); return job ? jobView(job) : null },
252
+ cancel(id) { const job = jobs.get(id); if (!job) throw new ModelDetectionError('检测记录已过期。'); if (job.status === 'running') job.controller.abort(); return jobView(job) },
253
+ dispose() { disposed = true; for (const job of jobs.values()) job.controller.abort(); for (const id of quotes.keys()) discardQuote(id); engine.dispose() }
254
+ }
255
+ }
@@ -1,5 +1,5 @@
1
1
  /** Persistent quota controls, bounded custom queries and host-side scheduling. */
2
- import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'
2
+ import { readFile, mkdir, writeFile, rename, stat as fileStat } from 'node:fs/promises'
3
3
  import { dirname } from 'node:path'
4
4
  import { randomUUID } from 'node:crypto'
5
5
  import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth, quotaErrorCode } from './provider-quotas.js'
@@ -20,7 +20,7 @@ export class QuotaControlError extends Error {
20
20
  const fail = (message, code, status) => { throw new QuotaControlError(message, code, status) }
21
21
 
22
22
  function defaults() {
23
- return { autoQuota: false, intervalMinutes: 60, showModelDetails: true, showModelMatch: false, showTokenSpeed: true, advancedModelSelect: false, customQueries: [] }
23
+ return { autoQuota: false, intervalMinutes: 10, showModelDetection: true, advancedModelSelect: true, modelReasoning: true, customQueries: [] }
24
24
  }
25
25
 
26
26
  /**
@@ -213,7 +213,7 @@ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fe
213
213
  }
214
214
 
215
215
  /** Source reads are explicit; cached page reads never spend account credit. */
216
- export function createQuotaControls(ctx, { path, sources, request = fetch, now = Date.now }) {
216
+ export function createQuotaControls(ctx, { path, legacyPath, sources, request = fetch, now = Date.now }) {
217
217
  let settings = defaults()
218
218
  let snapshots = {}
219
219
  let lastRefreshAt = 0
@@ -246,14 +246,24 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
246
246
 
247
247
  const load = () => loaded ||= (async () => {
248
248
  try {
249
- const stat = JSON.parse(await readFile(path, 'utf8'))
249
+ let text
250
+ try { text = await readFile(path, 'utf8') }
251
+ catch (error) {
252
+ if (error.code !== 'ENOENT' || !legacyPath) throw error
253
+ const candidates = await Promise.all((Array.isArray(legacyPath) ? legacyPath : [legacyPath]).map(async file => {
254
+ try { return { file, modified: (await fileStat(file)).mtimeMs } } catch { return null }
255
+ }))
256
+ const latest = candidates.filter(Boolean).sort((a, b) => b.modified - a.modified)[0]
257
+ if (!latest) throw error
258
+ text = await readFile(latest.file, 'utf8')
259
+ }
260
+ const stat = JSON.parse(text)
250
261
  const saved = stat.settings
251
262
  if (plain(saved)) {
252
263
  settings.autoQuota = saved.autoQuota === true
253
- settings.showModelDetails = saved.showModelDetails !== false
254
- settings.showModelMatch = saved.showModelMatch === true
255
- settings.showTokenSpeed = saved.showTokenSpeed !== false
256
- settings.advancedModelSelect = saved.advancedModelSelect === true
264
+ settings.showModelDetection = saved.showModelDetection !== false
265
+ settings.advancedModelSelect = saved.advancedModelSelect !== false
266
+ settings.modelReasoning = saved.modelReasoning !== false
257
267
  if (QUOTA_INTERVALS.includes(saved.intervalMinutes)) settings.intervalMinutes = saved.intervalMinutes
258
268
  if (Array.isArray(saved.customQueries)) {
259
269
  settings.customQueries = saved.customQueries.slice(0, 50).flatMap((query) => {
@@ -264,7 +274,7 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
264
274
  }
265
275
  if (plain(stat.snapshots)) snapshots = Object.fromEntries(Object.keys(sources).filter((key) => plain(stat.snapshots[key])).map((key) => [key, stat.snapshots[key]]))
266
276
  if (Number.isFinite(stat.lastRefreshAt) && stat.lastRefreshAt > 0) lastRefreshAt = Math.min(stat.lastRefreshAt, now())
267
- } catch {} // Missing/corrupt settings use conservative defaults.
277
+ } catch {} // Missing/corrupt settings use defaults.
268
278
  })()
269
279
 
270
280
  const persist = () => {
@@ -301,8 +311,9 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
301
311
  if (key !== 'providers') return clone(body)
302
312
  const entries = directory(ctx)
303
313
  const quotas = (Array.isArray(body.quotas) ? body.quotas : []).filter((quota) => {
304
- if (!officialQuotaSpec(quota.provider)?.configuredOnly) return true
305
314
  const entry = entries.find((item) => item.provider === quota.provider)
315
+ if (!entry) return false
316
+ if (!officialQuotaSpec(quota.provider)?.configuredOnly) return true
306
317
  return !!entry?.profile && (settings.customQueries.some((query) => query.provider === quota.provider) || hasBuiltinQuotaQuery(entry.provider, entry.profile))
307
318
  })
308
319
  const shown = new Set(quotas.map((quota) => quota.provider))
@@ -322,7 +333,12 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
322
333
  if (!Object.hasOwn(sources, key)) fail('未知配额来源。')
323
334
  const source = sources[key]
324
335
  const adapter = plain(source)
325
- if (provider && (key !== 'providers' || !directory(ctx).some((entry) => entry.provider === provider))) fail('所选供应商已不存在,请重新选择。')
336
+ if (provider && key !== 'providers') fail('所选供应商已不存在,请重新选择。')
337
+ // A card can outlive the provider's settings. Return the current display
338
+ // snapshot so the client removes it instead of retaining its old balance.
339
+ if (provider && !directory(ctx).some((entry) => entry.provider === provider)) {
340
+ return displaySnapshot(key, snapshots[key] || { ok: true, quotas: [], cached: true })
341
+ }
326
342
  if (!sourceEnabled(key)) return { ok: true, available: false, status: 'not-configured', cached: true }
327
343
  if (!fresh) {
328
344
  const body = snapshots[key] || (key === 'providers' ? { ok: true, quotas: [], cached: true } : { ok: true, available: adapter, status: adapter ? 'unqueried' : 'unavailable', cached: true })
@@ -426,22 +442,18 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
426
442
  if (!QUOTA_INTERVALS.includes(patch.intervalMinutes)) fail('仅支持 10 分钟、1 小时、5 小时或每天。')
427
443
  next.intervalMinutes = patch.intervalMinutes
428
444
  }
429
- if (patch.showModelDetails != null) {
430
- if (typeof patch.showModelDetails !== 'boolean') fail('模型明细开关值无效。')
431
- next.showModelDetails = patch.showModelDetails
432
- }
433
- if (patch.showModelMatch != null) {
434
- if (typeof patch.showModelMatch !== 'boolean') fail('检测模型匹配开关值无效。')
435
- next.showModelMatch = patch.showModelMatch
436
- }
437
- if (patch.showTokenSpeed != null) {
438
- if (typeof patch.showTokenSpeed !== 'boolean') fail('Token 输出速度开关值无效。')
439
- next.showTokenSpeed = patch.showTokenSpeed
445
+ if (patch.showModelDetection != null) {
446
+ if (typeof patch.showModelDetection !== 'boolean') fail('模型检测开关值无效。')
447
+ next.showModelDetection = patch.showModelDetection
440
448
  }
441
449
  if (patch.advancedModelSelect != null) {
442
450
  if (typeof patch.advancedModelSelect !== 'boolean') fail('高级模型选择器开关值无效。')
443
451
  next.advancedModelSelect = patch.advancedModelSelect
444
452
  }
453
+ if (Object.prototype.hasOwnProperty.call(patch, 'modelReasoning')) {
454
+ if (typeof patch.modelReasoning !== 'boolean') fail('模型推理强度开关值无效。')
455
+ next.modelReasoning = patch.modelReasoning
456
+ }
445
457
  } else fail('未知设置操作。')
446
458
  const oldSnapshots = clone(snapshots)
447
459
  if (patch.action) {
@@ -465,7 +477,7 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
465
477
  return {
466
478
  get, update, test, read, tick,
467
479
  providers: async () => ({ ok: true, providers: providerDirectory() }),
468
- dispose: () => { disposed = true },
480
+ dispose: async () => { disposed = true; await updateQueue.catch(() => {}); await Promise.allSettled([allFlight, ...flights.values()]); await saveQueue.catch(() => {}) },
469
481
  }
470
482
  }
471
483
 
@@ -474,6 +486,35 @@ const WORKBUDDY_SOURCES = {
474
486
  'workbuddy-ai': { name: 'WorkBuddy AI', path: '/plugins/dsh-workbuddy-connect/ai/status' },
475
487
  }
476
488
 
489
+ /** Display the selected built-in account; never import credentials on a page read. */
490
+ export function nativeWorkBuddyQuotaSource(getService, provider = 'workbuddy') {
491
+ if (!Object.hasOwn(WORKBUDDY_SOURCES, provider)) fail('未知 WorkBuddy 积分来源。')
492
+ const channelId = provider === 'workbuddy-ai' ? 'workbuddy' : 'buddy'
493
+ const name = provider === 'workbuddy-ai' ? 'WorkBuddy 国际版' : 'WorkBuddy 国内版'
494
+ const selected = service => service?.accountsView(channelId).accounts.find(account => account.selected && account.enabled && account.status === 'ready')
495
+ return {
496
+ provider, name,
497
+ description: '读取“模型接入 → 账号渠道”中已选定的 ' + name + ' 账号积分,无需其他插件。',
498
+ available: () => Boolean(selected(getService())),
499
+ async read() {
500
+ const service = getService()
501
+ const account = selected(service)
502
+ if (!account) return { ok: true, available: false, status: 'not-configured' }
503
+ const reading = (await service.refreshQuota({ channelId, accountId: account.id })).results?.[0]
504
+ const current = selected(service)
505
+ if (!reading || reading.error || !current || current.id !== account.id || current.version !== account.version) {
506
+ return { ok: true, available: true, status: reading?.error?.code === 'AUTH' ? 'signed-out' : 'unavailable' }
507
+ }
508
+ return {
509
+ ok: true, available: true, status: 'signed-in', syncedAt: reading.observedAt,
510
+ credits: { total: reading.total, ...reading.unlimited === true ? { unlimited: true } : {},
511
+ accounts: (reading.kinds ?? []).map(pool => ({ packageName: pool.name, remain: pool.balance, size: pool.total })) },
512
+ models: service.models(channelId).filter(model => model.accountId === account.id && model.billing === 'account-included' && model.callable === true).map(model => ({ id: model.modelId, name: model.displayName, free: true, badges: [] })),
513
+ }
514
+ },
515
+ }
516
+ }
517
+
477
518
  /** Optional plugin registration is detected without probing its HTTP route. */
478
519
  export function workBuddyAvailable(ctx, provider = 'workbuddy') {
479
520
  if (!Object.hasOwn(WORKBUDDY_SOURCES, provider)) return false
@@ -0,0 +1,77 @@
1
+ /** Persist and deduplicate reported detection usage across plugin restarts. */
2
+ import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+ import { getBridge, HUB_SERVICE } from './bridge.js'
5
+
6
+ const keys = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens']
7
+ function sanitize(raw) {
8
+ if (!raw || typeof raw.id !== 'string' || !raw.id || !Number.isFinite(raw.time) || raw.time <= 0
9
+ || typeof raw.selection?.provider !== 'string' || typeof raw.selection?.model !== 'string') throw new Error('Invalid supplemental usage')
10
+ return { id: raw.id.slice(0, 320), time: raw.time,
11
+ selection: { provider: raw.selection.provider.slice(0, 256), model: raw.selection.model.slice(0, 256) },
12
+ usageReported: raw.usageReported === true,
13
+ usage: Object.fromEntries(keys.map(key => [key, Number.isFinite(raw.usage?.[key]) && raw.usage[key] >= 0 ? raw.usage[key] : 0])),
14
+ stop: ['stop', 'max-tokens'].includes(raw.stop) ? raw.stop : 'unknown',
15
+ ttftMs: Number.isFinite(raw.ttftMs) && raw.ttftMs >= 0 ? raw.ttftMs : null,
16
+ decodeMs: Number.isFinite(raw.decodeMs) && raw.decodeMs > 0 ? raw.decodeMs : null }
17
+ }
18
+
19
+ export function createSupplementalUsage(ctx, path, legacyPath, history) {
20
+ let records = new Map(), warning = '', loaded, flight, queue = Promise.resolve(), connected = false
21
+ const merge = rows => { const next = new Map(records); for (const row of rows) { const cleaned = sanitize(row); next.set(cleaned.id, cleaned) }; records = next }
22
+ const persist = () => {
23
+ const text = JSON.stringify({ version: 1, records: [...records.values()] })
24
+ return queue = queue.catch(() => {}).then(async () => {
25
+ await mkdir(dirname(path), { recursive: true })
26
+ await writeFile(path + '.tmp', text, { mode: 0o600 }); await rename(path + '.tmp', path)
27
+ })
28
+ }
29
+ const load = () => loaded ||= (async () => {
30
+ try {
31
+ let document
32
+ try { document = JSON.parse(await readFile(path, 'utf8')); if (document.version !== 1 || !Array.isArray(document.records)) throw new Error('Invalid cache'); merge(document.records) }
33
+ catch (error) {
34
+ if (error.code !== 'ENOENT') throw error
35
+ try { document = JSON.parse(await readFile(legacyPath, 'utf8')) } catch (legacyError) { if (legacyError.code !== 'ENOENT') throw legacyError; return }
36
+ if (document.version !== 1 || !Array.isArray(document.usage)) throw new Error('Invalid legacy history')
37
+ merge(document.usage.flatMap(job => (job.results || []).map((row, index) => ({ ...row, selection: row.selection || job.selection,
38
+ time: row.time || job.finishedAt || job.startedAt, id: `${job.id}:${index}` }))))
39
+ await persist()
40
+ }
41
+ } catch { warning = '检测用量记录无法读取,统计可能不完整。' }
42
+ })()
43
+ return {
44
+ refresh() {
45
+ if (flight) return flight
46
+ return flight = (async () => {
47
+ await load()
48
+ if (history) {
49
+ const before = JSON.stringify([...records.values()])
50
+ merge(history.usageRecords())
51
+ if (before !== JSON.stringify([...records.values()])) await persist()
52
+ warning = history.warning()
53
+ }
54
+ const hub = getBridge(ctx, HUB_SERVICE)
55
+ connected = !!hub
56
+ if (!hub || typeof hub.readSupplementalUsage !== 'function') return
57
+ let timer
58
+ try {
59
+ const snapshot = await Promise.race([
60
+ Promise.resolve().then(() => hub.readSupplementalUsage()),
61
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('Supplemental usage timeout')), 1500); timer.unref?.() }),
62
+ ])
63
+ if (snapshot.protocolVersion !== 1 || !Array.isArray(snapshot.records)) throw new Error('Invalid interface')
64
+ const before = JSON.stringify([...records.values()])
65
+ merge(snapshot.records)
66
+ if (before !== JSON.stringify([...records.values()])) await persist()
67
+ warning = snapshot.warning || ''
68
+ } catch { warning = '补充用量暂时不可用,保留上次成功记录。' }
69
+ finally { clearTimeout(timer) }
70
+ })().finally(() => { flight = null })
71
+ },
72
+ usage: () => [...records.values()],
73
+ warning: () => warning,
74
+ connected: () => connected,
75
+ flush: () => queue.catch(() => {}),
76
+ }
77
+ }
@@ -0,0 +1,51 @@
1
+ /** Persistent usage display preferences. */
2
+ import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ const defaults = () => ({ showModelDetails: false, showTokenSpeed: true })
6
+ export class PreferenceError extends Error {}
7
+ export function validateDisplayPatch(patch) {
8
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new PreferenceError('设置应为 JSON 对象。')
9
+ for (const [key, value] of Object.entries(patch)) {
10
+ if (!['showModelDetails', 'showTokenSpeed'].includes(key) || typeof value !== 'boolean') throw new PreferenceError('用量展示设置无效。')
11
+ }
12
+ }
13
+
14
+ export function createUsagePreferences(path, legacyPath) {
15
+ let settings = defaults(), loaded, queue = Promise.resolve()
16
+ const load = () => loaded ||= (async () => {
17
+ let text
18
+ try { text = await readFile(path, 'utf8') }
19
+ catch (error) {
20
+ if (error.code !== 'ENOENT') throw error
21
+ try { text = await readFile(legacyPath, 'utf8') } catch (legacyError) { if (legacyError.code !== 'ENOENT') throw legacyError }
22
+ }
23
+ if (text) {
24
+ const saved = JSON.parse(text).settings
25
+ if (!saved || typeof saved !== 'object') throw new Error('Invalid usage display preferences')
26
+ for (const key of Object.keys(settings)) if (typeof saved[key] === 'boolean') settings[key] = saved[key]
27
+ }
28
+ })()
29
+ return {
30
+ async get() { await load(); return { ok: true, settings: { ...settings } } },
31
+ update(patch) {
32
+ const operation = queue.catch(() => {}).then(async () => {
33
+ await load()
34
+ validateDisplayPatch(patch)
35
+ const next = { ...settings }
36
+ for (const [key, value] of Object.entries(patch)) {
37
+ if (!Object.hasOwn(next, key) || typeof value !== 'boolean') throw new PreferenceError('用量展示设置无效。')
38
+ next[key] = value
39
+ }
40
+ await mkdir(dirname(path), { recursive: true })
41
+ await writeFile(path + '.tmp', JSON.stringify({ version: 1, settings: next }), { mode: 0o600 })
42
+ await rename(path + '.tmp', path)
43
+ settings = next
44
+ return { ok: true, settings: { ...settings } }
45
+ })
46
+ queue = operation
47
+ return operation
48
+ },
49
+ flush: () => queue.catch(() => {}),
50
+ }
51
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Usage statistics",
4
+ "description": "Usage statistics, provider quotas, model detection and controls for DSH Desktop 0.2.1-alpha.2 and 0.2.0-rc.2."
5
+ }
6
+ }
package/locale/ja.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "利用状況",
4
+ "description": "利用統計、提供元の利用枠、モデル検査、操作設定。DSH Desktop 0.2.1-alpha.2 と 0.2.0-rc.2 に対応。"
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "用量统计",
4
+ "description": "会话用量统计、供应商配额、模型检测与控制设置。适用于 DSH Desktop 0.2.1-alpha.2 和 0.2.0-rc.2。"
5
+ }
6
+ }