dsh-all-usage 1.0.4 → 1.0.5

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/lib/index.js CHANGED
@@ -1,630 +1,650 @@
1
- // dsh-all-usage 插件 Host 半(永久版)
2
- // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
3
- import { randomBytes, timingSafeEqual } from 'node:crypto'
4
-
5
- const name = 'dsh-all-usage'
6
- const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
7
-
8
- // webServer route handlers do not inherit the connection API fence; keep this plugin
9
- // local and require a browser-originated capability for state-changing reads/writes.
10
- function requestHeader(req, name) {
11
- const headers = req && req.headers
12
- if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
13
- const value = headers[name.toLowerCase()]
14
- if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
15
- return typeof value === 'string' ? value : undefined
16
- }
17
-
18
- function isLoopbackHostname(hostname) {
19
- if (hostname === 'localhost' || hostname === '[::1]') return true
20
- const parts = hostname.split('.')
21
- return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
- }
23
-
24
- function isTrustedLocalApiRequest(req, requireOrigin) {
25
- const host = requestHeader(req, 'host')
26
- if (host === undefined) return false
27
- let hostUrl
28
- try {
29
- hostUrl = new URL('http://' + host)
30
- } catch (err) {
31
- return false
32
- }
33
- if (!isLoopbackHostname(hostUrl.hostname)) return false
34
- if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
35
- const origin = requestHeader(req, 'origin')
36
- if (origin === undefined) return requireOrigin !== true
37
- try {
38
- const originUrl = new URL(origin)
39
- return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
40
- } catch (err) {
41
- return false
42
- }
43
- }
44
-
45
- function hasWriteToken(req, expected) {
46
- const actual = requestHeader(req, 'x-all-usage-request-token')
47
- if (typeof actual !== 'string' || typeof expected !== 'string') return false
48
- const actualBytes = Buffer.from(actual)
49
- const expectedBytes = Buffer.from(expected)
50
- return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
51
- }
52
-
53
- function sendJson(res, code, value) {
54
- res.statusCode = code
55
- res.setHeader('content-type', 'application/json; charset=utf-8')
56
- res.setHeader('cache-control', 'no-store')
57
- res.end(JSON.stringify(value))
58
- }
59
-
60
- function readBody(req, maxBytes) {
61
- return new Promise((resolve) => {
62
- const chunks = []
63
- let size = 0
64
- req.on('data', (chunk) => {
65
- size += chunk.length
66
- if (size <= maxBytes) chunks.push(chunk)
67
- })
68
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
69
- req.on('error', () => resolve(''))
70
- })
71
- }
72
-
73
- function apply(ctx) {
74
- const credentials = ctx.get('credentials')
75
- const settings = ctx.get('settings')
76
- const storage = ctx.get('storage')
77
- const webServer = ctx.get('webServer')
78
-
79
- // ---------- owned aggregation state ----------
80
- const wsMeta = new Map()
81
- const pathIndex = new Map()
82
- const memberOf = new Map()
83
- const byDay = new Map()
84
- const byDayUtc = new Map()
85
- const perWorkspace = new Map()
86
- const perModel = new Map()
87
- // One canonical usage contribution per session turn/step. This makes retries and
88
- // replacement messages update a logical model call instead of double-counting it.
89
- const usageByStep = new Map()
90
- const sessionModel = new Map()
91
- const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
92
- const sessionCount = new Set()
93
- const sessionSeq = new Map()
94
- const chains = new Map()
95
- const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
96
- const aliases = {}
97
- let kvUnit = null
98
- let aliasWriteChain = Promise.resolve()
99
- let balanceCache = { fetchedAt: 0, payload: null }
100
- const requestToken = randomBytes(32).toString('base64url')
101
-
102
- const DAY_MS = 86400000
103
- const WEEKS = 53
104
-
105
- function dayKey(ms) {
106
- const d = new Date(ms)
107
- return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
108
- }
109
- function dayKeyUtc(ms) {
110
- const d = new Date(ms)
111
- return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
112
- }
113
- function cutoffKey() {
114
- return dayKey(Date.now() - WEEKS * 7 * DAY_MS)
115
- }
116
- function cutoffKeyUtc() {
117
- return dayKeyUtc(Date.now() - WEEKS * 7 * DAY_MS)
118
- }
119
- function num(v) {
120
- return typeof v === 'number' && Number.isFinite(v) ? v : 0
121
- }
122
- function ensureDay(dayMap, date) {
123
- let day = dayMap.get(date)
124
- if (day === undefined) {
125
- day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map() }
126
- dayMap.set(date, day)
127
- }
128
- return day
129
- }
130
- function ensureWs(wsId) {
131
- let ws = perWorkspace.get(wsId)
132
- if (ws === undefined) {
133
- ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
134
- perWorkspace.set(wsId, ws)
135
- }
136
- return ws
137
- }
138
- function ensureDayWs(day, wsId) {
139
- let w = day.byWs.get(wsId)
140
- if (w === undefined) {
141
- w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
142
- day.byWs.set(wsId, w)
143
- }
144
- return w
145
- }
146
- function ensureModel(model) {
147
- let item = perModel.get(model)
148
- if (item === undefined) {
149
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
150
- perModel.set(model, item)
151
- }
152
- return item
153
- }
154
- function ensureDayModel(day, model) {
155
- let item = day.byModel.get(model)
156
- if (item === undefined) {
157
- item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
158
- day.byModel.set(model, item)
159
- }
160
- return item
161
- }
162
- function usageValues(usage) {
163
- return {
164
- input: num(usage && usage.inputTokens),
165
- output: num(usage && usage.outputTokens),
166
- cacheRead: num(usage && usage.cacheReadTokens),
167
- cacheWrite: num(usage && usage.cacheWriteTokens),
168
- reasoning: num(usage && usage.reasoningTokens),
169
- }
170
- }
171
- function adjustValues(target, values, direction) {
172
- target.input += values.input * direction
173
- target.output += values.output * direction
174
- target.cacheRead += values.cacheRead * direction
175
- target.cacheWrite += values.cacheWrite * direction
176
- target.reasoning += values.reasoning * direction
177
- }
178
- function noValues(target) {
179
- return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
180
- }
181
- function adjustDay(dayMap, date, wsId, values, modelId, direction) {
182
- const day = ensureDay(dayMap, date)
183
- adjustValues(day.tokens, values, direction)
184
- const dayWs = ensureDayWs(day, wsId)
185
- adjustValues(dayWs, values, direction)
186
- if (noValues(dayWs)) day.byWs.delete(wsId)
187
- const dayModel = ensureDayModel(day, modelId)
188
- dayModel.calls += direction
189
- adjustValues(dayModel, values, direction)
190
- if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(modelId)
191
- }
192
- function adjustUsage(wsId, time, values, modelId, direction) {
193
- const modelTotals = ensureModel(modelId)
194
- modelTotals.calls += direction
195
- adjustValues(modelTotals, values, direction)
196
- if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(modelId)
197
- adjustValues(totals, values, direction)
198
- const ws = ensureWs(wsId)
199
- adjustValues(ws, values, direction)
200
- const date = dayKey(time)
201
- if (date >= cutoffKey()) adjustDay(byDay, date, wsId, values, modelId, direction)
202
- const utcDate = dayKeyUtc(time)
203
- if (utcDate >= cutoffKeyUtc()) adjustDay(byDayUtc, utcDate, wsId, values, modelId, direction)
204
- }
205
- function usageStepKey(sid, data, seq) {
206
- const turn = data && typeof data.turn === 'number' ? data.turn : null
207
- const step = data && typeof data.step === 'number' ? data.step : null
208
- if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
209
- return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
210
- }
211
- function addUsage(wsId, time, usage, model, sid, data, seq) {
212
- const values = usageValues(usage)
213
- const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
214
- const eventSeq = typeof seq === 'number' ? seq : -1
215
- const key = usageStepKey(sid, data, seq)
216
- const previous = usageByStep.get(key)
217
- // A late replay of an older raw event cannot replace the canonical later step.
218
- if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
219
- if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.modelId, -1)
220
- const next = { seq: eventSeq, wsId, time, values, modelId }
221
- usageByStep.set(key, next)
222
- adjustUsage(wsId, time, values, modelId, 1)
223
- }
224
- function addDayTurn(dayMap, date, wsId) {
225
- const day = ensureDay(dayMap, date)
226
- day.turns += 1
227
- day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
228
- }
229
- function addTurn(wsId, time) {
230
- ensureWs(wsId).turns += 1
231
- totals.turns += 1
232
- const date = dayKey(time)
233
- if (date >= cutoffKey()) addDayTurn(byDay, date, wsId)
234
- const utcDate = dayKeyUtc(time)
235
- if (utcDate >= cutoffKeyUtc()) addDayTurn(byDayUtc, utcDate, wsId)
236
- }
237
- function routeLabel(route) {
238
- if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
239
- return undefined
240
- }
241
- function modelFromRoute(data) {
242
- return routeLabel(data) || routeLabel(data && data.header && data.header.config)
243
- }
244
- function modelFromMessage(data, fallback) {
245
- return routeLabel(data && data.message && data.message.source) || fallback
246
- }
247
- function foldEvent(wsId, time, type, data, sid, seq) {
248
- if (type === 'request/context' || type === 'request/header') {
249
- const model = modelFromRoute(data)
250
- if (model !== undefined) sessionModel.set(sid, model)
251
- } else if (type === 'turn/end') addTurn(wsId, time)
252
- else if (type === 'assistant/message' && data && data.usage) addUsage(wsId, time, data.usage, modelFromMessage(data, sessionModel.get(sid)), sid, data, seq)
253
- }
254
- function foldEvents(wsId, events, fromSeq, sid) {
255
- for (const ev of events) {
256
- if (fromSeq !== undefined) {
257
- const s = typeof ev.seq === 'number' ? ev.seq : -1
258
- if (s <= fromSeq) continue
259
- }
260
- if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq)
261
- }
262
- }
263
- function lastSeqOf(events) {
264
- let last = 0
265
- for (const ev of events) {
266
- const s = typeof ev.seq === 'number' ? ev.seq : -1
267
- if (s > last) last = s
268
- }
269
- return last
270
- }
271
- function enqueue(sid, task) {
272
- const prev = chains.get(sid) || Promise.resolve()
273
- const next = prev.then(() => task(), () => task())
274
- chains.set(sid, next)
275
- return next
276
- }
277
- function wsForLiveSession(session, sid) {
278
- let wsId = memberOf.get(sid)
279
- if (wsId !== undefined) return wsId
280
- const header = session && session.header
281
- const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
282
- if (cwd === '') return undefined
283
- wsId = pathIndex.get(cwd)
284
- if (wsId !== undefined) memberOf.set(sid, wsId)
285
- return wsId
286
- }
287
- async function processLiveEvent(sid, wsId, event) {
288
- const seq = typeof event.seq === 'number' ? event.seq : -1
289
- const last = sessionSeq.get(sid)
290
- if (last === undefined) {
291
- try {
292
- const snap = await ctx.sessionQuery.readSession(sid)
293
- if (snap && Array.isArray(snap.events)) {
294
- foldEvents(wsId, snap.events, undefined, sid)
295
- sessionSeq.set(sid, lastSeqOf(snap.events))
296
- sessionCount.add(sid)
297
- }
298
- } catch (err) { /* retry on the next event */ }
299
- return
300
- }
301
- if (seq <= last) return
302
- if (seq > last + 1) {
303
- try {
304
- const snap = await ctx.sessionQuery.readSession(sid)
305
- if (snap && Array.isArray(snap.events)) {
306
- foldEvents(wsId, snap.events, last, sid)
307
- sessionSeq.set(sid, lastSeqOf(snap.events))
308
- }
309
- } catch (err) { /* keep last; retry later */ }
310
- return
311
- }
312
- foldEvent(wsId, event.time, event.type, event.data, sid, event.seq)
313
- sessionSeq.set(sid, seq)
314
- sessionCount.add(sid)
315
- }
316
-
317
- // ---------- baseline scan over durable logs ----------
318
- async function runBaseline() {
319
- if (scan.started) return
320
- scan.started = true
321
- try {
322
- const workspaces = ctx.workspaceRegistry.list()
323
- for (const w of workspaces) {
324
- const id = w && w.id
325
- const path = w && typeof w.path === 'string' ? w.path : ''
326
- const title = w && typeof w.title === 'string' ? w.title : ''
327
- if (id === undefined) continue
328
- wsMeta.set(id, { id, title, path })
329
- if (path !== '') pathIndex.set(path, id)
330
- if (w && Array.isArray(w.sessionIds)) {
331
- for (const sid of w.sessionIds) memberOf.set(sid, id)
332
- }
333
- }
334
- } catch (err) {
335
- console.error('[all-usage] workspace list failed:', err)
336
- }
337
- let records = []
338
- try {
339
- records = await ctx.sessionQuery.listSessions()
340
- } catch (err) {
341
- console.error('[all-usage] session list failed:', err)
342
- }
343
- scan.total = Array.isArray(records) ? records.length : 0
344
- for (const record of records) {
345
- if (record === undefined || record === null || record.header === undefined) {
346
- scan.scanned += 1
347
- continue
348
- }
349
- const sid = record.header.id
350
- const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
351
- const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
352
- if (sid === undefined || wsId === undefined) {
353
- scan.scanned += 1
354
- continue
355
- }
356
- await enqueue(sid, async () => {
357
- try {
358
- if (sessionSeq.has(sid)) return
359
- const snap = await ctx.sessionQuery.readSession(sid)
360
- if (snap && Array.isArray(snap.events)) {
361
- foldEvents(wsId, snap.events, undefined, sid)
362
- sessionSeq.set(sid, lastSeqOf(snap.events))
363
- sessionCount.add(sid)
364
- }
365
- } catch (err) {
366
- sessionSeq.set(sid, -1)
367
- scan.failed += 1
368
- } finally {
369
- scan.scanned += 1
370
- }
371
- })
372
- await ctx.timeout(0)
373
- }
374
- scan.done = true
375
- }
376
-
377
- // ---------- live feed ----------
378
- ctx.on('session/event', (session, event) => {
379
- if (event === undefined || event === null) return
380
- const type = event.type
381
- if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
382
- const sid = session && session.id
383
- if (typeof sid !== 'string') return
384
- const wsId = wsForLiveSession(session, sid)
385
- if (wsId === undefined) return
386
- enqueue(sid, () => processLiveEvent(sid, wsId, event))
387
- })
388
-
389
- // ---------- workspace aliases (durable, schema-free KV unit) ----------
390
- async function loadAliases() {
391
- if (storage === undefined) return
392
- try {
393
- const backend = storage.backend.get('json')
394
- if (backend === undefined || backend === null || backend.kv === undefined) return
395
- const unit = await backend.kv.open({ name: 'all-usage-aliases', version: 0, tables: [], hasGlobal: true })
396
- kvUnit = unit
397
- const snap = await unit.loadAll()
398
- const g = snap && snap.global
399
- if (g !== null && g !== undefined && typeof g === 'object') {
400
- for (const key of Object.keys(g)) {
401
- const value = g[key]
402
- if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
403
- }
404
- }
405
- } catch (err) {
406
- console.error('[all-usage] alias storage unavailable:', err)
407
- }
408
- }
409
- function persistAliases() {
410
- const snapshotAliases = {}
411
- for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
412
- aliasWriteChain = aliasWriteChain.then(() => {
413
- if (kvUnit === null || kvUnit === undefined) return undefined
414
- return kvUnit.setGlobal(snapshotAliases).catch((err) => {
415
- console.error('[all-usage] alias persist failed:', err)
416
- })
417
- })
418
- }
419
- function setAlias(wsId, raw) {
420
- const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
421
- if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
422
- if (alias === '') delete aliases[wsId]
423
- else aliases[wsId] = alias
424
- persistAliases()
425
- return { ok: true, aliases: Object.assign({}, aliases) }
426
- }
427
- ctx.effect(() => () => {
428
- const unit = kvUnit
429
- kvUnit = null
430
- if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
431
- })
432
-
433
- // ---------- snapshot for the client ----------
434
- function serializeDays(dayMap, cutoff) {
435
- const result = []
436
- for (const pair of dayMap) {
437
- const date = pair[0]
438
- const day = pair[1]
439
- if (date < cutoff) continue
440
- result.push({
441
- date,
442
- turns: day.turns,
443
- tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
444
- perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
445
- byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
446
- byModel: Array.from(day.byModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
447
- })
448
- }
449
- result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
450
- return result
451
- }
452
- function snapshot() {
453
- return {
454
- scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
455
- generatedAt: Date.now(),
456
- requestToken,
457
- workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
458
- aliases: Object.assign({}, aliases),
459
- tokenSemantics: {
460
- processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
461
- cacheRead: 'reused context tokens; not newly generated output',
462
- cacheWrite: 'tokens written into a provider cache',
463
- },
464
- totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
465
- perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
466
- perModel: Array.from(perModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
467
- byDay: serializeDays(byDay, cutoffKey()),
468
- byDayUtc: serializeDays(byDayUtc, cutoffKeyUtc()),
469
- }
470
- }
471
-
472
- // ---------- account balance (DeepSeek open platform) ----------
473
- async function requestBalance(url, key) {
474
- let controller = null
475
- let timer = null
476
- try {
477
- if (typeof AbortController === 'function') {
478
- controller = new AbortController()
479
- timer = setTimeout(() => controller.abort(), 30000)
480
- }
481
- const response = await fetch(url, {
482
- method: 'GET',
483
- headers: { accept: 'application/json', authorization: 'Bearer ' + key },
484
- ...(controller === null ? {} : { signal: controller.signal }),
485
- })
486
- return { ok: response.ok, status: response.status, text: await response.text() }
487
- } catch (err) {
488
- return { ok: false, status: 0, text: '', error: 'network request failed' }
489
- } finally {
490
- if (timer !== null) clearTimeout(timer)
491
- }
492
- }
493
- function moneyOf(v) {
494
- if (typeof v === 'number' && Number.isFinite(v)) return v
495
- if (typeof v === 'string' && v.trim() !== '') {
496
- const n = parseFloat(v)
497
- if (Number.isFinite(n)) return n
498
- }
499
- return null
500
- }
501
- function parseBalance(text) {
502
- let obj = null
503
- try {
504
- obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
505
- } catch (err) {
506
- return null
507
- }
508
- if (obj === null || typeof obj !== 'object') return null
509
- if (obj.is_available === false) return { unavailable: true, currencies: [] }
510
- const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
511
- if (!infos) return null
512
- const out = []
513
- for (const info of infos) {
514
- if (info === null || typeof info !== 'object') continue
515
- if (typeof info.currency !== 'string') continue
516
- out.push({
517
- currency: info.currency,
518
- total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
519
- granted: moneyOf(info.granted_balance),
520
- toppedUp: moneyOf(info.topped_up_balance),
521
- })
522
- }
523
- return { unavailable: false, currencies: out }
524
- }
525
- async function fetchBalance(force) {
526
- const now = Date.now()
527
- if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
528
- let ref = 'DEEPSEEK_API_KEY'
529
- if (settings !== undefined) {
530
- try {
531
- const section = settings.get('llm-deepseek')
532
- if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
533
- } catch (err) { /* default ref */ }
534
- }
535
- let key
536
- if (credentials !== undefined) {
537
- try {
538
- const hit = await credentials.resolve(ref)
539
- if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
540
- } catch (err) { /* unconfigured */ }
541
- }
542
- if (key === undefined) {
543
- const payload = { status: 'missing-key' }
544
- balanceCache = { fetchedAt: now, payload }
545
- return payload
546
- }
547
- if (typeof fetch !== 'function') {
548
- const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
549
- balanceCache = { fetchedAt: now, payload }
550
- return payload
551
- }
552
- const result = await requestBalance('https://api.deepseek.com/user/balance', key)
553
- const body = (result.text || '').trim()
554
- if (result.ok && body.length > 0) {
555
- const parsed = parseBalance(body)
556
- if (parsed !== null && parsed.unavailable) {
557
- const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
558
- balanceCache = { fetchedAt: now, payload }
559
- return payload
560
- }
561
- if (parsed !== null && parsed.currencies.length > 0) {
562
- const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
563
- balanceCache = { fetchedAt: now, payload }
564
- return payload
565
- }
566
- }
567
- const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
568
- const payload = { status: 'error', message: '余额查询失败', detail }
569
- balanceCache = { fetchedAt: now, payload }
570
- return payload
571
- }
572
-
573
- // ---------- HTTP data routes for the client half ----------
574
- if (webServer !== undefined) {
575
- const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
576
- ctx.effect(() => webServer.register({
577
- kind: 'exact',
578
- path: '/api/all-usage',
579
- handler: (req, res) => {
580
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
581
- if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
582
- if (!scan.started) void runBaseline()
583
- sendJson(res, 200, snapshot())
584
- },
585
- }))
586
- ctx.effect(() => webServer.register({
587
- kind: 'exact',
588
- path: '/api/all-usage/balance',
589
- handler: async (req, res) => {
590
- if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
591
- // Browsers may omit Origin on same-origin GET; the process token remains required.
592
- if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
593
- let force = false
594
- try {
595
- const url = new URL(req.url ?? '/', 'http://x')
596
- force = url.searchParams.get('force') === '1'
597
- } catch (err) { /* default */ }
598
- sendJson(res, 200, await fetchBalance(force))
599
- },
600
- }))
601
- ctx.effect(() => webServer.register({
602
- kind: 'exact',
603
- path: '/api/all-usage/alias',
604
- handler: async (req, res) => {
605
- if (req.method !== 'POST') {
606
- res.statusCode = 405
607
- res.end()
608
- return
609
- }
610
- if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
611
- const body = await readBody(req, 16 * 1024)
612
- let args = null
613
- try {
614
- args = JSON.parse(body)
615
- } catch (err) { /* invalid json */ }
616
- const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
617
- ? setAlias(args.workspaceId, args.alias)
618
- : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
619
- sendJson(res, 200, result)
620
- },
621
- }))
622
- }
623
-
624
- // ---------- start the historical backfill immediately ----------
625
- void runBaseline()
626
- void loadAliases()
627
- }
628
-
629
- export { name, inject, apply }
630
- export default { name, inject, apply }
1
+ // dsh-all-usage 插件 Host 半(永久版)
2
+ // 数据聚合 + 账户余额 + 工作区别名持久化,通过 webServer 路由向客户端提供数据。
3
+ import { randomBytes, timingSafeEqual } from 'node:crypto'
4
+
5
+ const name = 'dsh-all-usage'
6
+ const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
7
+
8
+ // webServer route handlers do not inherit the connection API fence; keep this plugin
9
+ // local and require a browser-originated capability for state-changing reads/writes.
10
+ function requestHeader(req, name) {
11
+ const headers = req && req.headers
12
+ if (headers === null || headers === undefined || typeof headers !== 'object') return undefined
13
+ const value = headers[name.toLowerCase()]
14
+ if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : undefined
15
+ return typeof value === 'string' ? value : undefined
16
+ }
17
+
18
+ function isLoopbackHostname(hostname) {
19
+ if (hostname === 'localhost' || hostname === '[::1]') return true
20
+ const parts = hostname.split('.')
21
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ function isTrustedLocalApiRequest(req, requireOrigin) {
25
+ const host = requestHeader(req, 'host')
26
+ if (host === undefined) return false
27
+ let hostUrl
28
+ try {
29
+ hostUrl = new URL('http://' + host)
30
+ } catch (err) {
31
+ return false
32
+ }
33
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
34
+ if (requestHeader(req, 'sec-fetch-site') === 'cross-site') return false
35
+ const origin = requestHeader(req, 'origin')
36
+ if (origin === undefined) return requireOrigin !== true
37
+ try {
38
+ const originUrl = new URL(origin)
39
+ return originUrl.protocol === 'http:' && originUrl.host === hostUrl.host
40
+ } catch (err) {
41
+ return false
42
+ }
43
+ }
44
+
45
+ function hasWriteToken(req, expected) {
46
+ const actual = requestHeader(req, 'x-all-usage-request-token')
47
+ if (typeof actual !== 'string' || typeof expected !== 'string') return false
48
+ const actualBytes = Buffer.from(actual)
49
+ const expectedBytes = Buffer.from(expected)
50
+ return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes)
51
+ }
52
+
53
+ function sendJson(res, code, value) {
54
+ res.statusCode = code
55
+ res.setHeader('content-type', 'application/json; charset=utf-8')
56
+ res.setHeader('cache-control', 'no-store')
57
+ res.end(JSON.stringify(value))
58
+ }
59
+
60
+ function readBody(req, maxBytes) {
61
+ return new Promise((resolve) => {
62
+ const chunks = []
63
+ let size = 0
64
+ req.on('data', (chunk) => {
65
+ size += chunk.length
66
+ if (size <= maxBytes) chunks.push(chunk)
67
+ })
68
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
69
+ req.on('error', () => resolve(''))
70
+ })
71
+ }
72
+
73
+ function apply(ctx) {
74
+ const credentials = ctx.get('credentials')
75
+ const settings = ctx.get('settings')
76
+ const storage = ctx.get('storage')
77
+ const webServer = ctx.get('webServer')
78
+
79
+ // ---------- owned aggregation state ----------
80
+ const wsMeta = new Map()
81
+ const pathIndex = new Map()
82
+ const memberOf = new Map()
83
+ const byDay = new Map()
84
+ const byDayUtc = new Map()
85
+ const perWorkspace = new Map()
86
+ const perModel = new Map()
87
+ // One canonical usage contribution per session turn/step. This makes retries and
88
+ // replacement messages update a logical model call instead of double-counting it.
89
+ const usageByStep = new Map()
90
+ const sessionModel = new Map()
91
+ const totals = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
92
+ const sessionCount = new Set()
93
+ const sessionSeq = new Map()
94
+ const chains = new Map()
95
+ const scan = { started: false, done: false, scanned: 0, total: 0, failed: 0 }
96
+ const aliases = {}
97
+ let kvUnit = null
98
+ let aliasWriteChain = Promise.resolve()
99
+ let balanceCache = { fetchedAt: 0, payload: null }
100
+ const requestToken = randomBytes(32).toString('base64url')
101
+ let disposed = false
102
+ let baselineRetryDelay = 1000
103
+ let baselineRetryScheduled = false
104
+
105
+ function dayKey(ms) {
106
+ const d = new Date(ms)
107
+ return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0')
108
+ }
109
+ function dayKeyUtc(ms) {
110
+ const d = new Date(ms)
111
+ return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + String(d.getUTCDate()).padStart(2, '0')
112
+ }
113
+ function num(v) {
114
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0
115
+ }
116
+ function ensureDay(dayMap, date) {
117
+ let day = dayMap.get(date)
118
+ if (day === undefined) {
119
+ day = { turns: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }, perWs: new Map(), byWs: new Map(), byModel: new Map(), sessionIds: new Set() }
120
+ dayMap.set(date, day)
121
+ }
122
+ return day
123
+ }
124
+ function ensureWs(wsId) {
125
+ let ws = perWorkspace.get(wsId)
126
+ if (ws === undefined) {
127
+ ws = { turns: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
128
+ perWorkspace.set(wsId, ws)
129
+ }
130
+ return ws
131
+ }
132
+ function ensureDayWs(day, wsId) {
133
+ let w = day.byWs.get(wsId)
134
+ if (w === undefined) {
135
+ w = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
136
+ day.byWs.set(wsId, w)
137
+ }
138
+ return w
139
+ }
140
+ function ensureModel(model) {
141
+ let item = perModel.get(model)
142
+ if (item === undefined) {
143
+ item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
144
+ perModel.set(model, item)
145
+ }
146
+ return item
147
+ }
148
+ function ensureDayModel(day, model) {
149
+ let item = day.byModel.get(model)
150
+ if (item === undefined) {
151
+ item = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 }
152
+ day.byModel.set(model, item)
153
+ }
154
+ return item
155
+ }
156
+ function usageValues(usage) {
157
+ return {
158
+ input: num(usage && usage.inputTokens),
159
+ output: num(usage && usage.outputTokens),
160
+ cacheRead: num(usage && usage.cacheReadTokens),
161
+ cacheWrite: num(usage && usage.cacheWriteTokens),
162
+ reasoning: num(usage && usage.reasoningTokens),
163
+ }
164
+ }
165
+ function adjustValues(target, values, direction) {
166
+ target.input += values.input * direction
167
+ target.output += values.output * direction
168
+ target.cacheRead += values.cacheRead * direction
169
+ target.cacheWrite += values.cacheWrite * direction
170
+ target.reasoning += values.reasoning * direction
171
+ }
172
+ function noValues(target) {
173
+ return target.input === 0 && target.output === 0 && target.cacheRead === 0 && target.cacheWrite === 0 && target.reasoning === 0
174
+ }
175
+ function adjustDay(dayMap, date, wsId, values, modelId, direction, sid) {
176
+ const day = ensureDay(dayMap, date)
177
+ if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
178
+ adjustValues(day.tokens, values, direction)
179
+ const dayWs = ensureDayWs(day, wsId)
180
+ adjustValues(dayWs, values, direction)
181
+ if (noValues(dayWs)) day.byWs.delete(wsId)
182
+ const dayModel = ensureDayModel(day, modelId)
183
+ dayModel.calls += direction
184
+ adjustValues(dayModel, values, direction)
185
+ if (dayModel.calls === 0 && noValues(dayModel)) day.byModel.delete(modelId)
186
+ }
187
+ function adjustUsage(wsId, time, values, modelId, direction, sid) {
188
+ const modelTotals = ensureModel(modelId)
189
+ modelTotals.calls += direction
190
+ adjustValues(modelTotals, values, direction)
191
+ if (modelTotals.calls === 0 && noValues(modelTotals)) perModel.delete(modelId)
192
+ adjustValues(totals, values, direction)
193
+ const ws = ensureWs(wsId)
194
+ adjustValues(ws, values, direction)
195
+ adjustDay(byDay, dayKey(time), wsId, values, modelId, direction, sid)
196
+ adjustDay(byDayUtc, dayKeyUtc(time), wsId, values, modelId, direction, sid)
197
+ }
198
+ function usageStepKey(sid, data, seq) {
199
+ const turn = data && typeof data.turn === 'number' ? data.turn : null
200
+ const step = data && typeof data.step === 'number' ? data.step : null
201
+ if (turn !== null && step !== null) return sid + ':step:' + turn + ':' + step
202
+ return sid + ':event:' + (typeof seq === 'number' ? seq : String(Date.now()))
203
+ }
204
+ function addUsage(wsId, time, usage, model, sid, data, seq) {
205
+ const values = usageValues(usage)
206
+ const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
207
+ const eventSeq = typeof seq === 'number' ? seq : -1
208
+ const key = usageStepKey(sid, data, seq)
209
+ const previous = usageByStep.get(key)
210
+ // A late replay of an older raw event cannot replace the canonical later step.
211
+ if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) return
212
+ if (previous !== undefined) adjustUsage(previous.wsId, previous.time, previous.values, previous.modelId, -1, previous.sid)
213
+ const next = { seq: eventSeq, wsId, time, values, modelId, sid }
214
+ usageByStep.set(key, next)
215
+ adjustUsage(wsId, time, values, modelId, 1, sid)
216
+ }
217
+ function addDayTurn(dayMap, date, wsId, sid) {
218
+ const day = ensureDay(dayMap, date)
219
+ if (sid !== undefined && sid !== null) day.sessionIds.add(sid)
220
+ day.turns += 1
221
+ day.perWs.set(wsId, (day.perWs.get(wsId) || 0) + 1)
222
+ }
223
+ function addTurn(wsId, time, sid) {
224
+ ensureWs(wsId).turns += 1
225
+ totals.turns += 1
226
+ addDayTurn(byDay, dayKey(time), wsId, sid)
227
+ addDayTurn(byDayUtc, dayKeyUtc(time), wsId, sid)
228
+ }
229
+ function routeLabel(route) {
230
+ if (route && typeof route.model === 'string' && route.model !== '') return (typeof route.provider === 'string' && route.provider !== '' ? route.provider + ' / ' : '') + route.model
231
+ return undefined
232
+ }
233
+ function modelFromRoute(data) {
234
+ return routeLabel(data) || routeLabel(data && data.header && data.header.config)
235
+ }
236
+ function modelFromMessage(data, fallback) {
237
+ return routeLabel(data && data.message && data.message.source) || fallback
238
+ }
239
+ function foldEvent(wsId, time, type, data, sid, seq) {
240
+ if (type === 'request/context' || type === 'request/header') {
241
+ const model = modelFromRoute(data)
242
+ if (model !== undefined) sessionModel.set(sid, model)
243
+ } else if (type === 'turn/end') addTurn(wsId, time, sid)
244
+ else if (type === 'assistant/message' && data && data.usage) addUsage(wsId, time, data.usage, modelFromMessage(data, sessionModel.get(sid)), sid, data, seq)
245
+ }
246
+ function foldEvents(wsId, events, fromSeq, sid) {
247
+ for (const ev of events) {
248
+ if (fromSeq !== undefined) {
249
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
250
+ if (s <= fromSeq) continue
251
+ }
252
+ if (ev.type === 'turn/end' || ev.type === 'assistant/message' || ev.type === 'request/context' || ev.type === 'request/header') foldEvent(wsId, ev.time, ev.type, ev.data, sid, ev.seq)
253
+ }
254
+ }
255
+ function lastSeqOf(events) {
256
+ let last = 0
257
+ for (const ev of events) {
258
+ const s = typeof ev.seq === 'number' ? ev.seq : -1
259
+ if (s > last) last = s
260
+ }
261
+ return last
262
+ }
263
+ function enqueue(sid, task) {
264
+ const prev = chains.get(sid) || Promise.resolve()
265
+ const next = prev.then(() => task(), () => task())
266
+ chains.set(sid, next)
267
+ return next
268
+ }
269
+ function wsForLiveSession(session, sid) {
270
+ let wsId = memberOf.get(sid)
271
+ if (wsId !== undefined) return wsId
272
+ const header = session && session.header
273
+ const cwd = header && typeof header.cwd === 'string' ? header.cwd : ''
274
+ if (cwd === '') return undefined
275
+ wsId = pathIndex.get(cwd)
276
+ if (wsId !== undefined) memberOf.set(sid, wsId)
277
+ return wsId
278
+ }
279
+ async function processLiveEvent(sid, wsId, event) {
280
+ if (disposed) return
281
+ const seq = typeof event.seq === 'number' ? event.seq : -1
282
+ const last = sessionSeq.get(sid)
283
+ if (last === undefined) {
284
+ try {
285
+ const snap = await ctx.sessionQuery.readSession(sid)
286
+ if (snap && Array.isArray(snap.events)) {
287
+ foldEvents(wsId, snap.events, undefined, sid)
288
+ sessionSeq.set(sid, lastSeqOf(snap.events))
289
+ sessionCount.add(sid)
290
+ }
291
+ } catch (err) { /* retry on the next event */ }
292
+ return
293
+ }
294
+ if (seq <= last) return
295
+ if (seq > last + 1) {
296
+ try {
297
+ const snap = await ctx.sessionQuery.readSession(sid)
298
+ if (snap && Array.isArray(snap.events)) {
299
+ foldEvents(wsId, snap.events, last, sid)
300
+ sessionSeq.set(sid, lastSeqOf(snap.events))
301
+ }
302
+ } catch (err) { /* keep last; retry later */ }
303
+ return
304
+ }
305
+ foldEvent(wsId, event.time, event.type, event.data, sid, event.seq)
306
+ sessionSeq.set(sid, seq)
307
+ sessionCount.add(sid)
308
+ }
309
+
310
+ // ---------- baseline scan over durable logs ----------
311
+ function scheduleBaselineRetry() {
312
+ if (disposed || baselineRetryScheduled || scan.done) return
313
+ baselineRetryScheduled = true
314
+ const delay = baselineRetryDelay
315
+ baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
316
+ void Promise.resolve(ctx.timeout(delay)).then(() => {
317
+ baselineRetryScheduled = false
318
+ if (!disposed && !scan.started && !scan.done) return runBaseline()
319
+ return undefined
320
+ })
321
+ }
322
+ async function runBaseline() {
323
+ if (scan.started || disposed) return
324
+ scan.started = true
325
+ let setupFailed = false
326
+ try {
327
+ const workspaces = ctx.workspaceRegistry.list()
328
+ for (const w of workspaces) {
329
+ const id = w && w.id
330
+ const path = w && typeof w.path === 'string' ? w.path : ''
331
+ const title = w && typeof w.title === 'string' ? w.title : ''
332
+ if (id === undefined) continue
333
+ wsMeta.set(id, { id, title, path })
334
+ if (path !== '') pathIndex.set(path, id)
335
+ if (w && Array.isArray(w.sessionIds)) {
336
+ for (const sid of w.sessionIds) memberOf.set(sid, id)
337
+ }
338
+ }
339
+ } catch (err) {
340
+ console.error('[all-usage] workspace list failed:', err)
341
+ setupFailed = true
342
+ }
343
+ let records = null
344
+ try {
345
+ records = await ctx.sessionQuery.listSessions()
346
+ } catch (err) {
347
+ console.error('[all-usage] session list failed:', err)
348
+ }
349
+ if (disposed) return
350
+ if (setupFailed || !Array.isArray(records)) {
351
+ // A transient registry failure must not be reported as a completed empty scan.
352
+ scan.started = false
353
+ scheduleBaselineRetry()
354
+ return
355
+ }
356
+ scan.total = records.length
357
+ for (const record of records) {
358
+ if (disposed) return
359
+ if (record === undefined || record === null || record.header === undefined) {
360
+ scan.scanned += 1
361
+ continue
362
+ }
363
+ const sid = record.header.id
364
+ const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
365
+ const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
366
+ if (sid === undefined || wsId === undefined) {
367
+ scan.scanned += 1
368
+ continue
369
+ }
370
+ await enqueue(sid, async () => {
371
+ if (disposed) return
372
+ try {
373
+ if (sessionSeq.has(sid)) return
374
+ const snap = await ctx.sessionQuery.readSession(sid)
375
+ if (snap && Array.isArray(snap.events)) {
376
+ foldEvents(wsId, snap.events, undefined, sid)
377
+ sessionSeq.set(sid, lastSeqOf(snap.events))
378
+ sessionCount.add(sid)
379
+ }
380
+ } catch (err) {
381
+ sessionSeq.set(sid, -1)
382
+ scan.failed += 1
383
+ } finally {
384
+ scan.scanned += 1
385
+ }
386
+ })
387
+ await ctx.timeout(0)
388
+ }
389
+ scan.done = true
390
+ }
391
+
392
+ // ---------- live feed ----------
393
+ ctx.on('session/event', (session, event) => {
394
+ if (disposed) return
395
+ if (event === undefined || event === null) return
396
+ const type = event.type
397
+ if (type !== 'turn/end' && type !== 'assistant/message' && type !== 'request/context' && type !== 'request/header') return
398
+ const sid = session && session.id
399
+ if (typeof sid !== 'string') return
400
+ const wsId = wsForLiveSession(session, sid)
401
+ if (wsId === undefined) return
402
+ enqueue(sid, () => processLiveEvent(sid, wsId, event))
403
+ })
404
+
405
+ // ---------- workspace aliases (durable, schema-free KV unit) ----------
406
+ async function loadAliases() {
407
+ if (storage === undefined) return
408
+ try {
409
+ const backend = storage.backend.get('json')
410
+ if (backend === undefined || backend === null || backend.kv === undefined) return
411
+ const unit = await backend.kv.open({ name: 'all-usage-aliases', version: 0, tables: [], hasGlobal: true })
412
+ kvUnit = unit
413
+ const snap = await unit.loadAll()
414
+ const g = snap && snap.global
415
+ if (g !== null && g !== undefined && typeof g === 'object') {
416
+ for (const key of Object.keys(g)) {
417
+ const value = g[key]
418
+ if (typeof value === 'string' && value.trim() !== '') aliases[key] = value
419
+ }
420
+ }
421
+ } catch (err) {
422
+ console.error('[all-usage] alias storage unavailable:', err)
423
+ }
424
+ }
425
+ function persistAliases() {
426
+ const snapshotAliases = {}
427
+ for (const key of Object.keys(aliases)) snapshotAliases[key] = aliases[key]
428
+ aliasWriteChain = aliasWriteChain.then(() => {
429
+ if (kvUnit === null || kvUnit === undefined) return undefined
430
+ return kvUnit.setGlobal(snapshotAliases).catch((err) => {
431
+ console.error('[all-usage] alias persist failed:', err)
432
+ })
433
+ })
434
+ }
435
+ function setAlias(wsId, raw) {
436
+ const alias = typeof raw === 'string' ? raw.trim().slice(0, 80) : ''
437
+ if (!wsMeta.has(wsId)) return { ok: false, message: 'unknown-workspace', aliases: Object.assign({}, aliases) }
438
+ if (alias === '') delete aliases[wsId]
439
+ else aliases[wsId] = alias
440
+ persistAliases()
441
+ return { ok: true, aliases: Object.assign({}, aliases) }
442
+ }
443
+ ctx.effect(() => () => {
444
+ disposed = true
445
+ })
446
+ ctx.effect(() => () => {
447
+ const unit = kvUnit
448
+ kvUnit = null
449
+ if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
450
+ })
451
+
452
+ // ---------- snapshot for the client ----------
453
+ function serializeDays(dayMap) {
454
+ const result = []
455
+ for (const pair of dayMap) {
456
+ const date = pair[0]
457
+ const day = pair[1]
458
+ result.push({
459
+ date,
460
+ turns: day.turns,
461
+ sessions: day.sessionIds.size,
462
+ sessionIds: Array.from(day.sessionIds).sort(),
463
+ tokens: { input: day.tokens.input, output: day.tokens.output, cacheRead: day.tokens.cacheRead, cacheWrite: day.tokens.cacheWrite, reasoning: day.tokens.reasoning },
464
+ perWorkspace: Array.from(day.perWs, (p) => ({ workspaceId: p[0], turns: p[1] })),
465
+ byWorkspace: Array.from(day.byWs, (p) => ({ workspaceId: p[0], input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
466
+ byModel: Array.from(day.byModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
467
+ })
468
+ }
469
+ result.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0))
470
+ return result
471
+ }
472
+ function snapshot() {
473
+ return {
474
+ scan: { started: scan.started, done: scan.done, scanned: scan.scanned, total: scan.total, failed: scan.failed },
475
+ generatedAt: Date.now(),
476
+ requestToken,
477
+ workspaces: Array.from(wsMeta.values(), (w) => ({ id: w.id, title: w.title, path: w.path })),
478
+ aliases: Object.assign({}, aliases),
479
+ tokenSemantics: {
480
+ processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
481
+ cacheRead: 'reused context tokens; not newly generated output',
482
+ cacheWrite: 'tokens written into a provider cache',
483
+ },
484
+ totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
485
+ perWorkspace: Array.from(perWorkspace, (p) => ({ workspaceId: p[0], turns: p[1].turns, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
486
+ perModel: Array.from(perModel, (p) => ({ model: p[0], calls: p[1].calls, input: p[1].input, output: p[1].output, cacheRead: p[1].cacheRead, cacheWrite: p[1].cacheWrite, reasoning: p[1].reasoning })),
487
+ byDay: serializeDays(byDay),
488
+ byDayUtc: serializeDays(byDayUtc),
489
+ }
490
+ }
491
+
492
+ // ---------- account balance (DeepSeek open platform) ----------
493
+ async function requestBalance(url, key) {
494
+ let controller = null
495
+ let timer = null
496
+ try {
497
+ if (typeof AbortController === 'function') {
498
+ controller = new AbortController()
499
+ timer = setTimeout(() => controller.abort(), 30000)
500
+ }
501
+ const response = await fetch(url, {
502
+ method: 'GET',
503
+ headers: { accept: 'application/json', authorization: 'Bearer ' + key },
504
+ ...(controller === null ? {} : { signal: controller.signal }),
505
+ })
506
+ return { ok: response.ok, status: response.status, text: await response.text() }
507
+ } catch (err) {
508
+ return { ok: false, status: 0, text: '', error: 'network request failed' }
509
+ } finally {
510
+ if (timer !== null) clearTimeout(timer)
511
+ }
512
+ }
513
+ function moneyOf(v) {
514
+ if (typeof v === 'number' && Number.isFinite(v)) return v
515
+ if (typeof v === 'string' && v.trim() !== '') {
516
+ const n = parseFloat(v)
517
+ if (Number.isFinite(n)) return n
518
+ }
519
+ return null
520
+ }
521
+ function parseBalance(text) {
522
+ let obj = null
523
+ try {
524
+ obj = JSON.parse(String(text).replace(/^\uFEFF/, ''))
525
+ } catch (err) {
526
+ return null
527
+ }
528
+ if (obj === null || typeof obj !== 'object') return null
529
+ if (obj.is_available === false) return { unavailable: true, currencies: [] }
530
+ const infos = (Array.isArray(obj.balance_infos) && obj.balance_infos) || (Array.isArray(obj.balance) && obj.balance) || null
531
+ if (!infos) return null
532
+ const out = []
533
+ for (const info of infos) {
534
+ if (info === null || typeof info !== 'object') continue
535
+ if (typeof info.currency !== 'string') continue
536
+ out.push({
537
+ currency: info.currency,
538
+ total: moneyOf(info.total_balance !== undefined ? info.total_balance : info.balance),
539
+ granted: moneyOf(info.granted_balance),
540
+ toppedUp: moneyOf(info.topped_up_balance),
541
+ })
542
+ }
543
+ return { unavailable: false, currencies: out }
544
+ }
545
+ async function fetchBalance(force) {
546
+ const now = Date.now()
547
+ if (force !== true && balanceCache.payload !== null && now - balanceCache.fetchedAt < 300000) return balanceCache.payload
548
+ let ref = 'DEEPSEEK_API_KEY'
549
+ if (settings !== undefined) {
550
+ try {
551
+ const section = settings.get('llm-deepseek')
552
+ if (section !== null && typeof section === 'object' && typeof section.apiKeyEnv === 'string' && section.apiKeyEnv.length > 0) ref = section.apiKeyEnv
553
+ } catch (err) { /* default ref */ }
554
+ }
555
+ let key
556
+ if (credentials !== undefined) {
557
+ try {
558
+ const hit = await credentials.resolve(ref)
559
+ if (hit !== null && hit !== undefined && typeof hit.value === 'string' && hit.value.length > 0) key = hit.value
560
+ } catch (err) { /* unconfigured */ }
561
+ }
562
+ if (key === undefined) {
563
+ const payload = { status: 'missing-key' }
564
+ balanceCache = { fetchedAt: now, payload }
565
+ return payload
566
+ }
567
+ if (typeof fetch !== 'function') {
568
+ const payload = { status: 'error', message: '当前 DSH 运行时不支持余额查询' }
569
+ balanceCache = { fetchedAt: now, payload }
570
+ return payload
571
+ }
572
+ const result = await requestBalance('https://api.deepseek.com/user/balance', key)
573
+ const body = (result.text || '').trim()
574
+ if (result.ok && body.length > 0) {
575
+ const parsed = parseBalance(body)
576
+ if (parsed !== null && parsed.unavailable) {
577
+ const payload = { status: 'unavailable', message: 'DeepSeek 接口返回余额不可用(is_available=false)' }
578
+ balanceCache = { fetchedAt: now, payload }
579
+ return payload
580
+ }
581
+ if (parsed !== null && parsed.currencies.length > 0) {
582
+ const payload = { status: 'ok', currencies: parsed.currencies, fetchedAt: now }
583
+ balanceCache = { fetchedAt: now, payload }
584
+ return payload
585
+ }
586
+ }
587
+ const detail = body.length > 0 ? body.slice(0, 300) : (result.status > 0 ? 'HTTP ' + result.status : result.error || 'network request failed')
588
+ const payload = { status: 'error', message: '余额查询失败', detail }
589
+ balanceCache = { fetchedAt: now, payload }
590
+ return payload
591
+ }
592
+
593
+ // ---------- HTTP data routes for the client half ----------
594
+ if (webServer !== undefined) {
595
+ const rejectRequest = (res) => sendJson(res, 403, { ok: false, message: 'forbidden' })
596
+ ctx.effect(() => webServer.register({
597
+ kind: 'exact',
598
+ path: '/api/all-usage',
599
+ handler: (req, res) => {
600
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
601
+ if (!isTrustedLocalApiRequest(req, false)) { rejectRequest(res); return }
602
+ if (!scan.started) void runBaseline()
603
+ sendJson(res, 200, snapshot())
604
+ },
605
+ }))
606
+ ctx.effect(() => webServer.register({
607
+ kind: 'exact',
608
+ path: '/api/all-usage/balance',
609
+ handler: async (req, res) => {
610
+ if (req.method !== 'GET') { res.statusCode = 405; res.end(); return }
611
+ // Browsers may omit Origin on same-origin GET; the process token remains required.
612
+ if (!isTrustedLocalApiRequest(req, false) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
613
+ let force = false
614
+ try {
615
+ const url = new URL(req.url ?? '/', 'http://x')
616
+ force = url.searchParams.get('force') === '1'
617
+ } catch (err) { /* default */ }
618
+ sendJson(res, 200, await fetchBalance(force))
619
+ },
620
+ }))
621
+ ctx.effect(() => webServer.register({
622
+ kind: 'exact',
623
+ path: '/api/all-usage/alias',
624
+ handler: async (req, res) => {
625
+ if (req.method !== 'POST') {
626
+ res.statusCode = 405
627
+ res.end()
628
+ return
629
+ }
630
+ if (!isTrustedLocalApiRequest(req, true) || !hasWriteToken(req, requestToken)) { rejectRequest(res); return }
631
+ const body = await readBody(req, 16 * 1024)
632
+ let args = null
633
+ try {
634
+ args = JSON.parse(body)
635
+ } catch (err) { /* invalid json */ }
636
+ const result = args !== null && args !== undefined && typeof args.workspaceId === 'string'
637
+ ? setAlias(args.workspaceId, args.alias)
638
+ : { ok: false, message: 'bad-request', aliases: Object.assign({}, aliases) }
639
+ sendJson(res, 200, result)
640
+ },
641
+ }))
642
+ }
643
+
644
+ // ---------- start the historical backfill immediately ----------
645
+ void runBaseline()
646
+ void loadAliases()
647
+ }
648
+
649
+ export { name, inject, apply }
650
+ export default { name, inject, apply }