@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,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
+ }
@@ -27,6 +27,18 @@ const PROVIDERS = {
27
27
 
28
28
  const TTL_MS = 60_000
29
29
 
30
+ /** Only bounded reason codes and HTTP status may cross the host boundary. */
31
+ export function quotaErrorCode(error, status) {
32
+ if (status === 401) return 'authentication'
33
+ if (status === 403) return 'forbidden'
34
+ if (status === 429) return 'rate-limit'
35
+ if (status >= 500) return 'service'
36
+ if (status >= 400) return 'http'
37
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return 'timeout'
38
+ if (error instanceof SyntaxError) return 'parse'
39
+ return 'network'
40
+ }
41
+
30
42
  function nonnegative(value) {
31
43
  if (typeof value !== 'number' && typeof value !== 'string') return null
32
44
  if (typeof value === 'string' && !/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())) return null
@@ -396,14 +408,14 @@ export async function providerKey(credentials, profile, provider, defaultEnv) {
396
408
  /** Resolve official catalog entries and make bounded, single-flight reads. */
397
409
  export function createProviderQuotaReader(ctx, request = fetch) {
398
410
  const cache = new Map()
399
- return async function readProviderQuotas(fresh = false, excluded = []) {
411
+ return async function readProviderQuotas(fresh = false, excluded = [], onlyProvider = null) {
400
412
  const llm = ctx.get('llm')
401
413
  const settings = ctx.get('settings')
402
414
  const credentials = ctx.get('credentials')
403
415
  if (!llm || typeof llm.listConfigurableProviders !== 'function' || typeof settings?.describe !== 'function') return { ok: true, quotas: [] }
404
416
  const directory = llm.listConfigurableProviders()
405
417
  const descriptors = settings.describe({ redactSecrets: true })
406
- const entries = Array.isArray(directory) ? directory.filter((entry) => entry && typeof entry.provider === 'string' && !excluded.includes(entry.provider)) : []
418
+ const entries = Array.isArray(directory) ? directory.filter((entry) => entry && typeof entry.provider === 'string' && !excluded.includes(entry.provider) && (!onlyProvider || entry.provider === onlyProvider)) : []
407
419
  const quotas = await Promise.all(entries.map(async (entry) => {
408
420
  const descriptor = Array.isArray(descriptors) ? descriptors.find((row) => row.ns === entry.settingsNs) : null
409
421
  if (!descriptor) return null
@@ -421,6 +433,7 @@ export function createProviderQuotaReader(ctx, request = fetch) {
421
433
  if (cached?.identity === identity && cached.promise) return cached.promise
422
434
  const promise = (async () => {
423
435
  let metrics = []
436
+ let refreshError = { code: 'parse' }
424
437
  try {
425
438
  const headers = spec.auth === 'alibaba'
426
439
  ? signAlibabaRequest({ url: spec.url, action: 'GetSubscriptionStats', version: '2026-02-10', id: key, secret: auth.secret, token: auth.token, date: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), nonce: randomUUID() })
@@ -432,7 +445,12 @@ export function createProviderQuotaReader(ctx, request = fetch) {
432
445
  signal: AbortSignal.timeout(8000),
433
446
  })
434
447
  if (response.ok) metrics = spec.parse(await response.json())
435
- } catch {
448
+ else {
449
+ refreshError = { code: quotaErrorCode(null, response.status), ...(Number.isInteger(response.status) ? { httpStatus: response.status } : {}) }
450
+ await response.body?.cancel().catch(() => {})
451
+ }
452
+ } catch (error) {
453
+ refreshError = { code: quotaErrorCode(error) }
436
454
  // An optional account-credit read can still succeed for OpenRouter.
437
455
  }
438
456
  if (entry.provider === 'openrouter') {
@@ -452,7 +470,9 @@ export function createProviderQuotaReader(ctx, request = fetch) {
452
470
  }
453
471
  }
454
472
  const name = entry.displayName && entry.displayName !== entry.provider ? entry.displayName : spec.name
455
- return metrics.length ? { provider: entry.provider, name, metrics, syncedAt: Date.now() } : null
473
+ return metrics.length
474
+ ? { provider: entry.provider, name, metrics, syncedAt: Date.now() }
475
+ : { provider: entry.provider, name, metrics: [], status: 'unavailable', refreshError, lastAttemptAt: Date.now() }
456
476
  })()
457
477
  cache.set(entry.provider, { identity, at: 0, result: null, promise })
458
478
  try {
@@ -468,7 +488,8 @@ export function createProviderQuotaReader(ctx, request = fetch) {
468
488
  return null
469
489
  }
470
490
  }))
471
- return { ok: true, quotas: quotas.filter(Boolean) }
491
+ const rows = quotas.filter(Boolean)
492
+ return { ok: true, quotas: rows, partial: rows.some((row) => !!row.refreshError) }
472
493
  }
473
494
  }
474
495
 
@@ -1,8 +1,8 @@
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
- import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth } from './provider-quotas.js'
5
+ import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth, quotaErrorCode } from './provider-quotas.js'
6
6
 
7
7
  export const QUOTA_INTERVALS = [10, 60, 300, 1440]
8
8
  const MAX_BYTES = 1_048_576
@@ -10,15 +10,17 @@ const plain = (value) => value !== null && typeof value === 'object' && !Array.i
10
10
  const clone = (value) => structuredClone(value)
11
11
  /** Only deliberate, credential-free validation messages may reach the renderer. */
12
12
  export class QuotaControlError extends Error {
13
- constructor(message) {
13
+ constructor(message, code = 'invalid', httpStatus) {
14
14
  super(message)
15
15
  this.name = 'QuotaControlError'
16
+ this.code = code
17
+ if (Number.isInteger(httpStatus)) this.httpStatus = httpStatus
16
18
  }
17
19
  }
18
- const fail = (message) => { throw new QuotaControlError(message) }
20
+ const fail = (message, code, status) => { throw new QuotaControlError(message, code, status) }
19
21
 
20
22
  function defaults() {
21
- return { autoQuota: false, intervalMinutes: 60, showModelDetails: true, advancedModelSelect: false, customQueries: [] }
23
+ return { autoQuota: false, intervalMinutes: 10, showModelDetection: true, advancedModelSelect: true, modelReasoning: true, customQueries: [] }
22
24
  }
23
25
 
24
26
  /**
@@ -189,7 +191,7 @@ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fe
189
191
  if (template.auth === 'provider') {
190
192
  if (!allowedOrigins(entry.profile, spec).has(target.origin)) fail('认证查询地址需与所选供应商的地址或官方配额地址同源。')
191
193
  const auth = await customQuotaAuth(ctx, entry.profile, provider, url, template.method, template.body == null ? '' : JSON.stringify(template.body)).catch(() => null)
192
- if (!auth) fail('未找到可用的供应商凭据,请先在模型设置中配置或登录。')
194
+ if (!auth) fail('未找到可用的供应商凭据,请先在模型设置中配置或登录。', 'authentication')
193
195
  if (url.includes('{{teamId}}')) url = auth.url || fail('未配置 xAI 管理凭据和团队 ID。')
194
196
  headers = { ...headers, ...auth.headers }
195
197
  }
@@ -197,21 +199,21 @@ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fe
197
199
  let response
198
200
  try {
199
201
  response = await request(url, { method: template.method, headers, ...(template.body == null ? {} : { body: JSON.stringify(template.body) }), redirect: 'error', signal: AbortSignal.timeout(8000) })
200
- } catch {
201
- fail('查询失败,请检查地址和网络(8 秒超时,不跟随重定向)。')
202
+ } catch (error) {
203
+ fail('查询失败,请检查地址和网络(8 秒超时,不跟随重定向)。', quotaErrorCode(error))
202
204
  }
203
205
  if (!response.ok) {
204
206
  await response.body?.cancel().catch(() => {})
205
- fail('查询返回 HTTP ' + response.status + ',请检查地址和供应商凭据。')
207
+ fail('查询返回 HTTP ' + response.status + ',请检查地址和供应商凭据。', quotaErrorCode(null, response.status), response.status)
206
208
  }
207
209
  const body = await readBoundedJson(response)
208
210
  const metrics = template.response.format === 'official' ? spec.parse(body) : mappedMetrics(body, template.response)
209
- if (!metrics.length) fail('响应中未找到有效配额,请检查 response 的字段路径。')
211
+ if (!metrics.length) fail('响应中未找到有效配额,请检查 response 的字段路径。', 'parse')
210
212
  return { provider, name: entry.name, metrics, syncedAt: Date.now(), custom: true }
211
213
  }
212
214
 
213
215
  /** Source reads are explicit; cached page reads never spend account credit. */
214
- export function createQuotaControls(ctx, { path, sources, request = fetch, now = Date.now }) {
216
+ export function createQuotaControls(ctx, { path, legacyPath, sources, request = fetch, now = Date.now }) {
215
217
  let settings = defaults()
216
218
  let snapshots = {}
217
219
  let lastRefreshAt = 0
@@ -221,6 +223,8 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
221
223
  let allFlight = null
222
224
  const flights = new Map()
223
225
  const tests = new Map()
226
+ const providerTickets = new Map()
227
+ let readTicket = 0
224
228
  let queryRevision = 0
225
229
  let disposed = false
226
230
 
@@ -242,12 +246,24 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
242
246
 
243
247
  const load = () => loaded ||= (async () => {
244
248
  try {
245
- 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)
246
261
  const saved = stat.settings
247
262
  if (plain(saved)) {
248
263
  settings.autoQuota = saved.autoQuota === true
249
- settings.showModelDetails = saved.showModelDetails !== false
250
- settings.advancedModelSelect = saved.advancedModelSelect === true
264
+ settings.showModelDetection = saved.showModelDetection !== false
265
+ settings.advancedModelSelect = saved.advancedModelSelect !== false
266
+ settings.modelReasoning = saved.modelReasoning !== false
251
267
  if (QUOTA_INTERVALS.includes(saved.intervalMinutes)) settings.intervalMinutes = saved.intervalMinutes
252
268
  if (Array.isArray(saved.customQueries)) {
253
269
  settings.customQueries = saved.customQueries.slice(0, 50).flatMap((query) => {
@@ -258,7 +274,7 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
258
274
  }
259
275
  if (plain(stat.snapshots)) snapshots = Object.fromEntries(Object.keys(sources).filter((key) => plain(stat.snapshots[key])).map((key) => [key, stat.snapshots[key]]))
260
276
  if (Number.isFinite(stat.lastRefreshAt) && stat.lastRefreshAt > 0) lastRefreshAt = Math.min(stat.lastRefreshAt, now())
261
- } catch {} // Missing/corrupt settings use conservative defaults.
277
+ } catch {} // Missing/corrupt settings use defaults.
262
278
  })()
263
279
 
264
280
  const persist = () => {
@@ -295,8 +311,9 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
295
311
  if (key !== 'providers') return clone(body)
296
312
  const entries = directory(ctx)
297
313
  const quotas = (Array.isArray(body.quotas) ? body.quotas : []).filter((quota) => {
298
- if (!officialQuotaSpec(quota.provider)?.configuredOnly) return true
299
314
  const entry = entries.find((item) => item.provider === quota.provider)
315
+ if (!entry) return false
316
+ if (!officialQuotaSpec(quota.provider)?.configuredOnly) return true
300
317
  return !!entry?.profile && (settings.customQueries.some((query) => query.provider === quota.provider) || hasBuiltinQuotaQuery(entry.provider, entry.profile))
301
318
  })
302
319
  const shown = new Set(quotas.map((quota) => quota.provider))
@@ -308,38 +325,70 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
308
325
  const balance = mapping && mapping.format !== 'official' ? mapping.metrics.every((metric) => metric.kind === 'amount') : spec?.kind === 'balance'
309
326
  quotas.push({ provider: entry.provider, name: entry.name, kind: balance ? 'balance' : 'subscription', status: queried.has(entry.provider) ? 'unavailable' : 'unqueried', metrics: [] })
310
327
  }
311
- return clone({ ...body, quotas })
328
+ return clone({ ...body, quotas, partial: quotas.some((row) => !!row.refreshError) })
312
329
  }
313
330
 
314
- const read = async (key, fresh = false) => {
331
+ const read = async (key, fresh = false, provider = null) => {
315
332
  await load()
316
333
  if (!Object.hasOwn(sources, key)) fail('未知配额来源。')
317
334
  const source = sources[key]
318
335
  const adapter = plain(source)
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
+ }
319
342
  if (!sourceEnabled(key)) return { ok: true, available: false, status: 'not-configured', cached: true }
320
343
  if (!fresh) {
321
344
  const body = snapshots[key] || (key === 'providers' ? { ok: true, quotas: [], cached: true } : { ok: true, available: adapter, status: adapter ? 'unqueried' : 'unavailable', cached: true })
322
345
  return displaySnapshot(key, adapter ? { ...body, available: true } : body)
323
346
  }
324
- if (flights.has(key)) return flights.get(key)
347
+ const flightKey = key + ':' + (provider || '*')
348
+ if (flights.has(flightKey)) return flights.get(flightKey)
325
349
  const revision = queryRevision
326
350
  const queries = clone(settings.customQueries)
327
- const queriedProviders = key === 'providers' ? configuredQuotaEntries().map((entry) => entry.provider) : []
351
+ const targeted = key === 'providers' ? directory(ctx).filter((entry) => !adapterFor(entry.provider) && (!provider || entry.provider === provider)).map((entry) => entry.provider) : []
352
+ const ticket = ++readTicket
353
+ for (const id of targeted) providerTickets.set(id, ticket)
328
354
  const flight = (async () => {
329
- let body = await (adapter ? source.read() : source(queries.map((query) => query.provider)))
355
+ let body = await (adapter ? source.read() : source(queries.map((query) => query.provider), provider))
330
356
  if (key === 'providers') {
331
- const custom = await Promise.all(queries.filter((query) => !adapterFor(query.provider)).map(async (query) => {
332
- try { return await executeQuotaQuery(ctx, query.provider, query.template, request) } catch { return null }
357
+ const custom = await Promise.all(queries.filter((query) => !adapterFor(query.provider) && (!provider || query.provider === provider)).map(async (query) => {
358
+ try { return await executeQuotaQuery(ctx, query.provider, query.template, request) } catch (error) {
359
+ return { provider: query.provider, name: directory(ctx).find((entry) => entry.provider === query.provider)?.name || query.provider, metrics: [], status: 'unavailable', refreshError: { code: error instanceof QuotaControlError ? error.code : 'network', ...(error instanceof QuotaControlError && error.httpStatus ? { httpStatus: error.httpStatus } : {}) }, lastAttemptAt: now(), custom: true }
360
+ }
333
361
  }))
334
- body = { ok: true, quotas: (body?.quotas || []).concat(custom.filter(Boolean)), queriedProviders }
362
+ const incoming = (body?.quotas || []).concat(custom)
363
+ const previous = snapshots.providers || {}
364
+ const rows = new Map((previous.quotas || []).map((row) => [row.provider, row]))
365
+ const attempted = new Set(targeted)
366
+ for (const row of incoming) {
367
+ if (provider && row.provider !== provider) continue
368
+ // A newer single-provider read wins over an older full refresh.
369
+ if (targeted.includes(row.provider) && providerTickets.get(row.provider) !== ticket) continue
370
+ attempted.delete(row.provider)
371
+ const prior = rows.get(row.provider)
372
+ rows.set(row.provider, row.refreshError && prior?.metrics?.length
373
+ ? { ...prior, stale: true, refreshError: row.refreshError, lastAttemptAt: row.lastAttemptAt || now() }
374
+ : row)
375
+ }
376
+ for (const id of attempted) {
377
+ if (providerTickets.get(id) !== ticket) continue
378
+ const prior = rows.get(id)
379
+ if (prior?.metrics?.length) rows.set(id, { ...prior, stale: true, refreshError: { code: 'authentication' }, lastAttemptAt: now() })
380
+ }
381
+ body = { ok: true, quotas: [...rows.values()], queriedProviders: [...new Set([...(previous.queriedProviders || []), ...targeted])] }
382
+ } else if (['unavailable', 'error'].includes(body?.status) && snapshots[key]?.status === 'signed-in') {
383
+ body = { ...snapshots[key], stale: true, refreshError: { code: 'service' }, lastAttemptAt: now() }
335
384
  }
336
385
  if (key !== 'providers' || revision === queryRevision) {
337
386
  snapshots[key] = body
338
387
  await persist()
339
388
  }
340
389
  return displaySnapshot(key, snapshots[key] || body)
341
- })().finally(() => { if (flights.get(key) === flight) flights.delete(key) })
342
- flights.set(key, flight)
390
+ })().finally(() => { if (flights.get(flightKey) === flight) flights.delete(flightKey) })
391
+ flights.set(flightKey, flight)
343
392
  return flight
344
393
  }
345
394
 
@@ -393,14 +442,18 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
393
442
  if (!QUOTA_INTERVALS.includes(patch.intervalMinutes)) fail('仅支持 10 分钟、1 小时、5 小时或每天。')
394
443
  next.intervalMinutes = patch.intervalMinutes
395
444
  }
396
- if (patch.showModelDetails != null) {
397
- if (typeof patch.showModelDetails !== 'boolean') fail('模型明细开关值无效。')
398
- next.showModelDetails = patch.showModelDetails
445
+ if (patch.showModelDetection != null) {
446
+ if (typeof patch.showModelDetection !== 'boolean') fail('模型检测开关值无效。')
447
+ next.showModelDetection = patch.showModelDetection
399
448
  }
400
449
  if (patch.advancedModelSelect != null) {
401
450
  if (typeof patch.advancedModelSelect !== 'boolean') fail('高级模型选择器开关值无效。')
402
451
  next.advancedModelSelect = patch.advancedModelSelect
403
452
  }
453
+ if (Object.prototype.hasOwnProperty.call(patch, 'modelReasoning')) {
454
+ if (typeof patch.modelReasoning !== 'boolean') fail('模型推理强度开关值无效。')
455
+ next.modelReasoning = patch.modelReasoning
456
+ }
404
457
  } else fail('未知设置操作。')
405
458
  const oldSnapshots = clone(snapshots)
406
459
  if (patch.action) {
@@ -424,7 +477,7 @@ export function createQuotaControls(ctx, { path, sources, request = fetch, now =
424
477
  return {
425
478
  get, update, test, read, tick,
426
479
  providers: async () => ({ ok: true, providers: providerDirectory() }),
427
- dispose: () => { disposed = true },
480
+ dispose: async () => { disposed = true; await updateQueue.catch(() => {}); await Promise.allSettled([allFlight, ...flights.values()]); await saveQueue.catch(() => {}) },
428
481
  }
429
482
  }
430
483
 
@@ -433,6 +486,35 @@ const WORKBUDDY_SOURCES = {
433
486
  'workbuddy-ai': { name: 'WorkBuddy AI', path: '/plugins/dsh-workbuddy-connect/ai/status' },
434
487
  }
435
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
+
436
518
  /** Optional plugin registration is detected without probing its HTTP route. */
437
519
  export function workBuddyAvailable(ctx, provider = 'workbuddy') {
438
520
  if (!Object.hasOwn(WORKBUDDY_SOURCES, provider)) return false