@sligqoer/dsh-usage-stats 0.2.0 → 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.
- package/CHANGELOG.md +71 -1
- package/NOTICE +39 -0
- package/README.en.md +187 -0
- package/README.md +93 -263
- package/lib/bridge.js +33 -0
- package/lib/client.js +2321 -1579
- package/lib/index.js +173 -197
- package/lib/lm-fingerprint-engine.js +166 -0
- package/lib/lm-fingerprint-source.js +134 -0
- package/lib/lm-fingerprint-worker.js +31 -0
- package/lib/{model-test-history.js → model-detection-history.js} +23 -30
- package/lib/model-detection.js +255 -0
- package/lib/quota-controls.js +64 -23
- package/lib/supplemental-usage.js +77 -0
- package/lib/usage-preferences.js +51 -0
- package/locale/en.json +6 -0
- package/locale/ja.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +28 -25
- package/screenshots/controls.png +0 -0
- package/screenshots/detection.png +0 -0
- package/screenshots/quotas.png +0 -0
- package/screenshots/usage.png +0 -0
- package/third_party/brand-icons/CHANNEL-ICONS-LICENSE +21 -0
- package/third_party/brand-icons/LICENSE +59 -0
- package/third_party/brand-icons/NOTICE.txt +5 -0
- package/third_party/brand-icons/REACT-ICONS-LICENSE +104 -0
- package/third_party/brand-icons/channel-icons/LICENSE +21 -0
- package/third_party/brand-icons/channel-icons/UPSTREAM.json +6 -0
- package/third_party/brand-icons/lobe-icons/LICENSE +21 -0
- package/third_party/brand-icons/lobe-icons/UPSTREAM.json +16 -0
- package/third_party/brand-icons/react-icons/LICENSE +13 -0
- package/third_party/brand-icons/react-icons/UPSTREAM.json +6 -0
- package/third_party/lm-fingerprint/LICENSE +21 -0
- package/third_party/lm-fingerprint/UPSTREAM.json +32 -0
- package/third_party/lm-fingerprint/core/challenge-browser.mjs +210 -0
- package/third_party/lm-fingerprint/core/detector-codec.mjs +20 -0
- package/third_party/lm-fingerprint/core/fingerprint-core.mjs +121 -0
- package/third_party/lm-fingerprint/core/positional.mjs +64 -0
- package/third_party/lm-fingerprint/core/shared-detector.mjs +132 -0
- package/third_party/lm-fingerprint/data/shared_detector.bin +0 -0
- package/third_party/lm-fingerprint/data/unified_bank.json +826820 -0
- package/README.zh-CN.md +0 -357
- package/lib/fingerprints/KBF-LICENSE.txt +0 -201
- package/lib/fingerprints/NOTICE.txt +0 -6
- package/lib/fingerprints/kbf-202609.json +0 -1
- package/lib/model-capabilities.js +0 -41
- package/lib/model-fingerprints.js +0 -146
- package/lib/model-match.js +0 -407
- package/screenshots/match.png +0 -0
- 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
|
+
}
|
package/lib/quota-controls.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
254
|
-
settings.
|
|
255
|
-
settings.
|
|
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
|
|
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 &&
|
|
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.
|
|
430
|
-
if (typeof patch.
|
|
431
|
-
next.
|
|
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
package/locale/ja.json
ADDED