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/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/screenshot-1.png +0 -0
- package/assets/screenshot-2.png +0 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +1063 -0
- package/lib/index.js +630 -0
- package/package.json +54 -0
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
|
+
}
|