@ponydev/dsh-model-usage 0.0.0-stage → 1.0.0

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/ledger.js ADDED
@@ -0,0 +1,420 @@
1
+ // 请求账本:session 事件 → 按日聚合(按小时/供应商/模型)+ 逐条请求环形日志
2
+ // 口径:Tokens = input + output + cacheRead + cacheWrite(reasoning 已含在 output)
3
+ // 不做成本估算:订阅套餐不是按 API 计费,金额以服务商账单/余额为准。
4
+ import { dirname, join } from 'node:path'
5
+ import { homedir } from 'node:os'
6
+ import { mkdir, readFile, readdir, rename, stat as statFile, writeFile } from 'node:fs/promises'
7
+ import { zstdDecompressSync } from 'node:zlib'
8
+
9
+ const RECENT_MAX = 300
10
+ const BACKFILL_DAYS = 30
11
+
12
+ export function dateKey(d = new Date()) {
13
+ const y = d.getFullYear()
14
+ const m = String(d.getMonth() + 1).padStart(2, '0')
15
+ const day = String(d.getDate()).padStart(2, '0')
16
+ return `${y}-${m}-${day}`
17
+ }
18
+
19
+ function dshHome() {
20
+ return process.env.DSH_HOME && process.env.DSH_HOME.trim() ? process.env.DSH_HOME.trim() : join(homedir(), '.dsh')
21
+ }
22
+
23
+ export function ledgerFilePath() {
24
+ return join(dshHome(), 'model-usage', 'ledger.json')
25
+ }
26
+
27
+ export function sessionsRoot() {
28
+ return join(dshHome(), 'sessions')
29
+ }
30
+
31
+ function emptyDay() {
32
+ return { hours: {}, providers: {} }
33
+ }
34
+
35
+ function usageOf(u) {
36
+ // 兼容两种写法:日志事件用 inputTokens/…,实时链路用 input/…
37
+ const out = {
38
+ input: Number(u.inputTokens != null ? u.inputTokens : u.input) || 0,
39
+ cacheRead: Number(u.cacheReadTokens != null ? u.cacheReadTokens : u.cacheRead) || 0,
40
+ cacheWrite: Number(u.cacheWriteTokens != null ? u.cacheWriteTokens : u.cacheWrite) || 0,
41
+ output: Number(u.outputTokens != null ? u.outputTokens : u.output) || 0,
42
+ }
43
+ out.total = out.input + out.cacheRead + out.cacheWrite + out.output
44
+ return out
45
+ }
46
+
47
+ function bumpBucket(bucket, usage) {
48
+ bucket.requests = (bucket.requests || 0) + 1
49
+ bucket.input = (bucket.input || 0) + (usage.input || 0)
50
+ bucket.cacheRead = (bucket.cacheRead || 0) + (usage.cacheRead || 0)
51
+ bucket.cacheWrite = (bucket.cacheWrite || 0) + (usage.cacheWrite || 0)
52
+ bucket.output = (bucket.output || 0) + (usage.output || 0)
53
+ bucket.tokens = (bucket.tokens || 0) + (usage.total || 0)
54
+ }
55
+
56
+ function pushRecord(data, rec) {
57
+ if (!Array.isArray(data.recent)) data.recent = []
58
+ data.recent.push(rec)
59
+ if (data.recent.length > RECENT_MAX) data.recent.splice(0, data.recent.length - RECENT_MAX)
60
+ }
61
+
62
+ function applyRecord(data, { time, provider, model, usage, durationMs, ttftMs }) {
63
+ const u = usageOf(usage)
64
+ const d = new Date(time || Date.now())
65
+ const dayKey = dateKey(d)
66
+ const day = data.days[dayKey] || (data.days[dayKey] = emptyDay())
67
+ const hourBucket = day.hours[d.getHours()] || (day.hours[d.getHours()] = {})
68
+ const prov = day.providers[provider] || (day.providers[provider] = { models: {} })
69
+ const modelBucket = prov.models[model] || (prov.models[model] = {})
70
+ bumpBucket(day, u)
71
+ bumpBucket(hourBucket, u)
72
+ bumpBucket(prov, u)
73
+ bumpBucket(modelBucket, u)
74
+ if (!hourBucket.byProvider) hourBucket.byProvider = {}
75
+ const hp = hourBucket.byProvider[provider] || (hourBucket.byProvider[provider] = { tokens: 0, requests: 0 })
76
+ hp.tokens += u.total
77
+ hp.requests += 1
78
+ pushRecord(data, {
79
+ t: time || Date.now(),
80
+ p: provider,
81
+ m: model,
82
+ i: u.input,
83
+ o: u.output,
84
+ cr: u.cacheRead,
85
+ cw: u.cacheWrite,
86
+ dur: Number.isFinite(durationMs) ? durationMs : null,
87
+ ttft: Number.isFinite(ttftMs) ? ttftMs : null,
88
+ })
89
+ }
90
+
91
+ /** 从会话日志回填:完整重算 days/recent(日志为权威),完成后设 backfilled 标记 */
92
+ async function backfillFromLogs(data, root, days) {
93
+ const cutoff = Date.now() - days * 86400000
94
+ const files = []
95
+ async function walk(dir) {
96
+ let ents
97
+ try { ents = await readdir(dir, { withFileTypes: true }) } catch { return }
98
+ for (const e of ents) {
99
+ const p = join(dir, e.name)
100
+ if (e.isDirectory()) await walk(p)
101
+ else if (e.name.endsWith('.jsonl.zstd')) files.push(p)
102
+ }
103
+ }
104
+ await walk(root)
105
+ const tmp = { days: {}, recent: [] }
106
+ const recs = []
107
+ for (const file of files) {
108
+ let text
109
+ try {
110
+ const st = await statFile(file)
111
+ if (st.mtimeMs < cutoff) continue
112
+ const buf = await readFile(file)
113
+ text = zstdDecompressSync(buf).toString('utf8')
114
+ } catch { continue }
115
+ let pendingAt = null
116
+ for (const line of text.split('\n')) {
117
+ if (!line.trim()) continue
118
+ let ev
119
+ try { ev = JSON.parse(line) } catch { continue }
120
+ const type = ev.type
121
+ const d = ev.data
122
+ if (!d) continue
123
+ if (type === 'step/start') { pendingAt = ev.time || null; continue }
124
+ if (type !== 'assistant/message') continue
125
+ if (!d.usage || !d.message || !d.message.source) continue
126
+ const t = ev.time || Date.now()
127
+ if (t < cutoff) continue
128
+ const stream = Array.isArray(d.stream) ? d.stream : []
129
+ const firstTime = stream.length && Number.isFinite(stream[0].time) ? stream[0].time : null
130
+ const ttft = pendingAt && firstTime && firstTime >= pendingAt ? firstTime - pendingAt : null
131
+ const dur = pendingAt ? Math.max(0, t - pendingAt) : null
132
+ applyRecord(tmp, {
133
+ time: t,
134
+ provider: d.message.source.provider,
135
+ model: d.message.source.model || '',
136
+ usage: d.usage,
137
+ durationMs: dur,
138
+ ttftMs: ttft,
139
+ })
140
+ pendingAt = null
141
+ }
142
+ }
143
+ tmp.recent.sort((a, b) => a.t - b.t)
144
+ data.days = tmp.days
145
+ data.recent = tmp.recent.slice(-RECENT_MAX)
146
+ data.backfilled = true
147
+ data.backfillAt = Date.now()
148
+ }
149
+
150
+ export function createLedger() {
151
+ const file = ledgerFilePath()
152
+ let data = { version: 1, days: {}, recent: [], obs: { last: {}, days: {} } }
153
+ let ready = null
154
+ let dirty = false
155
+ let flushTimer = null
156
+ let backfilling = false
157
+ const queue = []
158
+
159
+ async function load() {
160
+ try {
161
+ const text = await readFile(file, 'utf8')
162
+ const parsed = JSON.parse(text)
163
+ if (parsed && parsed.version === 1 && parsed.days) data = parsed
164
+ } catch {}
165
+ if (!Array.isArray(data.recent)) data.recent = []
166
+ if (!data.obs || typeof data.obs !== 'object') data.obs = { last: {}, days: {} }
167
+ if (!data.obs.last) data.obs.last = {}
168
+ if (!data.obs.days) data.obs.days = {}
169
+ prune()
170
+ }
171
+ function ensureLoaded() {
172
+ if (!ready) ready = load()
173
+ return ready
174
+ }
175
+ function prune() {
176
+ const cutoff = Date.now() - 120 * 86400000
177
+ for (const key of Object.keys(data.days)) {
178
+ const t = new Date(key + 'T00:00:00').getTime()
179
+ if (Number.isFinite(t) && t < cutoff) delete data.days[key]
180
+ }
181
+ if (Array.isArray(data.recent)) data.recent = data.recent.filter((r) => r && r.t > cutoff).slice(-RECENT_MAX)
182
+ if (data.obs && data.obs.days) {
183
+ for (const key of Object.keys(data.obs.days)) {
184
+ const t = new Date(key + 'T00:00:00').getTime()
185
+ if (Number.isFinite(t) && t < cutoff) delete data.obs.days[key]
186
+ }
187
+ }
188
+ }
189
+
190
+ /** 首次运行从会话日志回填;回填期间的实时记录先入队,完成后合并 */
191
+ async function backfill() {
192
+ if (data.backfilled) return false
193
+ backfilling = true
194
+ try {
195
+ await backfillFromLogs(data, sessionsRoot(), BACKFILL_DAYS)
196
+ for (const rec of queue.splice(0)) applyRecord(data, rec)
197
+ dirty = true
198
+ await flush()
199
+ return true
200
+ } catch {
201
+ return false
202
+ } finally {
203
+ backfilling = false
204
+ }
205
+ }
206
+
207
+ function record(provider, model, usage, timing) {
208
+ const rec = {
209
+ time: (timing && timing.time) || Date.now(),
210
+ provider,
211
+ model,
212
+ usage,
213
+ durationMs: timing && timing.durationMs,
214
+ ttftMs: timing && timing.ttftMs,
215
+ }
216
+ if (backfilling) { queue.push(rec); return }
217
+ applyRecord(data, rec)
218
+ dirty = true
219
+ scheduleFlush()
220
+ }
221
+
222
+ function scheduleFlush() {
223
+ if (flushTimer) return
224
+ flushTimer = setTimeout(() => {
225
+ flushTimer = null
226
+ flush().catch(() => {})
227
+ }, 3000)
228
+ }
229
+
230
+ async function flush() {
231
+ if (!dirty) return
232
+ dirty = false
233
+ try {
234
+ await mkdir(dirname(file), { recursive: true })
235
+ const tmp = file + '.tmp'
236
+ await writeFile(tmp, JSON.stringify(data))
237
+ await rename(tmp, file)
238
+ } catch {
239
+ dirty = true
240
+ }
241
+ }
242
+
243
+ function statBucket(b) {
244
+ return {
245
+ requests: b.requests || 0,
246
+ tokens: b.tokens || 0,
247
+ input: b.input || 0,
248
+ output: b.output || 0,
249
+ cacheRead: b.cacheRead || 0,
250
+ cacheWrite: b.cacheWrite || 0,
251
+ }
252
+ }
253
+ function cacheHitRate(hit, totalIn) {
254
+ const d = hit + totalIn
255
+ return d > 0 ? Math.round((hit / d) * 1000) / 10 : null
256
+ }
257
+ function sumRange(fromKey, toKey) {
258
+ const agg = { requests: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, byProvider: {}, byModel: [] }
259
+ for (const [key, day] of Object.entries(data.days)) {
260
+ if (key < fromKey || key > toKey) continue
261
+ for (const [route, prov] of Object.entries(day.providers || {})) {
262
+ const s = statBucket(prov)
263
+ agg.requests += s.requests
264
+ agg.tokens += s.tokens
265
+ agg.input += s.input
266
+ agg.output += s.output
267
+ agg.cacheRead += s.cacheRead
268
+ agg.cacheWrite += s.cacheWrite
269
+ const bp = agg.byProvider[route] || (agg.byProvider[route] = { requests: 0, tokens: 0, input: 0, cacheRead: 0 })
270
+ bp.requests += s.requests
271
+ bp.tokens += s.tokens
272
+ bp.input += s.input
273
+ bp.cacheRead += s.cacheRead
274
+ for (const [model, mb] of Object.entries(prov.models || {})) {
275
+ const ms = statBucket(mb)
276
+ let row = agg.byModel.find((x) => x.provider === route && x.model === model)
277
+ if (!row) { row = { provider: route, model, requests: 0, tokens: 0 }; agg.byModel.push(row) }
278
+ row.requests += ms.requests
279
+ row.tokens += ms.tokens
280
+ }
281
+ }
282
+ }
283
+ agg.cacheHitRate = cacheHitRate(agg.cacheRead, agg.input)
284
+ agg.byProvider = Object.entries(agg.byProvider)
285
+ .map(([route, v]) => ({
286
+ route, requests: v.requests, tokens: v.tokens, input: v.input, cacheRead: v.cacheRead,
287
+ cacheHitRate: cacheHitRate(v.cacheRead, v.input),
288
+ }))
289
+ .sort((a, b) => b.tokens - a.tokens)
290
+ agg.byModel.sort((a, b) => b.tokens - a.tokens)
291
+ return agg
292
+ }
293
+
294
+ function obsDay(route) {
295
+ const key = dateKey()
296
+ const days = data.obs.days
297
+ const day = days[key] || (days[key] = {})
298
+ return day[route] || (day[route] = {})
299
+ }
300
+
301
+ /**
302
+ * 观测服务商真实额度变动(不估算):
303
+ * - 余额类:余额下降 = 当日支出;余额上升 = 充值/赠金(分开记,不冲抵)
304
+ * - 窗口类:同一重置周期内 usedPct 上升量 = 当日额度消耗
305
+ */
306
+ function observe(route, usage) {
307
+ if (!usage) return
308
+ let changed = false
309
+ if (usage.balance && usage.balance.currency) {
310
+ const cur = usage.balance.currency
311
+ const val = Number(usage.balance.total)
312
+ if (Number.isFinite(val)) {
313
+ const last = data.obs.last[route]
314
+ if (last && last.kind === 'balance' && last.currency === cur && Number.isFinite(last.total)) {
315
+ const delta = last.total - val
316
+ if (delta > 0.000001) {
317
+ const d = obsDay(route)
318
+ d.kind = 'balance'
319
+ d.currency = cur
320
+ d.spent = Math.round(((d.spent || 0) + delta) * 10000) / 10000
321
+ changed = true
322
+ } else if (delta < -0.000001) {
323
+ const d = obsDay(route)
324
+ d.kind = 'balance'
325
+ d.currency = cur
326
+ d.recharged = Math.round(((d.recharged || 0) - delta) * 10000) / 10000
327
+ changed = true
328
+ }
329
+ }
330
+ data.obs.last[route] = { kind: 'balance', total: val, currency: cur, ts: Date.now() }
331
+ }
332
+ }
333
+ if (Array.isArray(usage.windows) && usage.windows.length) {
334
+ const last = data.obs.last[route]
335
+ const prevWindows = last && last.kind === 'windows' ? last.windows || {} : null
336
+ const nextWindows = {}
337
+ for (const w of usage.windows) {
338
+ if (!w || !w.key) continue
339
+ const used = Number(w.usedPct)
340
+ if (!Number.isFinite(used)) continue
341
+ const resetAt = w.resetAt || null
342
+ const prev = prevWindows && prevWindows[w.key]
343
+ if (prev && prev.resetAt === resetAt && used >= prev.used) {
344
+ const delta = used - prev.used
345
+ if (delta > 0.01) {
346
+ const d = obsDay(route)
347
+ d.kind = 'windows'
348
+ if (!d.windows) d.windows = {}
349
+ d.windows[w.key] = Math.round(((d.windows[w.key] || 0) + delta) * 10) / 10
350
+ changed = true
351
+ }
352
+ }
353
+ nextWindows[w.key] = { used, resetAt }
354
+ }
355
+ data.obs.last[route] = { kind: 'windows', windows: nextWindows, ts: Date.now() }
356
+ }
357
+ if (changed) { dirty = true; scheduleFlush() }
358
+ }
359
+
360
+ /** 今日观测汇总:{ [route]: {kind, currency?, spent?, recharged?, windows?} } */
361
+ function spendToday() {
362
+ const day = data.obs.days[dateKey()] || {}
363
+ return day
364
+ }
365
+
366
+ /** @param {{days?: number}} opts 范围天数:1=当天,7/30=近 N 天(含当天) */
367
+ function stats(opts = {}) {
368
+ const rangeDays = Math.min(90, Math.max(1, Number(opts.days) || 7))
369
+ const now = new Date()
370
+ const todayKey = dateKey(now)
371
+ const fromKey = dateKey(new Date(now.getTime() - (rangeDays - 1) * 86400000))
372
+ const today = sumRange(todayKey, todayKey)
373
+ const range = sumRange(fromKey, todayKey)
374
+ const day = data.days[todayKey] || emptyDay()
375
+ const hours = []
376
+ for (let h = 0; h < 24; h++) {
377
+ const b = day.hours[h]
378
+ hours.push({ h, tokens: (b && b.tokens) || 0, requests: (b && b.requests) || 0, byProvider: (b && b.byProvider) || {} })
379
+ }
380
+ const daysList = []
381
+ for (let i = rangeDays - 1; i >= 0; i--) {
382
+ const key = dateKey(new Date(now.getTime() - i * 86400000))
383
+ const d = data.days[key]
384
+ const byProvider = {}
385
+ for (const [route, prov] of Object.entries((d && d.providers) || {})) {
386
+ const sb = statBucket(prov)
387
+ byProvider[route] = { tokens: sb.tokens, requests: sb.requests, input: sb.input, cacheRead: sb.cacheRead }
388
+ }
389
+ daysList.push({ date: key, tokens: (d && d.tokens) || 0, requests: (d && d.requests) || 0, byProvider })
390
+ }
391
+ const kpi = (s) => ({ requests: s.requests, tokens: s.tokens, cacheHitRate: s.cacheHitRate })
392
+ return {
393
+ generatedAt: Date.now(),
394
+ rangeDays,
395
+ today: kpi(today),
396
+ range: kpi(range),
397
+ last7: kpi(sumRange(dateKey(new Date(now.getTime() - 6 * 86400000)), todayKey)),
398
+ hours,
399
+ days: daysList,
400
+ providers: range.byProvider,
401
+ todayByProvider: Object.entries((data.days[todayKey] && data.days[todayKey].providers) || {}).map(([route, prov]) => {
402
+ const sb = statBucket(prov)
403
+ return { route, requests: sb.requests, tokens: sb.tokens, cacheHitRate: cacheHitRate(sb.cacheRead, sb.input) }
404
+ }),
405
+ models: range.byModel,
406
+ recent: (data.recent || []).slice(-100).reverse(),
407
+ spendToday: spendToday(),
408
+ backfilled: !!data.backfilled,
409
+ }
410
+ }
411
+
412
+ return {
413
+ ready: ensureLoaded,
414
+ backfill,
415
+ observe,
416
+ record,
417
+ stats,
418
+ flush,
419
+ }
420
+ }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@ponydev/dsh-model-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "DSH Web 插件:实时显示模型供应商额度与用量(DeepSeek / GLM Coding Plan / OpenRouter / Kimi Coding Plan + 自定义适配器)",
5
+ "keywords": [
6
+ "dsh",
7
+ "dsh-plugin",
8
+ "deepseek-harness",
9
+ "deepseek",
10
+ "glm",
11
+ "openrouter",
12
+ "kimi",
13
+ "quota",
14
+ "usage",
15
+ "dashboard"
16
+ ],
17
+ "license": "MIT",
18
+ "author": "Pony <205503242+jmaubay7@users.noreply.github.com>",
19
+ "homepage": "https://github.com/PonyDevAI/dsh-model-usage#readme",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/PonyDevAI/dsh-model-usage.git"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/PonyDevAI/dsh-model-usage/issues"
26
+ },
27
+ "type": "module",
28
+ "files": [
29
+ "lib",
30
+ "assets",
31
+ "cordis.patch.yml",
32
+ "docs",
33
+ "README.md",
34
+ "CHANGELOG.md",
35
+ "LICENSE"
36
+ ],
37
+ "exports": {
38
+ ".": "./lib/index.js",
39
+ "./client": "./lib/client.js"
40
+ },
41
+ "engines": {
42
+ "dsh": ">=0.2.0-rc.2 <0.3.0"
43
+ },
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "dsh": {
48
+ "manifestVersion": 1,
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "immediately": true,
55
+ "inject": [
56
+ "@deepseek-ai/dsh-client-ui-primitives",
57
+ "@deepseek-ai/dsh-api-session-controller",
58
+ "@deepseek-ai/dsh-client-ui-sidebar",
59
+ "@deepseek-ai/dsh-client-ui-settings",
60
+ "@deepseek-ai/dsh-client-locale"
61
+ ],
62
+ "external": [
63
+ "@deepseek-ai/dsh-client-ui-primitives"
64
+ ]
65
+ }
66
+ }
67
+ }