dsh-all-usage 1.0.4

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 ADDED
@@ -0,0 +1,630 @@
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 }
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "dsh-all-usage",
3
+ "version": "1.0.4",
4
+ "description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ParticleLight/dsh-all-usage.git"
8
+ },
9
+ "homepage": "https://github.com/ParticleLight/dsh-all-usage#readme",
10
+ "type": "module",
11
+ "scripts": {
12
+ "test": "node --test"
13
+ },
14
+ "main": "lib/index.js",
15
+ "exports": {
16
+ ".": {
17
+ "default": "./lib/index.js"
18
+ },
19
+ "./client": {
20
+ "default": "./lib/client.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "dsh": {
25
+ "bundle": {
26
+ "patch": "./cordis.patch.yml"
27
+ },
28
+ "client": {
29
+ "inject": [
30
+ "@deepseek-ai/dsh-client-runtime",
31
+ "@deepseek-ai/dsh-client-ui-slots",
32
+ "@deepseek-ai/dsh-client-ui-sidebar"
33
+ ],
34
+ "platform": "web"
35
+ }
36
+ },
37
+ "files": [
38
+ "lib/index.js",
39
+ "lib/client.js",
40
+ "cordis.patch.yml",
41
+ "assets"
42
+ ],
43
+ "keywords": [
44
+ "deepseek-harness",
45
+ "dsh",
46
+ "plugin",
47
+ "usage",
48
+ "stats",
49
+ "heatmap",
50
+ "tokens",
51
+ "balance"
52
+ ],
53
+ "license": "MIT"
54
+ }