dsh-all-usage 1.0.5 → 1.0.7
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/CHANGELOG.md +57 -0
- package/README.md +34 -6
- package/lib/index.js +370 -21
- package/package.json +2 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `dsh-all-usage` are documented here.
|
|
4
|
+
|
|
5
|
+
## [1.0.7] - 2026-08-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added an incremental ledger cursor: baseline rescans reuse the durable ledger as a per-session cursor, so unchanged sessions seed from the ledger instead of re-folding every historical event, and changed sessions fold only the new tail.
|
|
10
|
+
- Added an all-zero usage guard: zero-token usage replays no longer overwrite already-recorded turn/step usage, while pure cache-read requests (billable cache hits with no input/output) are still counted.
|
|
11
|
+
- Added a structured, machine-readable token semantics declaration (`tokenSemantics.semantics`) to the `/api/all-usage` snapshot: input is fresh (excludes cache), cache and reasoning are separate buckets.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- Reduced startup scan cost on long usage history: sessions whose events are unchanged since the last ledger write are no longer re-folded or rewritten.
|
|
16
|
+
- Prevented a live retry of an already-recorded step from double-counting after a fresh-instance seed.
|
|
17
|
+
|
|
18
|
+
## [1.0.6] - 2026-08-23
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Added a durable per-session usage ledger written at the awaited `session/flush` boundary.
|
|
23
|
+
- Preserved successfully flushed turns and token usage after conversation logs are deleted.
|
|
24
|
+
- Added automatic rebuilds from ledger records after session disposal or reconciliation detects missing logs.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Prevented stale baseline snapshots from overwriting newer flushed ledger records.
|
|
29
|
+
- Normalized duplicate ledger keys during hydration and guarded ledger writes during plugin disposal.
|
|
30
|
+
- Prevented inactive Cordis timer contexts from escalating into fatal DSH load failures.
|
|
31
|
+
- Replaced invalid hyphenated storage unit names with valid underscore names.
|
|
32
|
+
|
|
33
|
+
## [1.0.5] - 2026-08-20
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Added custom start/end date ranges across all available historical daily data.
|
|
38
|
+
- Kept the heatmap as a fixed latest-53-week view while allowing older range aggregation and CSV export.
|
|
39
|
+
- Added distinct session counts for bounded ranges.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- Added baseline retry with backoff after transient session registry failures.
|
|
44
|
+
- Stopped in-flight scans and stale polling responses after plugin disposal or refresh races.
|
|
45
|
+
|
|
46
|
+
## [1.0.4] - 2026-08-19
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Hardened loopback, same-origin, method, and process-token checks for dashboard API routes.
|
|
51
|
+
- Allowed same-origin browser balance GET requests that omit `Origin` while retaining token protection.
|
|
52
|
+
- Standardized English date buckets, range filters, streaks, heatmap dates, and export timestamps on UTC.
|
|
53
|
+
|
|
54
|
+
[1.0.7]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.7
|
|
55
|
+
[1.0.6]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.6
|
|
56
|
+
[1.0.5]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.5
|
|
57
|
+
[1.0.4]: https://github.com/ParticleLight/dsh-all-usage/releases/tag/v1.0.4
|
package/README.md
CHANGED
|
@@ -15,8 +15,20 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
15
15
|
- **摘要与工作区**:Token 用量、缓存命中、账户余额、连续使用、工作区 Token 分布和明细
|
|
16
16
|
- **导出**:按当前时间范围和模型聚合方式导出 CSV
|
|
17
17
|
- **时间范围**:今日、近 30 天、近 90 天、全部,或在全部可扫描历史日数据中自定义起止日期;热力图始终展示最近 53 周
|
|
18
|
-
- **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `
|
|
18
|
+
- **工作区别名**:在侧栏入口打开看板后管理,持久化保存到 $DSH_HOME/storages 的 KV 单元 `all_usage_aliases`
|
|
19
19
|
- **界面语言**:在看板顶部切换中文与 English;选择会保存到浏览器本地
|
|
20
|
+
- **完整历史与增量重建**:基线扫描全部可读历史会话;独立用量账本同时作为每会话游标——未变化的会话直接复用账本,新增事件只增量回填,长历史重启不再全量重建
|
|
21
|
+
- **Token 口径**:输入按「未含缓存命中」计,缓存命中 / 写入与推理独立成桶;全 0 用量的重放事件不会覆盖已记录的真实用量,仅缓存命中的请求也会计入
|
|
22
|
+
|
|
23
|
+
### 最近更新
|
|
24
|
+
|
|
25
|
+
**v1.0.7**
|
|
26
|
+
|
|
27
|
+
- 增量游标:启动重扫只增量回填新增事件,未变化的会话直接从独立账本复用,不再全量重建
|
|
28
|
+
- 全 0 用量防护:全 0 用量的重放事件不会覆盖已记录的真实用量;纯缓存命中的请求仍会计入
|
|
29
|
+
- 结构化语义声明:API 返回机读的 token 口径(输入不含缓存命中,缓存与推理独立成桶)
|
|
30
|
+
|
|
31
|
+
完整版本记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
20
32
|
|
|
21
33
|
### 截图 / Screenshots
|
|
22
34
|
|
|
@@ -26,7 +38,7 @@ DeepSeek Harness 全量用量看板:按模型、供应商、工作区和时间
|
|
|
26
38
|
|
|
27
39
|
### 安装
|
|
28
40
|
|
|
29
|
-
本插件是标准的 DSH 社区插件包(声明 `dsh.bundle` manifest 与 Web Client
|
|
41
|
+
本插件是标准的 DSH 社区插件包(声明 `dsh.bundle` manifest 与 Web Client),数据来自持久化会话日志和独立用量账本,安装后自动回填历史。
|
|
30
42
|
|
|
31
43
|
#### 官方插件命令(推荐)
|
|
32
44
|
|
|
@@ -64,9 +76,11 @@ dsh plugin --profile web add github:ParticleLight/dsh-all-usage
|
|
|
64
76
|
|
|
65
77
|
### 数据说明
|
|
66
78
|
|
|
67
|
-
- 使用次数与 Token
|
|
79
|
+
- 使用次数与 Token 来自 DSH 会话日志,并在 `session/flush` 时写入独立用量账本;插件激活时会回填日志与账本历史,插件卸载/重启后已成功持久化的数据不丢
|
|
68
80
|
- 按日范围统计会保留全部可读取历史会话的有使用记录日期;热力图仅作为最近 53 周的固定视图窗口
|
|
81
|
+
- 会话删除后,已成功 flush 的用量仍从独立账本恢复;会话销毁提示和周期对账只负责触发重建,不会删除账本记录
|
|
69
82
|
- 同一会话的同一 `turn / step` 只保留一份最终 usage;重试或替换消息会替换旧贡献,不重复累计
|
|
83
|
+
- 输入 Token 按「未含缓存命中」计(缓存命中 / 写入独立成桶);全 0 用量的重放事件不会覆盖已记录的真实用量,纯缓存命中的请求仍会计入
|
|
70
84
|
- 看板中的总处理量 = 输入 + 输出 + 缓存读写 + 推理;缓存命中表示复用的上下文 Token,不等于新生成 Token 或实际费用
|
|
71
85
|
- 余额查询走 DeepSeek 官方 `/user/balance` 接口;未配置 API Key 时卡片显示引导文案
|
|
72
86
|
- 仅统计能归属到已注册工作区(按会话 cwd 匹配)的会话
|
|
@@ -87,12 +101,24 @@ A full usage dashboard for DeepSeek Harness. Analyze tokens, cache behavior, acc
|
|
|
87
101
|
- **Summary and workspaces**: processed tokens, cache hits, account balance, usage streaks, workspace distribution, and details
|
|
88
102
|
- **CSV export**: export data using the selected time range and aggregation mode
|
|
89
103
|
- **Time ranges**: today, last 30 days, last 90 days, all time, or a custom start/end date across all available historical daily data; the heatmap always shows the latest 53 weeks
|
|
90
|
-
- **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `
|
|
104
|
+
- **Workspace aliases**: manage aliases from the sidebar dashboard; values persist in the $DSH_HOME/storages KV cell `all_usage_aliases`
|
|
91
105
|
- **Interface language**: switch between Chinese and English from the dashboard header; your choice persists locally in the browser
|
|
106
|
+
- **Full history & incremental rebuild**: the baseline scans every readable historical session; the durable usage ledger doubles as a per-session cursor, so unchanged sessions are reused straight from the ledger and only newly appended events are folded — long histories restart without a full rebuild
|
|
107
|
+
- **Token accounting semantics**: input tokens are fresh (exclude cache hits/writes, which sit in separate buckets along with reasoning); all-zero usage replays never overwrite recorded usage, while cache-only requests still count
|
|
108
|
+
|
|
109
|
+
### Latest Update
|
|
110
|
+
|
|
111
|
+
**v1.0.7**
|
|
112
|
+
|
|
113
|
+
- Incremental scan cursor: restarts re-fold only newly appended events; unchanged sessions are seeded straight from the durable ledger instead of a full rebuild
|
|
114
|
+
- All-zero usage guard: zero-usage replays no longer overwrite recorded usage, while pure cache-read requests still count
|
|
115
|
+
- Structured token semantics: the API now declares a machine-readable accounting contract (input excludes cache; cache and reasoning are separate buckets)
|
|
116
|
+
|
|
117
|
+
See [CHANGELOG.md](CHANGELOG.md) for the complete version history.
|
|
92
118
|
|
|
93
119
|
### Installation
|
|
94
120
|
|
|
95
|
-
This is a standard DSH community bundle. It declares a `dsh.bundle` manifest and a web client, and backfills its data from persisted session logs after installation.
|
|
121
|
+
This is a standard DSH community bundle. It declares a `dsh.bundle` manifest and a web client, and backfills its data from persisted session logs and the durable usage ledger after installation.
|
|
96
122
|
|
|
97
123
|
#### Official plugin command (recommended)
|
|
98
124
|
|
|
@@ -130,9 +156,11 @@ The profile patch layer hot-reloads; save the file and refresh the page.
|
|
|
130
156
|
|
|
131
157
|
### Data semantics
|
|
132
158
|
|
|
133
|
-
-
|
|
159
|
+
- Calls and tokens come from DSH session logs and a separate usage ledger written at `session/flush`; readable logs and ledger history are backfilled when the plugin activates, so successfully persisted data survives reloads or session deletion
|
|
134
160
|
- Day-level range data retains every readable historical session date with tracked usage; the heatmap is only a fixed latest-53-week view
|
|
161
|
+
- After a session is deleted, successfully flushed usage is restored from the separate ledger; disposal hints and periodic reconciliation trigger rebuilds without deleting ledger rows
|
|
135
162
|
- For each session and logical `turn / step`, only the final usage contribution is kept; retries or replaced messages do not double-count
|
|
163
|
+
- Input tokens are fresh (exclude cache hits/writes, which sit in their own buckets); all-zero usage replays do not overwrite recorded usage and pure cache-read requests still count
|
|
136
164
|
- Processed tokens = input + output + cache read/write + reasoning; a cache hit means reused context, not newly generated tokens or actual cost
|
|
137
165
|
- Balance data comes from DeepSeek’s official `/user/balance` endpoint; the card shows guidance when no API key is configured
|
|
138
166
|
- English mode uses UTC for date buckets, range filters, streaks, heatmap dates, and export timestamps; Chinese mode uses local time
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
|
4
4
|
|
|
5
5
|
const name = 'dsh-all-usage'
|
|
6
|
-
const inject = ['sessionQuery', 'workspaceRegistry', 'timer']
|
|
6
|
+
const inject = ['sessionQuery', 'workspaceRegistry', 'timer', 'storage']
|
|
7
7
|
|
|
8
8
|
// webServer route handlers do not inherit the connection API fence; keep this plugin
|
|
9
9
|
// local and require a browser-originated capability for state-changing reads/writes.
|
|
@@ -98,9 +98,24 @@ function apply(ctx) {
|
|
|
98
98
|
let aliasWriteChain = Promise.resolve()
|
|
99
99
|
let balanceCache = { fetchedAt: 0, payload: null }
|
|
100
100
|
const requestToken = randomBytes(32).toString('base64url')
|
|
101
|
+
const ledgerRecords = new Map()
|
|
102
|
+
let ledgerUnit = null
|
|
103
|
+
let ledgerReady = Promise.resolve()
|
|
104
|
+
let ledgerWriteChain = Promise.resolve()
|
|
105
|
+
const LEDGER_VERSION = 1
|
|
106
|
+
let ledgerRevision = Date.now()
|
|
101
107
|
let disposed = false
|
|
102
108
|
let baselineRetryDelay = 1000
|
|
103
109
|
let baselineRetryScheduled = false
|
|
110
|
+
let baselineFallbackTimer = null
|
|
111
|
+
const knownSessionIds = new Set()
|
|
112
|
+
let aggregationGeneration = 0
|
|
113
|
+
let reconcileHintScheduled = false
|
|
114
|
+
let reconcilePending = false
|
|
115
|
+
let reconcileInFlight = false
|
|
116
|
+
let reconcileTimer = null
|
|
117
|
+
const RECONCILE_INTERVAL_MS = 120000
|
|
118
|
+
const RECONCILE_HINT_DELAY_MS = 3000
|
|
104
119
|
|
|
105
120
|
function dayKey(ms) {
|
|
106
121
|
const d = new Date(ms)
|
|
@@ -113,6 +128,45 @@ function apply(ctx) {
|
|
|
113
128
|
function num(v) {
|
|
114
129
|
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
|
115
130
|
}
|
|
131
|
+
async function safeContextTimeout(ms) {
|
|
132
|
+
if (disposed) return false
|
|
133
|
+
try {
|
|
134
|
+
await ctx.timeout(ms)
|
|
135
|
+
return !disposed
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (!disposed) console.error('[all-usage] context timer unavailable:', err)
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resetAggregationState() {
|
|
142
|
+
aggregationGeneration += 1
|
|
143
|
+
wsMeta.clear()
|
|
144
|
+
pathIndex.clear()
|
|
145
|
+
memberOf.clear()
|
|
146
|
+
byDay.clear()
|
|
147
|
+
byDayUtc.clear()
|
|
148
|
+
perWorkspace.clear()
|
|
149
|
+
perModel.clear()
|
|
150
|
+
usageByStep.clear()
|
|
151
|
+
sessionModel.clear()
|
|
152
|
+
sessionCount.clear()
|
|
153
|
+
sessionSeq.clear()
|
|
154
|
+
chains.clear()
|
|
155
|
+
knownSessionIds.clear()
|
|
156
|
+
totals.turns = 0
|
|
157
|
+
totals.input = 0
|
|
158
|
+
totals.output = 0
|
|
159
|
+
totals.cacheRead = 0
|
|
160
|
+
totals.cacheWrite = 0
|
|
161
|
+
totals.reasoning = 0
|
|
162
|
+
scan.started = false
|
|
163
|
+
scan.done = false
|
|
164
|
+
scan.scanned = 0
|
|
165
|
+
scan.total = 0
|
|
166
|
+
scan.failed = 0
|
|
167
|
+
baselineRetryDelay = 1000
|
|
168
|
+
return aggregationGeneration
|
|
169
|
+
}
|
|
116
170
|
function ensureDay(dayMap, date) {
|
|
117
171
|
let day = dayMap.get(date)
|
|
118
172
|
if (day === undefined) {
|
|
@@ -205,6 +259,10 @@ function apply(ctx) {
|
|
|
205
259
|
const values = usageValues(usage)
|
|
206
260
|
const modelId = typeof model === 'string' && model !== '' ? model : '未知模型(历史记录缺少路由)'
|
|
207
261
|
const eventSeq = typeof seq === 'number' ? seq : -1
|
|
262
|
+
// v1.0.7: a usage event carrying no billable token in any bucket must not add a
|
|
263
|
+
// meaningless row nor wipe previously recorded real usage (cc-switch
|
|
264
|
+
// has_billable_tokens parity). Pure cache-read requests are billable and pass.
|
|
265
|
+
if (noValues(values)) return
|
|
208
266
|
const key = usageStepKey(sid, data, seq)
|
|
209
267
|
const previous = usageByStep.get(key)
|
|
210
268
|
// A late replay of an older raw event cannot replace the canonical later step.
|
|
@@ -236,6 +294,96 @@ function apply(ctx) {
|
|
|
236
294
|
function modelFromMessage(data, fallback) {
|
|
237
295
|
return routeLabel(data && data.message && data.message.source) || fallback
|
|
238
296
|
}
|
|
297
|
+
function nextLedgerRevision() {
|
|
298
|
+
ledgerRevision = Math.max(ledgerRevision + 1, Date.now())
|
|
299
|
+
return ledgerRevision
|
|
300
|
+
}
|
|
301
|
+
function ledgerEventKey(event, index) {
|
|
302
|
+
return typeof event.seq === 'number' ? String(event.seq) : 'event:' + index
|
|
303
|
+
}
|
|
304
|
+
function buildLedgerRecord(session, workspaceId, source = 'scan') {
|
|
305
|
+
const sid = session && typeof session.id === 'string' ? session.id : ''
|
|
306
|
+
const events = session && Array.isArray(session.events) ? session.events : []
|
|
307
|
+
if (sid === '' || workspaceId === undefined) return null
|
|
308
|
+
const turns = new Map()
|
|
309
|
+
const usage = new Map()
|
|
310
|
+
let currentModel
|
|
311
|
+
for (let index = 0; index < events.length; index += 1) {
|
|
312
|
+
const event = events[index]
|
|
313
|
+
if (event === null || typeof event !== 'object') continue
|
|
314
|
+
const data = event.data
|
|
315
|
+
if (event.type === 'request/context' || event.type === 'request/header') {
|
|
316
|
+
const model = modelFromRoute(data)
|
|
317
|
+
if (model !== undefined) currentModel = model
|
|
318
|
+
continue
|
|
319
|
+
}
|
|
320
|
+
if (event.type === 'turn/end') {
|
|
321
|
+
const key = ledgerEventKey(event, index)
|
|
322
|
+
turns.set(key, { key, time: event.time, workspaceId })
|
|
323
|
+
continue
|
|
324
|
+
}
|
|
325
|
+
if (event.type !== 'assistant/message' || data === null || typeof data !== 'object' || data.usage === undefined) continue
|
|
326
|
+
const values = usageValues(data.usage)
|
|
327
|
+
// v1.0.7: all-zero usage rows carry no billable tokens and stay out of the
|
|
328
|
+
// durable ledger (cc-switch has_billable_tokens parity).
|
|
329
|
+
if (noValues(values)) continue
|
|
330
|
+
const modelId = typeof modelFromMessage(data, currentModel) === 'string' && modelFromMessage(data, currentModel) !== ''
|
|
331
|
+
? modelFromMessage(data, currentModel) : '未知模型(历史记录缺少路由)'
|
|
332
|
+
const eventSeq = typeof event.seq === 'number' ? event.seq : -1
|
|
333
|
+
const key = usageStepKey(sid, data, event.seq)
|
|
334
|
+
const previous = usage.get(key)
|
|
335
|
+
if (previous !== undefined && eventSeq >= 0 && previous.seq > eventSeq) continue
|
|
336
|
+
usage.set(key, { key, seq: eventSeq, time: event.time, workspaceId, modelId, values })
|
|
337
|
+
}
|
|
338
|
+
return { version: LEDGER_VERSION, sessionId: sid, workspaceId, lastSeq: lastSeqOf(events), source, updatedAt: nextLedgerRevision(), turns: Array.from(turns.values()), usage: Array.from(usage.values()) }
|
|
339
|
+
}
|
|
340
|
+
function normalizeLedgerRecord(raw, key) {
|
|
341
|
+
if (raw === null || typeof raw !== 'object' || raw.version !== LEDGER_VERSION || typeof raw.sessionId !== 'string' || raw.sessionId !== key) return null
|
|
342
|
+
if (!Array.isArray(raw.turns) || !Array.isArray(raw.usage)) return null
|
|
343
|
+
const turnMap = new Map()
|
|
344
|
+
for (const turn of raw.turns) {
|
|
345
|
+
if (turn && typeof turn.key === 'string' && turn.workspaceId !== undefined && Number.isFinite(turn.time)) turnMap.set(turn.key, { key: turn.key, time: turn.time, workspaceId: turn.workspaceId })
|
|
346
|
+
}
|
|
347
|
+
const usageMap = new Map()
|
|
348
|
+
for (const item of raw.usage) {
|
|
349
|
+
if (!item || typeof item.key !== 'string' || item.workspaceId === undefined || typeof item.modelId !== 'string' || !Number.isFinite(item.time) || item.values === null || typeof item.values !== 'object') continue
|
|
350
|
+
const normalized = { key: item.key, seq: typeof item.seq === 'number' ? item.seq : -1, time: item.time, workspaceId: item.workspaceId, modelId: item.modelId, values: usageValues({ inputTokens: item.values.input, outputTokens: item.values.output, cacheReadTokens: item.values.cacheRead, cacheWriteTokens: item.values.cacheWrite, reasoningTokens: item.values.reasoning }) }
|
|
351
|
+
const previous = usageMap.get(normalized.key)
|
|
352
|
+
if (previous === undefined || previous.seq <= normalized.seq) usageMap.set(normalized.key, normalized)
|
|
353
|
+
}
|
|
354
|
+
const updatedAt = typeof raw.updatedAt === 'number' ? raw.updatedAt : 0
|
|
355
|
+
ledgerRevision = Math.max(ledgerRevision, updatedAt)
|
|
356
|
+
return { version: LEDGER_VERSION, sessionId: raw.sessionId, workspaceId: raw.workspaceId, lastSeq: typeof raw.lastSeq === 'number' ? raw.lastSeq : -1, source: raw.source === 'flush' ? 'flush' : 'scan', updatedAt, turns: Array.from(turnMap.values()), usage: Array.from(usageMap.values()) }
|
|
357
|
+
}
|
|
358
|
+
function applyLedgerRecord(record) {
|
|
359
|
+
if (record === null || record === undefined) return
|
|
360
|
+
for (const turn of record.turns) addTurn(turn.workspaceId, turn.time, record.sessionId)
|
|
361
|
+
for (const item of record.usage) adjustUsage(item.workspaceId, item.time, item.values, item.modelId, 1, record.sessionId)
|
|
362
|
+
if (record.turns.length > 0 || record.usage.length > 0) sessionCount.add(record.sessionId)
|
|
363
|
+
}
|
|
364
|
+
function ledgerRank(record) {
|
|
365
|
+
return [typeof record.lastSeq === 'number' ? record.lastSeq : -1, record.source === 'flush' ? 1 : 0, typeof record.updatedAt === 'number' ? record.updatedAt : 0]
|
|
366
|
+
}
|
|
367
|
+
function storeLedgerRecord(record) {
|
|
368
|
+
const current = ledgerRecords.get(record.sessionId)
|
|
369
|
+
if (current !== undefined) {
|
|
370
|
+
const nextRank = ledgerRank(record)
|
|
371
|
+
const currentRank = ledgerRank(current)
|
|
372
|
+
if (nextRank[0] < currentRank[0] || (nextRank[0] === currentRank[0] && (nextRank[1] < currentRank[1] || (nextRank[1] === currentRank[1] && nextRank[2] <= currentRank[2])))) return current
|
|
373
|
+
}
|
|
374
|
+
ledgerRecords.set(record.sessionId, record)
|
|
375
|
+
return record
|
|
376
|
+
}
|
|
377
|
+
function persistLedgerRecord(record) {
|
|
378
|
+
if (ledgerUnit === null || record === null || record === undefined) return ledgerWriteChain
|
|
379
|
+
const write = ledgerWriteChain.then(async () => {
|
|
380
|
+
if (ledgerUnit !== null && ledgerRecords.get(record.sessionId) === record) await ledgerUnit.putRecord('sessions', record.sessionId, record)
|
|
381
|
+
})
|
|
382
|
+
ledgerWriteChain = write.catch((err) => {
|
|
383
|
+
console.error('[all-usage] usage ledger write failed:', err)
|
|
384
|
+
})
|
|
385
|
+
return ledgerWriteChain
|
|
386
|
+
}
|
|
239
387
|
function foldEvent(wsId, time, type, data, sid, seq) {
|
|
240
388
|
if (type === 'request/context' || type === 'request/header') {
|
|
241
389
|
const model = modelFromRoute(data)
|
|
@@ -276,13 +424,14 @@ function apply(ctx) {
|
|
|
276
424
|
if (wsId !== undefined) memberOf.set(sid, wsId)
|
|
277
425
|
return wsId
|
|
278
426
|
}
|
|
279
|
-
async function processLiveEvent(sid, wsId, event) {
|
|
280
|
-
if (disposed) return
|
|
427
|
+
async function processLiveEvent(sid, wsId, event, generation = aggregationGeneration) {
|
|
428
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
281
429
|
const seq = typeof event.seq === 'number' ? event.seq : -1
|
|
282
430
|
const last = sessionSeq.get(sid)
|
|
283
431
|
if (last === undefined) {
|
|
284
432
|
try {
|
|
285
433
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
434
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
286
435
|
if (snap && Array.isArray(snap.events)) {
|
|
287
436
|
foldEvents(wsId, snap.events, undefined, sid)
|
|
288
437
|
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
@@ -295,6 +444,7 @@ function apply(ctx) {
|
|
|
295
444
|
if (seq > last + 1) {
|
|
296
445
|
try {
|
|
297
446
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
447
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
298
448
|
if (snap && Array.isArray(snap.events)) {
|
|
299
449
|
foldEvents(wsId, snap.events, last, sid)
|
|
300
450
|
sessionSeq.set(sid, lastSeqOf(snap.events))
|
|
@@ -307,21 +457,55 @@ function apply(ctx) {
|
|
|
307
457
|
sessionCount.add(sid)
|
|
308
458
|
}
|
|
309
459
|
|
|
460
|
+
// ---------- durable usage ledger ----------
|
|
461
|
+
async function loadLedger() {
|
|
462
|
+
if (storage === undefined || storage.backend === undefined || typeof storage.backend.get !== 'function') return
|
|
463
|
+
try {
|
|
464
|
+
const backend = storage.backend.get('json')
|
|
465
|
+
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
466
|
+
const unit = await backend.kv.open({ name: 'all_usage_ledger', version: 0, tables: ['sessions'], hasGlobal: false })
|
|
467
|
+
if (disposed) { await unit.close().catch(() => {}); return }
|
|
468
|
+
ledgerUnit = unit
|
|
469
|
+
const snapshot = await unit.loadAll()
|
|
470
|
+
const rows = snapshot && snapshot.tables && snapshot.tables.sessions
|
|
471
|
+
if (rows !== null && rows !== undefined && typeof rows === 'object') {
|
|
472
|
+
for (const [key, raw] of Object.entries(rows)) {
|
|
473
|
+
const record = normalizeLedgerRecord(raw, key)
|
|
474
|
+
if (record === null) console.warn('[all-usage] ignoring malformed usage ledger row:', key)
|
|
475
|
+
else ledgerRecords.set(key, record)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch (err) {
|
|
479
|
+
console.error('[all-usage] usage ledger unavailable:', err)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
310
483
|
// ---------- baseline scan over durable logs ----------
|
|
311
|
-
function
|
|
312
|
-
if (disposed ||
|
|
484
|
+
function scheduleNativeBaselineRetry(generation, delay) {
|
|
485
|
+
if (disposed || baselineFallbackTimer !== null) return
|
|
486
|
+
baselineFallbackTimer = setTimeout(() => {
|
|
487
|
+
baselineFallbackTimer = null
|
|
488
|
+
if (!disposed && generation === aggregationGeneration && !scan.started && !scan.done) void runBaseline(generation)
|
|
489
|
+
}, delay)
|
|
490
|
+
if (baselineFallbackTimer && typeof baselineFallbackTimer.unref === 'function') baselineFallbackTimer.unref()
|
|
491
|
+
}
|
|
492
|
+
function scheduleBaselineRetry(generation = aggregationGeneration) {
|
|
493
|
+
if (disposed || baselineRetryScheduled || scan.done || generation !== aggregationGeneration) return
|
|
313
494
|
baselineRetryScheduled = true
|
|
314
495
|
const delay = baselineRetryDelay
|
|
315
496
|
baselineRetryDelay = Math.min(baselineRetryDelay * 2, 30000)
|
|
316
|
-
void
|
|
497
|
+
void safeContextTimeout(delay).then((ready) => {
|
|
317
498
|
baselineRetryScheduled = false
|
|
318
|
-
if (
|
|
499
|
+
if (ready && generation === aggregationGeneration && !scan.started && !scan.done) return runBaseline(generation)
|
|
500
|
+
if (!ready && generation === aggregationGeneration && !disposed) scheduleNativeBaselineRetry(generation, delay)
|
|
319
501
|
return undefined
|
|
320
502
|
})
|
|
321
503
|
}
|
|
322
|
-
async function runBaseline() {
|
|
323
|
-
if (scan.started || disposed) return
|
|
504
|
+
async function runBaseline(generation = aggregationGeneration) {
|
|
505
|
+
if (scan.started || disposed || generation !== aggregationGeneration) return
|
|
324
506
|
scan.started = true
|
|
507
|
+
await ledgerReady
|
|
508
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
325
509
|
let setupFailed = false
|
|
326
510
|
try {
|
|
327
511
|
const workspaces = ctx.workspaceRegistry.list()
|
|
@@ -346,16 +530,27 @@ function apply(ctx) {
|
|
|
346
530
|
} catch (err) {
|
|
347
531
|
console.error('[all-usage] session list failed:', err)
|
|
348
532
|
}
|
|
349
|
-
if (disposed) return
|
|
533
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
350
534
|
if (setupFailed || !Array.isArray(records)) {
|
|
351
535
|
// A transient registry failure must not be reported as a completed empty scan.
|
|
352
536
|
scan.started = false
|
|
353
|
-
scheduleBaselineRetry()
|
|
537
|
+
scheduleBaselineRetry(generation)
|
|
354
538
|
return
|
|
355
539
|
}
|
|
356
540
|
scan.total = records.length
|
|
541
|
+
const listedSessionIds = new Set()
|
|
357
542
|
for (const record of records) {
|
|
358
|
-
if (
|
|
543
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
544
|
+
const sid = record.header.id
|
|
545
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
546
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
547
|
+
if (sid !== undefined && wsId !== undefined) listedSessionIds.add(sid)
|
|
548
|
+
}
|
|
549
|
+
for (const [sid, record] of ledgerRecords) {
|
|
550
|
+
if (!listedSessionIds.has(sid)) applyLedgerRecord(record)
|
|
551
|
+
}
|
|
552
|
+
for (const record of records) {
|
|
553
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
359
554
|
if (record === undefined || record === null || record.header === undefined) {
|
|
360
555
|
scan.scanned += 1
|
|
361
556
|
continue
|
|
@@ -367,26 +562,136 @@ function apply(ctx) {
|
|
|
367
562
|
scan.scanned += 1
|
|
368
563
|
continue
|
|
369
564
|
}
|
|
565
|
+
listedSessionIds.add(sid)
|
|
370
566
|
await enqueue(sid, async () => {
|
|
371
|
-
if (disposed) return
|
|
567
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
372
568
|
try {
|
|
373
569
|
if (sessionSeq.has(sid)) return
|
|
374
570
|
const snap = await ctx.sessionQuery.readSession(sid)
|
|
571
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
375
572
|
if (snap && Array.isArray(snap.events)) {
|
|
376
|
-
|
|
377
|
-
|
|
573
|
+
// v1.0.7: incremental seed — the durable ledger doubles as a per-session
|
|
574
|
+
// cursor (cc-switch session_log_sync mtime+offset parity). An unchanged
|
|
575
|
+
// session applies its canonical record directly and never re-folds;
|
|
576
|
+
// a changed session seeds the previous record once, then folds only the
|
|
577
|
+
// new tail (previously every listed session was re-read and fully rebuilt).
|
|
578
|
+
const currentLastSeq = lastSeqOf(snap.events)
|
|
579
|
+
const previous = ledgerRecords.get(sid)
|
|
580
|
+
if (previous !== undefined) {
|
|
581
|
+
applyLedgerRecord(previous)
|
|
582
|
+
// Seed usageByStep so a later live retry of an already-recorded step
|
|
583
|
+
// reverses the ledger contribution instead of double-counting it.
|
|
584
|
+
for (const item of previous.usage) {
|
|
585
|
+
usageByStep.set(item.key, { seq: item.seq, wsId: item.workspaceId, time: item.time, values: item.values, modelId: item.modelId, sid })
|
|
586
|
+
}
|
|
587
|
+
if (previous.lastSeq >= currentLastSeq) {
|
|
588
|
+
sessionSeq.set(sid, previous.lastSeq)
|
|
589
|
+
sessionCount.add(sid)
|
|
590
|
+
return
|
|
591
|
+
}
|
|
592
|
+
foldEvents(wsId, snap.events, previous.lastSeq, sid)
|
|
593
|
+
} else {
|
|
594
|
+
foldEvents(wsId, snap.events, undefined, sid)
|
|
595
|
+
}
|
|
596
|
+
const ledger = buildLedgerRecord({ id: sid, header: record.header, events: snap.events }, wsId)
|
|
597
|
+
const canonical = ledger === null ? ledgerRecords.get(sid) : storeLedgerRecord(ledger)
|
|
598
|
+
if (canonical === ledger) {
|
|
599
|
+
void persistLedgerRecord(ledger)
|
|
600
|
+
} else if (canonical !== undefined) {
|
|
601
|
+
applyLedgerRecord(canonical)
|
|
602
|
+
}
|
|
603
|
+
sessionSeq.set(sid, currentLastSeq)
|
|
378
604
|
sessionCount.add(sid)
|
|
379
605
|
}
|
|
380
606
|
} catch (err) {
|
|
381
|
-
|
|
382
|
-
|
|
607
|
+
const saved = ledgerRecords.get(sid)
|
|
608
|
+
if (saved !== undefined) {
|
|
609
|
+
applyLedgerRecord(saved)
|
|
610
|
+
sessionSeq.set(sid, -1)
|
|
611
|
+
sessionCount.add(sid)
|
|
612
|
+
} else if (generation === aggregationGeneration) {
|
|
613
|
+
sessionSeq.set(sid, -1)
|
|
614
|
+
scan.failed += 1
|
|
615
|
+
}
|
|
383
616
|
} finally {
|
|
384
|
-
scan.scanned += 1
|
|
617
|
+
if (generation === aggregationGeneration) scan.scanned += 1
|
|
385
618
|
}
|
|
386
619
|
})
|
|
387
|
-
await
|
|
620
|
+
if (!(await safeContextTimeout(0))) {
|
|
621
|
+
scan.started = false
|
|
622
|
+
scheduleBaselineRetry(generation)
|
|
623
|
+
return
|
|
624
|
+
}
|
|
388
625
|
}
|
|
626
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
627
|
+
await ledgerWriteChain
|
|
628
|
+
if (disposed || generation !== aggregationGeneration) return
|
|
629
|
+
knownSessionIds.clear()
|
|
630
|
+
for (const sid of listedSessionIds) knownSessionIds.add(sid)
|
|
389
631
|
scan.done = true
|
|
632
|
+
if (reconcilePending) scheduleReconcileHint()
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function sessionIdsFromRecords(records) {
|
|
636
|
+
const ids = new Set()
|
|
637
|
+
for (const record of records) {
|
|
638
|
+
if (record === undefined || record === null || record.header === undefined) continue
|
|
639
|
+
const sid = record.header.id
|
|
640
|
+
const cwd = typeof record.header.cwd === 'string' ? record.header.cwd : ''
|
|
641
|
+
const wsId = cwd === '' ? undefined : pathIndex.get(cwd)
|
|
642
|
+
if (sid !== undefined && wsId !== undefined) ids.add(sid)
|
|
643
|
+
}
|
|
644
|
+
return ids
|
|
645
|
+
}
|
|
646
|
+
async function reconcileSessions() {
|
|
647
|
+
if (disposed || reconcileInFlight || !scan.done) return
|
|
648
|
+
reconcilePending = false
|
|
649
|
+
reconcileInFlight = true
|
|
650
|
+
try {
|
|
651
|
+
const records = await ctx.sessionQuery.listSessions()
|
|
652
|
+
if (disposed || !Array.isArray(records)) return
|
|
653
|
+
const currentIds = sessionIdsFromRecords(records)
|
|
654
|
+
let removed = false
|
|
655
|
+
for (const sid of knownSessionIds) {
|
|
656
|
+
if (!currentIds.has(sid)) { removed = true; break }
|
|
657
|
+
}
|
|
658
|
+
if (removed && !disposed && scan.done) {
|
|
659
|
+
console.info('[all-usage] session removal detected; rebuilding usage index')
|
|
660
|
+
const generation = resetAggregationState()
|
|
661
|
+
void runBaseline(generation)
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
knownSessionIds.clear()
|
|
665
|
+
for (const sid of currentIds) knownSessionIds.add(sid)
|
|
666
|
+
} catch (err) {
|
|
667
|
+
console.error('[all-usage] session reconciliation failed:', err)
|
|
668
|
+
} finally {
|
|
669
|
+
reconcileInFlight = false
|
|
670
|
+
if (reconcilePending && !disposed) scheduleReconcileHint()
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function scheduleReconcileHint() {
|
|
674
|
+
if (disposed) return
|
|
675
|
+
reconcilePending = true
|
|
676
|
+
if (reconcileHintScheduled || reconcileInFlight) return
|
|
677
|
+
reconcileHintScheduled = true
|
|
678
|
+
void safeContextTimeout(RECONCILE_HINT_DELAY_MS).then((ready) => {
|
|
679
|
+
reconcileHintScheduled = false
|
|
680
|
+
if (ready && !disposed) void reconcileSessions()
|
|
681
|
+
}, () => {
|
|
682
|
+
reconcileHintScheduled = false
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
function scheduleReconcileTimer() {
|
|
686
|
+
if (disposed || reconcileTimer !== null) return
|
|
687
|
+
reconcileTimer = setTimeout(() => {
|
|
688
|
+
reconcileTimer = null
|
|
689
|
+
if (!disposed) {
|
|
690
|
+
void reconcileSessions()
|
|
691
|
+
scheduleReconcileTimer()
|
|
692
|
+
}
|
|
693
|
+
}, RECONCILE_INTERVAL_MS)
|
|
694
|
+
if (reconcileTimer && typeof reconcileTimer.unref === 'function') reconcileTimer.unref()
|
|
390
695
|
}
|
|
391
696
|
|
|
392
697
|
// ---------- live feed ----------
|
|
@@ -399,7 +704,23 @@ function apply(ctx) {
|
|
|
399
704
|
if (typeof sid !== 'string') return
|
|
400
705
|
const wsId = wsForLiveSession(session, sid)
|
|
401
706
|
if (wsId === undefined) return
|
|
402
|
-
|
|
707
|
+
const generation = aggregationGeneration
|
|
708
|
+
knownSessionIds.add(sid)
|
|
709
|
+
enqueue(sid, () => processLiveEvent(sid, wsId, event, generation))
|
|
710
|
+
})
|
|
711
|
+
ctx.on('session/flush', async (session) => {
|
|
712
|
+
if (disposed || session === null || typeof session !== 'object' || typeof session.id !== 'string') return
|
|
713
|
+
await ledgerReady
|
|
714
|
+
if (disposed) return
|
|
715
|
+
const wsId = wsForLiveSession(session, session.id)
|
|
716
|
+
if (wsId === undefined) return
|
|
717
|
+
const ledger = buildLedgerRecord(session, wsId, 'flush')
|
|
718
|
+
if (ledger === null) return
|
|
719
|
+
const canonical = storeLedgerRecord(ledger)
|
|
720
|
+
if (canonical === ledger) await persistLedgerRecord(ledger)
|
|
721
|
+
})
|
|
722
|
+
ctx.on('session/disposed', () => {
|
|
723
|
+
if (!disposed) scheduleReconcileHint()
|
|
403
724
|
})
|
|
404
725
|
|
|
405
726
|
// ---------- workspace aliases (durable, schema-free KV unit) ----------
|
|
@@ -408,7 +729,7 @@ function apply(ctx) {
|
|
|
408
729
|
try {
|
|
409
730
|
const backend = storage.backend.get('json')
|
|
410
731
|
if (backend === undefined || backend === null || backend.kv === undefined) return
|
|
411
|
-
const unit = await backend.kv.open({ name: '
|
|
732
|
+
const unit = await backend.kv.open({ name: 'all_usage_aliases', version: 0, tables: [], hasGlobal: true })
|
|
412
733
|
kvUnit = unit
|
|
413
734
|
const snap = await unit.loadAll()
|
|
414
735
|
const g = snap && snap.global
|
|
@@ -442,12 +763,26 @@ function apply(ctx) {
|
|
|
442
763
|
}
|
|
443
764
|
ctx.effect(() => () => {
|
|
444
765
|
disposed = true
|
|
766
|
+
if (reconcileTimer !== null) {
|
|
767
|
+
clearTimeout(reconcileTimer)
|
|
768
|
+
reconcileTimer = null
|
|
769
|
+
}
|
|
770
|
+
if (baselineFallbackTimer !== null) {
|
|
771
|
+
clearTimeout(baselineFallbackTimer)
|
|
772
|
+
baselineFallbackTimer = null
|
|
773
|
+
}
|
|
445
774
|
})
|
|
446
775
|
ctx.effect(() => () => {
|
|
447
776
|
const unit = kvUnit
|
|
448
777
|
kvUnit = null
|
|
449
778
|
if (unit !== null && unit !== undefined) void unit.close().catch(() => {})
|
|
450
779
|
})
|
|
780
|
+
ctx.effect(() => async () => {
|
|
781
|
+
const unit = ledgerUnit
|
|
782
|
+
await ledgerWriteChain
|
|
783
|
+
if (unit !== null && unit !== undefined) await unit.close().catch(() => {})
|
|
784
|
+
if (ledgerUnit === unit) ledgerUnit = null
|
|
785
|
+
})
|
|
451
786
|
|
|
452
787
|
// ---------- snapshot for the client ----------
|
|
453
788
|
function serializeDays(dayMap) {
|
|
@@ -480,6 +815,18 @@ function apply(ctx) {
|
|
|
480
815
|
processedTotal: 'input + output + cacheRead + cacheWrite + reasoning',
|
|
481
816
|
cacheRead: 'reused context tokens; not newly generated output',
|
|
482
817
|
cacheWrite: 'tokens written into a provider cache',
|
|
818
|
+
// v1.0.7: structured, machine-readable accounting semantics (cc-switch
|
|
819
|
+
// input_token_semantics parity). DSH reports input as fresh (cache read /
|
|
820
|
+
// cache write sit in their own buckets) — verified against real ledger data;
|
|
821
|
+
// reasoning is bucketed separately from output and assumed non-overlapping.
|
|
822
|
+
semantics: {
|
|
823
|
+
input: 'fresh (excludes cache-read and cache-write tokens, bucketed separately)',
|
|
824
|
+
buckets: ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning'],
|
|
825
|
+
inputIncludesCache: false,
|
|
826
|
+
cacheBucketed: true,
|
|
827
|
+
reasoningSeparate: true,
|
|
828
|
+
gate: 'all-zero usage rows are ignored; pure cache-read requests still count',
|
|
829
|
+
},
|
|
483
830
|
},
|
|
484
831
|
totals: { turns: totals.turns, sessions: sessionCount.size, input: totals.input, output: totals.output, cacheRead: totals.cacheRead, cacheWrite: totals.cacheWrite, reasoning: totals.reasoning },
|
|
485
832
|
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 })),
|
|
@@ -642,7 +989,9 @@ function apply(ctx) {
|
|
|
642
989
|
}
|
|
643
990
|
|
|
644
991
|
// ---------- start the historical backfill immediately ----------
|
|
992
|
+
ledgerReady = loadLedger()
|
|
645
993
|
void runBaseline()
|
|
994
|
+
scheduleReconcileTimer()
|
|
646
995
|
void loadAliases()
|
|
647
996
|
}
|
|
648
997
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-all-usage",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "DeepSeek Harness usage dashboard with model, provider, workspace, cache, balance, and CSV insights",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"files": [
|
|
38
38
|
"lib/index.js",
|
|
39
39
|
"lib/client.js",
|
|
40
|
+
"CHANGELOG.md",
|
|
40
41
|
"cordis.patch.yml",
|
|
41
42
|
"assets"
|
|
42
43
|
],
|