@sligqoer/dsh-usage-stats 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ /** Persistent quota controls, bounded custom queries and host-side scheduling. */
2
+ import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+ import { quotaTemplate, hasBuiltinQuotaQuery, officialQuotaSpec, customQuotaAuth } from './provider-quotas.js'
6
+
7
+ export const QUOTA_INTERVALS = [10, 60, 300, 1440]
8
+ const MAX_BYTES = 1_048_576
9
+ const plain = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
10
+ const clone = (value) => structuredClone(value)
11
+ /** Only deliberate, credential-free validation messages may reach the renderer. */
12
+ export class QuotaControlError extends Error {
13
+ constructor(message) {
14
+ super(message)
15
+ this.name = 'QuotaControlError'
16
+ }
17
+ }
18
+ const fail = (message) => { throw new QuotaControlError(message) }
19
+
20
+ function defaults() {
21
+ return { autoQuota: false, intervalMinutes: 60, showModelDetails: true, advancedModelSelect: false, customQueries: [] }
22
+ }
23
+
24
+ /**
25
+ * The providers the user actually configured on the models page, in the order the
26
+ * model settings list them. `listConfigurableProviders()` returns every provider
27
+ * DSH could configure, which is not what "从已有供应商列表选择" means: a provider
28
+ * counts as configured only once it has a `providers.<id>` entry in its settings
29
+ * namespace, which is the same rule the models page uses to draw its rows.
30
+ */
31
+ function directory(ctx) {
32
+ const entries = ctx.get('llm')?.listConfigurableProviders?.() || []
33
+ const descriptors = ctx.get('settings')?.describe?.({ redactSecrets: true }) || []
34
+ const namespaceOf = (ns) => descriptors.find((row) => row.ns === ns)
35
+ const withProfile = (entry) => namespaceOf(entry.settingsNs)?.value?.providers?.[entry.provider]
36
+ // Settings may be unavailable in some hosts. Filtering on it then would hide
37
+ // every provider, which reads as "nothing to choose" rather than "unknown", so
38
+ // fall back to the declared list instead of showing an empty picker.
39
+ const canFilter = descriptors.some((row) => row && row.value && typeof row.value === 'object' && row.value.providers && typeof row.value.providers === 'object')
40
+ const seen = new Set()
41
+ return entries.filter((entry) => {
42
+ if (!entry || typeof entry.provider !== 'string' || seen.has(entry.provider)) return false
43
+ if (canFilter) {
44
+ const profile = withProfile(entry)
45
+ if (profile === undefined || profile === null) return false
46
+ }
47
+ seen.add(entry.provider)
48
+ return true
49
+ }).map((entry) => ({
50
+ provider: entry.provider,
51
+ name: entry.displayName && entry.displayName !== entry.provider ? entry.displayName : officialQuotaSpec(entry.provider, withProfile(entry))?.name || entry.provider,
52
+ profile: withProfile(entry),
53
+ }))
54
+ }
55
+
56
+ function pathValue(body, path) {
57
+ if (typeof path !== 'string') return undefined
58
+ if (path === '' || path === '$') return body
59
+ const keys = path.replace(/^\$\./, '').replace(/\[(\d+)\]/g, '.$1').split('.')
60
+ let current = body
61
+ for (const key of keys) {
62
+ if (!key || ['__proto__', 'constructor', 'prototype'].includes(key) || current === null || typeof current !== 'object' || !Object.hasOwn(current, key)) return undefined
63
+ current = current[key]
64
+ }
65
+ return current
66
+ }
67
+
68
+ function number(value) {
69
+ if (typeof value !== 'number' && typeof value !== 'string') return null
70
+ if (typeof value === 'string' && !/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/.test(value.trim())) return null
71
+ const n = Number(value)
72
+ return Number.isFinite(n) && n >= 0 ? n : null
73
+ }
74
+
75
+ function mappedMetrics(body, mapping) {
76
+ const rows = mapping.rows ? pathValue(body, mapping.rows) : [body]
77
+ if (!Array.isArray(rows)) return []
78
+ const metrics = []
79
+ for (const row of rows.slice(0, 100)) {
80
+ for (const field of mapping.metrics) {
81
+ const total = number(pathValue(row, field.total))
82
+ const used = number(pathValue(row, field.used))
83
+ const remaining = number(pathValue(row, field.remaining)) ?? (total !== null && used !== null ? Math.max(0, total - used) : null)
84
+ let pct = number(pathValue(row, field.remainingPercent))
85
+ const usedPct = number(pathValue(row, field.usedPercent))
86
+ if (pct === null && usedPct !== null && usedPct <= 100) pct = 100 - usedPct
87
+ if (pct === null && total > 0 && remaining !== null) pct = remaining / total * 100
88
+ const labelValue = field.labelPath ? pathValue(row, field.labelPath) : field.label
89
+ const label = typeof labelValue === 'string' && labelValue ? labelValue.slice(0, 120) : field.label
90
+ const currency = field.currencyPath ? pathValue(row, field.currencyPath) : field.currency
91
+ const reset = pathValue(row, field.resetAt)
92
+ const date = typeof reset !== 'number' && typeof reset !== 'string' ? null : new Date(typeof reset === 'number' && reset < 1e12 ? reset * 1000 : reset)
93
+ if (field.kind === 'window') {
94
+ if (pct === null || pct > 100) continue
95
+ metrics.push({ label, kind: 'window', remainingPercent: pct, remaining, total, resetAt: date && Number.isFinite(date.getTime()) ? date.toISOString() : null })
96
+ } else if (remaining !== null) {
97
+ metrics.push({ label, kind: 'amount', remaining, total, currency: typeof currency === 'string' && /^[A-Z]{3}$/.test(currency) ? currency : undefined })
98
+ }
99
+ }
100
+ }
101
+ return metrics.slice(0, 100)
102
+ }
103
+
104
+ export function validateQuotaTemplate(template, provider, profile) {
105
+ if (!plain(template) || JSON.stringify(template).length > 16_384) fail('查询模板应为 JSON 对象,且不超过 16 KB。')
106
+ const spec = officialQuotaSpec(provider, profile)
107
+ if (typeof template.url !== 'string' || template.url.length > 2048) fail('请填写配额查询 URL。')
108
+ let target
109
+ try { target = new URL(template.url.replace('{{teamId}}', 'team')) } catch { fail('查询 URL 格式无效。') }
110
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)
111
+ if (target.username || target.password || target.hash || !['https:', 'http:'].includes(target.protocol) || (target.protocol === 'http:' && !loopback)) fail('查询 URL 需使用 HTTPS;本机供应商可使用 HTTP。')
112
+ if (/(?:api[-_]?key|access[-_]?token|secret|authorization)/i.test([...target.searchParams.keys()].join(' '))) fail('请使用供应商凭据认证,不要把密钥放入 URL。')
113
+ const method = template.method || 'GET'
114
+ if (!['GET', 'POST'].includes(method)) fail('查询方法仅支持 GET 或 POST。')
115
+ const auth = template.auth || 'provider'
116
+ if (!['provider', 'none'].includes(auth)) fail('auth 仅支持 provider 或 none。')
117
+ const headers = template.headers || {}
118
+ if (!plain(headers) || Object.keys(headers).length > 20) fail('headers 应为 JSON 对象,最多 20 项。')
119
+ for (const [key, value] of Object.entries(headers)) {
120
+ if (!/^[A-Za-z0-9-]+$/.test(key) || /^(authorization|proxy-authorization|cookie|host|connection|content-length|set-cookie)$/i.test(key) || /(?:api[-_]?key|token|secret)/i.test(key)) fail('认证由供应商凭据提供,请移除模板中的密钥或认证请求头。')
121
+ if (typeof value !== 'string' || value.length > 1000 || /[\r\n]/.test(value)) fail('请求头值应为单行文本。')
122
+ }
123
+ if (method === 'GET' && template.body != null) fail('GET 查询不能包含 body。')
124
+ if (template.body != null && !plain(template.body) && !Array.isArray(template.body)) fail('POST body 应为 JSON 对象或数组。')
125
+ if (template.body != null && /"(?:api[-_]?key|access[-_]?token|secret|authorization)"\s*:/i.test(JSON.stringify(template.body))) fail('请使用供应商凭据认证,不要在 body 中填写密钥。')
126
+ const response = template.response
127
+ if (!plain(response)) fail('请填写 response 字段映射。')
128
+ if (response.format === 'official') {
129
+ if (typeof spec?.parse !== 'function') fail('此供应商没有内置解析器,请使用 response.metrics 映射。')
130
+ } else {
131
+ if (!Array.isArray(response.metrics) || !response.metrics.length || response.metrics.length > 20) fail('response.metrics 需包含 1–20 个指标。')
132
+ if (response.rows != null && typeof response.rows !== 'string') fail('response.rows 应为数组字段路径。')
133
+ for (const field of response.metrics) {
134
+ if (!plain(field) || !['amount', 'window'].includes(field.kind) || typeof field.label !== 'string' || !field.label.trim() || field.label.length > 120) fail('每个指标需填写 label 和 kind(amount 或 window)。')
135
+ for (const key of ['remaining', 'total', 'used', 'remainingPercent', 'usedPercent', 'resetAt', 'currencyPath', 'labelPath']) {
136
+ if (field[key] != null && (typeof field[key] !== 'string' || field[key].length > 200)) fail('指标字段路径应为不超过 200 字符的文本。')
137
+ }
138
+ if (field.kind === 'amount' && !field.remaining && !(field.total && field.used)) fail('余额指标需提供 remaining,或 total 与 used 字段路径。')
139
+ if (field.kind === 'window' && !field.remainingPercent && !field.usedPercent && !(field.total && (field.remaining || field.used))) fail('配额指标需提供百分比或总量与剩余量字段路径。')
140
+ }
141
+ }
142
+ return { url: template.url, method, auth, headers: clone(headers), ...(template.body == null ? {} : { body: clone(template.body) }), response: clone(response) }
143
+ }
144
+
145
+ async function readBoundedJson(response) {
146
+ if (Number(response.headers.get('content-length')) > MAX_BYTES) fail('查询响应过大(上限 1 MB)。')
147
+ const reader = response.body?.getReader()
148
+ if (!reader) fail('查询未返回 JSON 数据。')
149
+ const chunks = []
150
+ let size = 0
151
+ try {
152
+ while (true) {
153
+ const { done, value } = await reader.read()
154
+ if (done) break
155
+ size += value.length
156
+ if (size > MAX_BYTES) fail('查询响应过大(上限 1 MB)。')
157
+ chunks.push(value)
158
+ }
159
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'))
160
+ } catch (error) {
161
+ await reader.cancel().catch(() => {})
162
+ if (error?.message === '查询响应过大(上限 1 MB)。') throw error
163
+ fail('查询响应不是有效 JSON,或读取已超时。')
164
+ }
165
+ }
166
+
167
+ function allowedOrigins(profile, spec) {
168
+ const result = new Set()
169
+ for (const url of [profile?.baseURL, spec?.url, ...(Array.isArray(spec?.host) ? spec.host : spec?.host ? [spec.host] : []).map((host) => 'https://' + host)]) {
170
+ try { result.add(new URL(url).origin) } catch {}
171
+ }
172
+ if (spec?.auth === 'management') result.add('https://management-api.x.ai')
173
+ return result
174
+ }
175
+
176
+ export async function executeQuotaQuery(ctx, provider, rawTemplate, request = fetch) {
177
+ const entry = directory(ctx).find((item) => item.provider === provider)
178
+ if (!entry) fail('所选供应商已不存在,请重新选择。')
179
+ const template = validateQuotaTemplate(rawTemplate, provider, entry.profile)
180
+ const spec = officialQuotaSpec(provider, entry.profile)
181
+ let url = template.url
182
+ const target = new URL(url.replace('{{teamId}}', 'team'))
183
+ if (target.protocol === 'http:') {
184
+ let configuredOrigin
185
+ try { configuredOrigin = new URL(entry.profile?.baseURL).origin } catch {}
186
+ if (configuredOrigin !== target.origin) fail('HTTP 本机查询地址需与供应商配置地址同源。')
187
+ }
188
+ let headers = { accept: 'application/json', ...template.headers }
189
+ if (template.auth === 'provider') {
190
+ if (!allowedOrigins(entry.profile, spec).has(target.origin)) fail('认证查询地址需与所选供应商的地址或官方配额地址同源。')
191
+ const auth = await customQuotaAuth(ctx, entry.profile, provider, url, template.method, template.body == null ? '' : JSON.stringify(template.body)).catch(() => null)
192
+ if (!auth) fail('未找到可用的供应商凭据,请先在模型设置中配置或登录。')
193
+ if (url.includes('{{teamId}}')) url = auth.url || fail('未配置 xAI 管理凭据和团队 ID。')
194
+ headers = { ...headers, ...auth.headers }
195
+ }
196
+ if (template.body != null) headers['content-type'] = 'application/json'
197
+ let response
198
+ try {
199
+ 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
+ }
203
+ if (!response.ok) {
204
+ await response.body?.cancel().catch(() => {})
205
+ fail('查询返回 HTTP ' + response.status + ',请检查地址和供应商凭据。')
206
+ }
207
+ const body = await readBoundedJson(response)
208
+ const metrics = template.response.format === 'official' ? spec.parse(body) : mappedMetrics(body, template.response)
209
+ if (!metrics.length) fail('响应中未找到有效配额,请检查 response 的字段路径。')
210
+ return { provider, name: entry.name, metrics, syncedAt: Date.now(), custom: true }
211
+ }
212
+
213
+ /** Source reads are explicit; cached page reads never spend account credit. */
214
+ export function createQuotaControls(ctx, { path, sources, request = fetch, now = Date.now }) {
215
+ let settings = defaults()
216
+ let snapshots = {}
217
+ let lastRefreshAt = 0
218
+ let loaded
219
+ let saveQueue = Promise.resolve()
220
+ let updateQueue = Promise.resolve()
221
+ let allFlight = null
222
+ const flights = new Map()
223
+ const tests = new Map()
224
+ let queryRevision = 0
225
+ let disposed = false
226
+
227
+ const sourceEnabled = (key) => {
228
+ const source = sources[key]
229
+ if (typeof source === 'function') return true
230
+ try { return typeof source?.read === 'function' && source.available() === true } catch { return false }
231
+ }
232
+ const adapters = () => Object.entries(sources).filter(([key, source]) => plain(source) && source.provider && sourceEnabled(key))
233
+ const adapterFor = (provider) => Object.values(sources).find((source) => plain(source) && source.provider === provider)
234
+ const providerDirectory = () => {
235
+ const entries = directory(ctx).map((entry) => {
236
+ const spec = officialQuotaSpec(entry.provider, entry.profile)
237
+ return { provider: entry.provider, name: entry.name, queryType: 'http', hasBuiltinQuery: hasBuiltinQuotaQuery(entry.provider, entry.profile), template: quotaTemplate(entry.provider, entry.profile), ...(spec?.description ? { description: spec.description } : {}) }
238
+ }).filter((entry) => !adapterFor(entry.provider))
239
+ for (const [key, source] of adapters()) entries.push({ provider: source.provider, name: source.name, queryType: 'adapter', source: key, hasBuiltinQuery: true, template: null, description: source.description })
240
+ return entries
241
+ }
242
+
243
+ const load = () => loaded ||= (async () => {
244
+ try {
245
+ const stat = JSON.parse(await readFile(path, 'utf8'))
246
+ const saved = stat.settings
247
+ if (plain(saved)) {
248
+ settings.autoQuota = saved.autoQuota === true
249
+ settings.showModelDetails = saved.showModelDetails !== false
250
+ settings.advancedModelSelect = saved.advancedModelSelect === true
251
+ if (QUOTA_INTERVALS.includes(saved.intervalMinutes)) settings.intervalMinutes = saved.intervalMinutes
252
+ if (Array.isArray(saved.customQueries)) {
253
+ settings.customQueries = saved.customQueries.slice(0, 50).flatMap((query) => {
254
+ if (adapterFor(query?.provider)) return []
255
+ try { return [{ provider: query.provider, template: validateQuotaTemplate(query.template, query.provider, directory(ctx).find((entry) => entry.provider === query.provider)?.profile) }] } catch { return [] }
256
+ }).filter((query) => typeof query.provider === 'string')
257
+ }
258
+ }
259
+ if (plain(stat.snapshots)) snapshots = Object.fromEntries(Object.keys(sources).filter((key) => plain(stat.snapshots[key])).map((key) => [key, stat.snapshots[key]]))
260
+ if (Number.isFinite(stat.lastRefreshAt) && stat.lastRefreshAt > 0) lastRefreshAt = Math.min(stat.lastRefreshAt, now())
261
+ } catch {} // Missing/corrupt settings use conservative defaults.
262
+ })()
263
+
264
+ const persist = () => {
265
+ const payload = JSON.stringify({ settings, snapshots, lastRefreshAt })
266
+ const next = saveQueue.catch(() => {}).then(async () => {
267
+ await mkdir(dirname(path), { recursive: true })
268
+ const temp = path + '.' + randomUUID() + '.tmp'
269
+ await writeFile(temp, payload, { encoding: 'utf8', mode: 0o600 })
270
+ await rename(temp, path)
271
+ })
272
+ saveQueue = next
273
+ return next
274
+ }
275
+
276
+ const get = async () => {
277
+ await load()
278
+ return { ok: true, settings: clone(settings), lastRefreshAt, nextRefreshAt: settings.autoQuota ? (lastRefreshAt ? lastRefreshAt + settings.intervalMinutes * 60_000 : now()) : null }
279
+ }
280
+
281
+ const configuredQuotaEntries = () => directory(ctx).filter((entry) => {
282
+ if (!entry.profile || typeof entry.profile !== 'object') return false
283
+ const spec = officialQuotaSpec(entry.provider, entry.profile)
284
+ const custom = settings.customQueries.find((query) => query.provider === entry.provider)
285
+ if (custom) {
286
+ const mapping = custom.template.response
287
+ return spec?.showUnqueried === true || (mapping.format === 'official' ? spec?.kind === 'balance' : mapping.metrics.every((metric) => metric.kind === 'amount'))
288
+ }
289
+ return (spec?.kind === 'balance' || spec?.showUnqueried) && hasBuiltinQuotaQuery(entry.provider, entry.profile)
290
+ })
291
+
292
+ // These rows describe configuration, not a provider response. Build them on
293
+ // each cached read so newly added model providers appear without a probe.
294
+ const displaySnapshot = (key, body) => {
295
+ if (key !== 'providers') return clone(body)
296
+ const entries = directory(ctx)
297
+ const quotas = (Array.isArray(body.quotas) ? body.quotas : []).filter((quota) => {
298
+ if (!officialQuotaSpec(quota.provider)?.configuredOnly) return true
299
+ const entry = entries.find((item) => item.provider === quota.provider)
300
+ return !!entry?.profile && (settings.customQueries.some((query) => query.provider === quota.provider) || hasBuiltinQuotaQuery(entry.provider, entry.profile))
301
+ })
302
+ const shown = new Set(quotas.map((quota) => quota.provider))
303
+ const queried = new Set(body.queriedProviders || [])
304
+ for (const entry of configuredQuotaEntries()) {
305
+ if (shown.has(entry.provider)) continue
306
+ const spec = officialQuotaSpec(entry.provider, entry.profile)
307
+ const mapping = settings.customQueries.find((query) => query.provider === entry.provider)?.template.response
308
+ const balance = mapping && mapping.format !== 'official' ? mapping.metrics.every((metric) => metric.kind === 'amount') : spec?.kind === 'balance'
309
+ quotas.push({ provider: entry.provider, name: entry.name, kind: balance ? 'balance' : 'subscription', status: queried.has(entry.provider) ? 'unavailable' : 'unqueried', metrics: [] })
310
+ }
311
+ return clone({ ...body, quotas })
312
+ }
313
+
314
+ const read = async (key, fresh = false) => {
315
+ await load()
316
+ if (!Object.hasOwn(sources, key)) fail('未知配额来源。')
317
+ const source = sources[key]
318
+ const adapter = plain(source)
319
+ if (!sourceEnabled(key)) return { ok: true, available: false, status: 'not-configured', cached: true }
320
+ if (!fresh) {
321
+ const body = snapshots[key] || (key === 'providers' ? { ok: true, quotas: [], cached: true } : { ok: true, available: adapter, status: adapter ? 'unqueried' : 'unavailable', cached: true })
322
+ return displaySnapshot(key, adapter ? { ...body, available: true } : body)
323
+ }
324
+ if (flights.has(key)) return flights.get(key)
325
+ const revision = queryRevision
326
+ const queries = clone(settings.customQueries)
327
+ const queriedProviders = key === 'providers' ? configuredQuotaEntries().map((entry) => entry.provider) : []
328
+ const flight = (async () => {
329
+ let body = await (adapter ? source.read() : source(queries.map((query) => query.provider)))
330
+ 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 }
333
+ }))
334
+ body = { ok: true, quotas: (body?.quotas || []).concat(custom.filter(Boolean)), queriedProviders }
335
+ }
336
+ if (key !== 'providers' || revision === queryRevision) {
337
+ snapshots[key] = body
338
+ await persist()
339
+ }
340
+ return displaySnapshot(key, snapshots[key] || body)
341
+ })().finally(() => { if (flights.get(key) === flight) flights.delete(key) })
342
+ flights.set(key, flight)
343
+ return flight
344
+ }
345
+
346
+ const tick = async () => {
347
+ await load()
348
+ if (disposed || !settings.autoQuota) return
349
+ if (allFlight) return allFlight
350
+ if (lastRefreshAt && now() < lastRefreshAt + settings.intervalMinutes * 60_000) return
351
+ lastRefreshAt = now()
352
+ allFlight = (async () => {
353
+ await Promise.allSettled(Object.keys(sources).filter(sourceEnabled).map((key) => read(key, true)))
354
+ await persist()
355
+ })().finally(() => { allFlight = null })
356
+ return allFlight
357
+ }
358
+
359
+ const test = async (provider, raw) => {
360
+ if (adapterFor(provider)) fail('此来源使用内置适配器,无需填写 HTTP 查询模板。')
361
+ const template = validateQuotaTemplate(raw, provider, directory(ctx).find((entry) => entry.provider === provider)?.profile)
362
+ const quota = await executeQuotaQuery(ctx, provider, template, request)
363
+ for (const [id, result] of tests) if (now() - result.at > 900_000) tests.delete(id)
364
+ if (tests.size >= 50) tests.delete(tests.keys().next().value)
365
+ const testId = randomUUID()
366
+ tests.set(testId, { provider, template, quota, at: now() })
367
+ return { ok: true, testId, quota }
368
+ }
369
+
370
+ const update = (patch) => {
371
+ const operation = updateQueue.catch(() => {}).then(async () => {
372
+ await load()
373
+ if (!plain(patch)) fail('设置应为 JSON 对象。')
374
+ const previous = clone(settings)
375
+ const next = clone(settings)
376
+ let tested
377
+ if (patch.action === 'save-query') {
378
+ if (adapterFor(patch.provider)) fail('此来源使用内置适配器,无需填写 HTTP 查询模板。')
379
+ const template = validateQuotaTemplate(patch.template, patch.provider, directory(ctx).find((entry) => entry.provider === patch.provider)?.profile)
380
+ tested = tests.get(patch.testId)
381
+ if (!tested || now() - tested.at > 900_000 || tested.provider !== patch.provider || JSON.stringify(tested.template) !== JSON.stringify(template)) fail('模板已修改或测试已过期,请重新测试后确认。')
382
+ next.customQueries = next.customQueries.filter((query) => query.provider !== patch.provider).concat({ provider: patch.provider, template })
383
+ if (next.customQueries.length > 50) fail('自定义查询最多支持 50 个供应商。')
384
+ } else if (patch.action === 'remove-query') {
385
+ next.customQueries = next.customQueries.filter((query) => query.provider !== patch.provider)
386
+ } else if (patch.action == null) {
387
+ if (patch.autoQuota != null) {
388
+ if (typeof patch.autoQuota !== 'boolean') fail('自动查询开关值无效。')
389
+ if (patch.autoQuota && !settings.autoQuota && patch.acknowledgeCost !== true) fail('请先确认自动查询可能会消耗少量余额。')
390
+ next.autoQuota = patch.autoQuota
391
+ }
392
+ if (patch.intervalMinutes != null) {
393
+ if (!QUOTA_INTERVALS.includes(patch.intervalMinutes)) fail('仅支持 10 分钟、1 小时、5 小时或每天。')
394
+ next.intervalMinutes = patch.intervalMinutes
395
+ }
396
+ if (patch.showModelDetails != null) {
397
+ if (typeof patch.showModelDetails !== 'boolean') fail('模型明细开关值无效。')
398
+ next.showModelDetails = patch.showModelDetails
399
+ }
400
+ if (patch.advancedModelSelect != null) {
401
+ if (typeof patch.advancedModelSelect !== 'boolean') fail('高级模型选择器开关值无效。')
402
+ next.advancedModelSelect = patch.advancedModelSelect
403
+ }
404
+ } else fail('未知设置操作。')
405
+ const oldSnapshots = clone(snapshots)
406
+ if (patch.action) {
407
+ queryRevision++
408
+ const rows = (snapshots.providers?.quotas || []).filter((quota) => quota.provider !== patch.provider)
409
+ if (tested) rows.push(tested.quota)
410
+ snapshots.providers = { ok: true, quotas: rows, queriedProviders: (snapshots.providers?.queriedProviders || []).filter((provider) => provider !== patch.provider) }
411
+ }
412
+ settings = next
413
+ const oldLastRefreshAt = lastRefreshAt
414
+ if (!previous.autoQuota && next.autoQuota) lastRefreshAt = 0
415
+ try { await persist() } catch { settings = previous; snapshots = oldSnapshots; lastRefreshAt = oldLastRefreshAt; fail('设置保存失败,请检查存储目录权限。') }
416
+ if (tested) tests.delete(patch.testId)
417
+ if (settings.autoQuota) void tick().catch(() => {})
418
+ return get()
419
+ })
420
+ updateQueue = operation
421
+ return operation
422
+ }
423
+
424
+ return {
425
+ get, update, test, read, tick,
426
+ providers: async () => ({ ok: true, providers: providerDirectory() }),
427
+ dispose: () => { disposed = true },
428
+ }
429
+ }
430
+
431
+ const WORKBUDDY_SOURCES = {
432
+ workbuddy: { name: 'WorkBuddy', path: '/plugins/dsh-workbuddy-connect/status' },
433
+ 'workbuddy-ai': { name: 'WorkBuddy AI', path: '/plugins/dsh-workbuddy-connect/ai/status' },
434
+ }
435
+
436
+ /** Optional plugin registration is detected without probing its HTTP route. */
437
+ export function workBuddyAvailable(ctx, provider = 'workbuddy') {
438
+ if (!Object.hasOwn(WORKBUDDY_SOURCES, provider)) return false
439
+ const port = ctx.get('webServer')?.port
440
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return false
441
+ try {
442
+ return (ctx.get('settings')?.describe?.({ redactSecrets: true }) || []).some((row) => row?.ns === provider) ||
443
+ (ctx.get('llm')?.listProviders?.() || []).some((row) => row?.id === provider) ||
444
+ (ctx.get('llm')?.listConfigurableProviders?.() || []).some((row) => row?.provider === provider)
445
+ } catch { return false }
446
+ }
447
+
448
+ export function workBuddyQuotaSource(ctx, request = fetch, provider = 'workbuddy') {
449
+ if (!Object.hasOwn(WORKBUDDY_SOURCES, provider)) fail('未知 WorkBuddy 积分来源。')
450
+ const source = WORKBUDDY_SOURCES[provider]
451
+ return {
452
+ provider, name: source.name,
453
+ description: '由已启用的 WorkBuddy Connect 插件读取 ' + source.name + ' 账号与积分,无需填写查询模板。',
454
+ available: () => workBuddyAvailable(ctx, provider), read: () => readWorkBuddyQuota(ctx, request, provider),
455
+ }
456
+ }
457
+
458
+ /** Only display fields from WorkBuddy are retained in our quota cache. */
459
+ export async function readWorkBuddyQuota(ctx, request = fetch, provider = 'workbuddy') {
460
+ if (!workBuddyAvailable(ctx, provider)) return { ok: true, available: false, status: 'not-configured' }
461
+ const port = ctx.get('webServer')?.port
462
+ const source = WORKBUDDY_SOURCES[provider]
463
+ let body
464
+ try {
465
+ const response = await request('http://127.0.0.1:' + port + source.path, { headers: { accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(12_000) })
466
+ if (!response.ok) return { ok: true, available: true, status: 'unavailable' }
467
+ body = await readBoundedJson(response)
468
+ } catch { return { ok: true, available: true, status: 'unavailable' } }
469
+ if (!plain(body)) return { ok: true, available: true, status: 'unavailable' }
470
+ if (body.status === 'signed-out') return { ok: true, available: true, status: 'signed-out' }
471
+ if (body.status !== 'signed-in') return { ok: true, available: true, status: 'unavailable' }
472
+ if (!plain(body.credits) || number(body.credits.total) === null) return { ok: true, available: true, status: 'unavailable' }
473
+ const text = (value) => typeof value === 'string' ? value.slice(0, 160) : ''
474
+ return {
475
+ ok: true, available: true, status: 'signed-in', syncedAt: Date.now(),
476
+ credits: { total: number(body.credits.total), accounts: (Array.isArray(body.credits.accounts) ? body.credits.accounts : []).slice(0, 200).filter(plain).map((row) => ({ packageName: text(row.packageName), remain: number(row.remain), size: number(row.size) })) },
477
+ models: (Array.isArray(body.models) ? body.models : []).slice(0, 200).filter(plain).map((row) => ({ id: text(row.id), name: text(row.name), free: row.free === true, badges: (Array.isArray(row.badges) ? row.badges : []).filter((tag) => typeof tag === 'string').slice(0, 10).map(text), credits: typeof row.credits === 'string' || typeof row.credits === 'number' ? text(String(row.credits)) : undefined })),
478
+ }
479
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@sligqoer/dsh-usage-stats",
3
+ "description": "DSH Desktop token statistics with range filters, an incremental session corpus, and connected quota panels. Desktop profile only.",
4
+ "version": "0.1.2",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Jockjrop/dsh-usage-stats.git"
9
+ },
10
+ "homepage": "https://github.com/Jockjrop/dsh-usage-stats#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/Jockjrop/dsh-usage-stats/issues"
13
+ },
14
+ "main": "lib/index.js",
15
+ "exports": {
16
+ ".": "./lib/index.js",
17
+ "./client": "./lib/client.js",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "files": [
21
+ "lib/index.js",
22
+ "lib/client.js",
23
+ "lib/provider-quotas.js",
24
+ "lib/quota-controls.js",
25
+ "cordis.patch.yml",
26
+ "README.md",
27
+ "README.zh-CN.md",
28
+ "CHANGELOG.md",
29
+ "screenshots/usage.png",
30
+ "screenshots/quotas.png",
31
+ "screenshots/controls.png",
32
+ "screenshots/icon.svg",
33
+ "LICENSE"
34
+ ],
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ },
39
+ "client": {
40
+ "inject": [],
41
+ "platform": "web"
42
+ }
43
+ },
44
+ "dependencies": {},
45
+ "publishConfig": {
46
+ "access": "public",
47
+ "registry": "https://registry.npmjs.org/"
48
+ },
49
+ "scripts": {
50
+ "build": "node scripts/build.mjs",
51
+ "prepack": "npm run build",
52
+ "release": "node scripts/build-release.mjs",
53
+ "publish:release": "node scripts/publish-npm.mjs",
54
+ "test": "node test/consistency.test.mjs && node --test test/desktop.test.mjs test/usage.test.mjs test/client.test.mjs test/client-locale-quota.test.mjs test/provider-quotas.test.mjs test/quota-controls.test.mjs test/privacy.test.mjs",
55
+ "preview:heatmap": "node scripts/build-heatmap-preview.mjs"
56
+ },
57
+ "license": "Apache-2.0"
58
+ }
Binary file
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 80" fill="none">
2
+ <rect width="80" height="80" rx="20" fill="#2563eb"/>
3
+ <path d="M21 58h39" stroke="white" stroke-width="4" stroke-linecap="round"/>
4
+ <path d="M27 48V37m13 11V29m13 19V21" stroke="white" stroke-width="7" stroke-linecap="round"/>
5
+ </svg>
Binary file
Binary file