@loommii/dsh-provider-usage 0.6.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.
@@ -0,0 +1,324 @@
1
+ // dsh-provider-usage — 按天物化的本地 Token 统计存储(v0.5.0,用户决策 2026-08-26)。
2
+ // 位置:$DSH_HOME/provider-usage/daily-stats/YYYY-MM-DD.json(单文件=单天,0600,原子写)。
3
+ // 语义:昨天及以前 = 封存(sealed,永不再算,deps mtime 校验兜底 compaction 改写);只有今天重算,
4
+ // 且今天只扫描"今天变过的会话文件"(mtime >= 今天零点 - 1h 缓冲),历史文件零读取。
5
+
6
+ import { join } from 'node:path'
7
+ import { promises as fs } from 'node:fs'
8
+ import { init as zstdInit, decompress as zstdWasmDec } from '@bokuweb/zstd-wasm'
9
+ import { decompress as zstdFzDec } from 'fzstd'
10
+
11
+ export const DAILY_VERSION = 1
12
+
13
+ /** 本地时区日期键:YYYY-MM-DD。 */
14
+ export function dayKey(ms) {
15
+ const d = new Date(ms)
16
+ const p = (n) => String(n).padStart(2, '0')
17
+ return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate())
18
+ }
19
+
20
+ /** 日期键对应本地时区当天零点(ms)。 */
21
+ export function dayStartMs(day) {
22
+ const parts = day.split('-').map(Number)
23
+ return new Date(parts[0], parts[1] - 1, parts[2], 0, 0, 0, 0).getTime()
24
+ }
25
+
26
+ /** 日期键 +N 天(可为负)。 */
27
+ export function addDays(day, n) {
28
+ return dayKey(dayStartMs(day) + n * 86400000)
29
+ }
30
+
31
+ /** 返回 [from, …到 to](含)的日期键数组。 */
32
+ export function dayRange(from, to) {
33
+ const out = []
34
+ for (let d = from; d <= to; d = addDays(d, 1)) out.push(d)
35
+ return out
36
+ }
37
+
38
+ /** 扫描会话目录(深度 ≤2)收集所有 session.jsonl.zstd;minMtimeMs 过滤后按 mtime 降序取前 limit。 */
39
+ export async function scanSessionFiles(dir, limit, minMtimeMs) {
40
+ const found = []
41
+ async function walk(d, depth) {
42
+ let entries
43
+ try { entries = await fs.readdir(d, { withFileTypes: true }) } catch { return }
44
+ for (const ent of entries) {
45
+ if (ent.isFile() && ent.name === 'session.jsonl.zstd') { found.push(join(d, ent.name)); continue }
46
+ if (ent.isDirectory() && depth < 2) await walk(join(d, ent.name), depth + 1)
47
+ }
48
+ }
49
+ await walk(dir, 0)
50
+ const meta = await Promise.all(found.map(async (f) => {
51
+ try { const st = await fs.stat(f); return { path: f, mtimeMs: st.mtimeMs, size: st.size } } catch { return null }
52
+ }))
53
+ let list = meta.filter(Boolean)
54
+ if (typeof minMtimeMs === 'number' && minMtimeMs > 0) list = list.filter((m) => m.mtimeMs >= minMtimeMs)
55
+ list.sort((a, b) => b.mtimeMs - a.mtimeMs)
56
+ return list.slice(0, (typeof limit === 'number' && limit > 0) ? limit : list.length)
57
+ }
58
+
59
+ // ── 解码层:DSH 会话文件 = 多帧 zstd(每次 flush 一帧),支持按帧增量解码 ──
60
+ // 主解码器 @bokuweb/zstd-wasm(帧级快 20 倍;个别大帧不支持),失败帧回退 fzstd(正确性兜底);
61
+ // 测试注入 identity(文件内容直接存明文 JSONL,整段视为一帧)。
62
+ let identityDecoder = null
63
+ let wasmReady = false
64
+ export async function initDecoder() {
65
+ if (!identityDecoder && !wasmReady) {
66
+ if (process.env.DSH_PROVIDER_USAGE_TEST !== '1') await zstdInit()
67
+ wasmReady = true
68
+ }
69
+ }
70
+ export function _setDecoderForTests(fn) {
71
+ identityDecoder = fn || null
72
+ wasmReady = !!fn
73
+ }
74
+
75
+ /** 解一个字节区间(单帧)为文本;wasm 失败自动回退 fzstd。 */
76
+ function decodeFrameBytes(buf) {
77
+ try {
78
+ if (identityDecoder) return identityDecoder(buf).toString('utf8')
79
+ try {
80
+ if (wasmReady) return Buffer.from(zstdWasmDec(new Uint8Array(buf))).toString('utf8')
81
+ } catch { /* 大帧回退 */ }
82
+ return Buffer.from(zstdFzDec(buf)).toString('utf8')
83
+ } catch { return '' }
84
+ }
85
+
86
+
87
+ /** zstd frame magic(小端 28 B5 2F FD)。 */
88
+ const ZSTD_MAGIC = [0x28, 0xb5, 0x2f, 0xfd]
89
+
90
+ /** 扫描 buf 中所有帧起始偏移;找不到任何 magic 时把整段视为一帧(identity 模式)。 */
91
+ export function splitFrames(buf) {
92
+ const frames = []
93
+ for (let i = 0; i + 4 <= buf.length; i++) {
94
+ if (buf[i] === ZSTD_MAGIC[0] && buf[i + 1] === ZSTD_MAGIC[1] && buf[i + 2] === ZSTD_MAGIC[2] && buf[i + 3] === ZSTD_MAGIC[3]) frames.push(i)
95
+ }
96
+ if (frames.length === 0) return buf.length > 0 ? [{ start: 0, end: buf.length }] : []
97
+ const out = []
98
+ for (let i = 0; i < frames.length; i++) {
99
+ out.push({ start: frames[i], end: i + 1 < frames.length ? frames[i + 1] : buf.length })
100
+ }
101
+ return out
102
+ }
103
+
104
+
105
+ /** 全量解码(所有帧串联)→ 文本;失败返回 null。 */
106
+ export async function readSessionFile(path) {
107
+ try {
108
+ const raw = await fs.readFile(path)
109
+ const frames = splitFrames(raw)
110
+ let text = ''
111
+ for (const f of frames) text += decodeFrameBytes(raw.subarray(f.start, f.end))
112
+ return text || null
113
+ } catch { return null }
114
+ }
115
+
116
+ /** 增量解码:只解 prevOffset 之后的新帧。
117
+ * 返回 { text(新事件的文本,可能为空串), changed(bool), total(当前文件大小) };
118
+ * 无法按偏移衔接(异常)时 changed=false,调用方应回退全量。 */
119
+ export async function readSessionFileFrom(path, prevOffset) {
120
+ try {
121
+ const raw = await fs.readFile(path)
122
+ const prev = (typeof prevOffset === 'number' && prevOffset > 0) ? prevOffset : 0
123
+ if (identityDecoder && prev > 0 && prev <= raw.length) {
124
+ // 测试模式:整段即文本,新内容 = 追加部分
125
+ return { text: Buffer.from(raw.subarray(prev)).toString('utf8'), changed: true, total: raw.length }
126
+ }
127
+ if (prev === 0 || prev >= raw.length) {
128
+ return { text: null, changed: false, total: raw.length }
129
+ }
130
+ const frames = splitFrames(raw)
131
+ let start = -1
132
+ for (let i = 0; i < frames.length; i++) { if (frames[i].start >= prev) { start = i; break } }
133
+ if (start < 0) return { text: null, changed: false, total: raw.length }
134
+ let text = ''
135
+ for (let i = start; i < frames.length; i++) text += decodeFrameBytes(raw.subarray(frames[i].start, frames[i].end))
136
+ return { text, changed: true, total: raw.length }
137
+ } catch { return { text: null, changed: false, total: 0 } }
138
+ }
139
+
140
+
141
+ /** 解析 JSONL 文本为事件数组(损坏行跳过)。 */
142
+ export function parseEvents(text) {
143
+ const out = []
144
+ if (!text) return out
145
+ for (const line of text.split('\n')) {
146
+ if (!line.trim()) continue
147
+ try { out.push(JSON.parse(line)) } catch { /* 跳过坏行 */ }
148
+ }
149
+ return out
150
+ }
151
+
152
+ /** 把事件流中落在 [dayStart, dayEnd) 的 assistant/message 记账折叠成按天 totals。
153
+ * 归属:message.source.provider/model,缺失回退 request/header;targetProvider 为空 = 全部 provider;
154
+ * 事件 time 缺失按当天计入(宽松)。返回 { byProvider, byModel },键值含
155
+ * { requests, inputTokens, outputTokens, cacheReadTokens }。 */
156
+ /** 内部:把一条记账累加到 byProvider/byModel 桶。 */
157
+ function bump(map, key, usage) {
158
+ let e = map[key]
159
+ if (!e) { e = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 }; map[key] = e }
160
+ e.requests += 1
161
+ e.inputTokens += Number(usage.inputTokens) || 0
162
+ e.outputTokens += Number(usage.outputTokens) || 0
163
+ e.cacheReadTokens += Number(usage.cacheReadTokens) || 0
164
+ }
165
+
166
+ /** 内部:解析一条事件的 provider/model 归属(source 优先,回退 header)。 */
167
+ function stepGuess(data, headerProvider, headerModel) {
168
+ const message = data.message && typeof data.message === 'object' ? data.message : undefined
169
+ const source = message && message.source && typeof message.source === 'object' ? message.source : undefined
170
+ const stepProvider = source && typeof source.provider === 'string' ? source.provider : headerProvider
171
+ const stepModel = source && typeof source.model === 'string' ? source.model : headerModel
172
+ return { stepProvider, stepModel }
173
+ }
174
+
175
+ /** 把事件流中落在 [dayStart, dayEnd) 的 assistant/message 记账折叠成按天 totals。
176
+ * 归属:message.source.provider/model,缺失回退 request/header;targetProvider 为空 = 全部 provider;
177
+ * 事件 time 缺失按当天计入(宽松)。返回 { byProvider, byModel },键值含
178
+ * { requests, inputTokens, outputTokens, cacheReadTokens }。 */
179
+ export function foldEventsByDay(events, dayStart, dayEnd, targetProvider) {
180
+ const target = (typeof targetProvider === 'string' && targetProvider.trim() !== '') ? targetProvider.trim() : null
181
+ const byProvider = {}
182
+ const byModel = {}
183
+ let headerProvider = null
184
+ let headerModel = null
185
+ for (const ev of events) {
186
+ if (!ev || typeof ev !== 'object') continue
187
+ const data = ev.data
188
+ if (!data || typeof data !== 'object') continue
189
+ if (ev.type === 'request/header') {
190
+ const conf = data.header && typeof data.header === 'object' ? data.header.config : undefined
191
+ if (conf && typeof conf === 'object') {
192
+ if (typeof conf.provider === 'string') headerProvider = conf.provider
193
+ if (typeof conf.model === 'string') headerModel = conf.model
194
+ }
195
+ continue
196
+ }
197
+ if (ev.type !== 'assistant/message') continue
198
+ const usage = data.usage
199
+ if (!usage || typeof usage !== 'object') continue
200
+ if (typeof ev.time === 'number' && ev.time > 0) {
201
+ if (ev.time < dayStart || ev.time >= dayEnd) continue
202
+ }
203
+ const guess = stepGuess(data, headerProvider, headerModel)
204
+ if (target !== null && guess.stepProvider !== target) continue
205
+ bump(byProvider, guess.stepProvider || 'unknown', usage)
206
+ bump(byModel, guess.stepModel || 'unknown', usage)
207
+ }
208
+ return { byProvider, byModel }
209
+ }
210
+
211
+ /** 把事件流按天分组折叠(首次全量回填用):返回 { <YYYY-MM-DD>: { byProvider, byModel },
212
+ * unknown: {...} }——time 缺失的事件归 unknown 桶。targetProvider 为空 = 全部 provider。 */
213
+ export function foldEventsByDays(events, targetProvider) {
214
+ const target = (typeof targetProvider === 'string' && targetProvider.trim() !== '') ? targetProvider.trim() : null
215
+ const days = {}
216
+ let headerProvider = null
217
+ let headerModel = null
218
+ for (const ev of events) {
219
+ if (!ev || typeof ev !== 'object') continue
220
+ const data = ev.data
221
+ if (!data || typeof data !== 'object') continue
222
+ if (ev.type === 'request/header') {
223
+ const conf = data.header && typeof data.header === 'object' ? data.header.config : undefined
224
+ if (conf && typeof conf === 'object') {
225
+ if (typeof conf.provider === 'string') headerProvider = conf.provider
226
+ if (typeof conf.model === 'string') headerModel = conf.model
227
+ }
228
+ continue
229
+ }
230
+ if (ev.type !== 'assistant/message') continue
231
+ const usage = data.usage
232
+ if (!usage || typeof usage !== 'object') continue
233
+ const guess = stepGuess(data, headerProvider, headerModel)
234
+ if (target !== null && guess.stepProvider !== target) continue
235
+ const key = (typeof ev.time === 'number' && ev.time > 0) ? dayKey(ev.time) : 'unknown'
236
+ let bucket = days[key]
237
+ if (!bucket) { bucket = { byProvider: {}, byModel: {} }; days[key] = bucket }
238
+ bump(bucket.byProvider, guess.stepProvider || 'unknown', usage)
239
+ bump(bucket.byModel, guess.stepModel || 'unknown', usage)
240
+ }
241
+ return days
242
+ }
243
+
244
+ /** 把 src 的 byProvider/byModel 累加进 dst(同键相加)。 */
245
+ export function mergeTotals(dst, src) {
246
+ if (!src) return
247
+ function mergeInto(map, srcMap) {
248
+ for (const k of Object.keys(srcMap || {})) {
249
+ const s = srcMap[k], t = map[k] || (map[k] = { requests: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 })
250
+ t.requests += s.requests || 0
251
+ t.inputTokens += s.inputTokens || 0
252
+ t.outputTokens += s.outputTokens || 0
253
+ t.cacheReadTokens += s.cacheReadTokens || 0
254
+ }
255
+ }
256
+ mergeInto(dst.byProvider, src.byProvider)
257
+ mergeInto(dst.byModel, src.byModel)
258
+ }
259
+
260
+ /** 读某天文件;损坏/缺失返回 null。 */
261
+ export async function readDayFile(dir, day) {
262
+ try {
263
+ const raw = await fs.readFile(join(dir, day + '.json'), 'utf8')
264
+ const data = JSON.parse(raw)
265
+ return (data && typeof data === 'object') ? data : null
266
+ } catch { return null }
267
+ }
268
+
269
+ /** 原子写某天文件(tmp + rename,0600)。 */
270
+ export async function writeDayFile(dir, day, data) {
271
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
272
+ const tmp = join(dir, '.' + day + '.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2))
273
+ await fs.writeFile(tmp, JSON.stringify(data, null, 1) + '\n', { mode: 0o600 })
274
+ await fs.rename(tmp, join(dir, day + '.json'))
275
+ }
276
+
277
+ /** 会话文件解码游标(watermark):path -> { offset, mtimeMs },持久化到 daily-stats/cursors.json。 */
278
+ export async function loadCursors(dir) {
279
+ try {
280
+ const raw = await fs.readFile(join(dir, 'cursors.json'), 'utf8')
281
+ const j = JSON.parse(raw)
282
+ return (j && j.files && typeof j.files === 'object') ? j.files : {}
283
+ } catch { return {} }
284
+ }
285
+ export async function saveCursors(dir, cursors) {
286
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
287
+ const tmp = join(dir, '.cursors.tmp-' + process.pid + '-' + Math.random().toString(36).slice(2))
288
+ await fs.writeFile(tmp, JSON.stringify({ version: 1, files: cursors }, null, 1) + '\n', { mode: 0o600 })
289
+ await fs.rename(tmp, join(dir, 'cursors.json'))
290
+ }
291
+
292
+ /** 回填哨兵:标记"全量历史回填已完成"(防止无历史数据时每次请求都重扫)。 */
293
+ export async function hasBackfilled(dir) {
294
+ try { await fs.access(join(dir, '.backfilled')); return true } catch { return false }
295
+ }
296
+ export async function writeBackfilled(dir) {
297
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 })
298
+ await fs.writeFile(join(dir, '.backfilled'), JSON.stringify({ version: DAILY_VERSION, at: Date.now() }) + '\n', { mode: 0o600 })
299
+ }
300
+
301
+ /** 校验 deps(path -> mtime):返回变化的与缺失的路径。 */
302
+ export async function validateDeps(deps) {
303
+ const changed = []
304
+ const missing = []
305
+ for (const [path, mtime] of Object.entries(deps || {})) {
306
+ try {
307
+ const st = await fs.stat(path)
308
+ if (Math.abs(st.mtimeMs - Number(mtime)) > 1) changed.push(path)
309
+ } catch { missing.push(path) }
310
+ }
311
+ return { changed, missing }
312
+ }
313
+
314
+ /** 枚举 daily-stats 目录中已有的天文件(YYYY-MM-DD.json),按日期升序。 */
315
+ export async function listDayFiles(dir) {
316
+ let names
317
+ try { names = await fs.readdir(dir) } catch { return [] }
318
+ const days = []
319
+ for (const n of names) {
320
+ if (/^\d{4}-\d{2}-\d{2}\.json$/.test(n)) days.push(n.slice(0, 10))
321
+ }
322
+ days.sort()
323
+ return days
324
+ }