@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.
- package/README.md +63 -0
- package/cordis.patch.yml +7 -0
- package/docs/CHANGELOG.md +120 -0
- package/lib/client.js +1164 -0
- package/lib/daily-stats.js +324 -0
- package/lib/index.js +1104 -0
- package/lib/secure-store.js +136 -0
- package/package.json +66 -0
|
@@ -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
|
+
}
|