@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.
- package/CHANGELOG.md +135 -1
- package/NOTICE +39 -0
- package/README.en.md +187 -0
- package/README.md +93 -224
- package/lib/bridge.js +33 -0
- package/lib/client.js +2587 -1159
- package/lib/index.js +420 -150
- 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-detection-history.js +86 -0
- package/lib/model-detection.js +255 -0
- package/lib/provider-quotas.js +26 -5
- package/lib/quota-controls.js +112 -30
- 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 -18
- 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 -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
|
+
}
|
package/lib/provider-quotas.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
491
|
+
const rows = quotas.filter(Boolean)
|
|
492
|
+
return { ok: true, quotas: rows, partial: rows.some((row) => !!row.refreshError) }
|
|
472
493
|
}
|
|
473
494
|
}
|
|
474
495
|
|
package/lib/quota-controls.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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.
|
|
250
|
-
settings.advancedModelSelect = saved.advancedModelSelect
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
342
|
-
flights.set(
|
|
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.
|
|
397
|
-
if (typeof patch.
|
|
398
|
-
next.
|
|
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
|