dsh-token-pulse 0.2.2-beta
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.en.md +69 -0
- package/README.md +69 -0
- package/README.zh.md +33 -0
- package/cordis.patch.yml +3 -0
- package/lib/client.js +2290 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +1084 -0
- package/lib/index.mjs.map +1 -0
- package/package.json +98 -0
package/lib/index.mjs
ADDED
|
@@ -0,0 +1,1084 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
//#region src/date-bucket.ts
|
|
5
|
+
const TZ = "Asia/Shanghai";
|
|
6
|
+
function toDayKey(ms, tz = TZ) {
|
|
7
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
8
|
+
timeZone: tz,
|
|
9
|
+
year: "numeric",
|
|
10
|
+
month: "2-digit",
|
|
11
|
+
day: "2-digit"
|
|
12
|
+
}).format(new Date(ms));
|
|
13
|
+
}
|
|
14
|
+
function dayKeyToDate(key, _tz = TZ) {
|
|
15
|
+
return /* @__PURE__ */ new Date(key + "T12:00:00+08:00");
|
|
16
|
+
}
|
|
17
|
+
function weekStartOf(dayKey) {
|
|
18
|
+
const d = dayKeyToDate(dayKey);
|
|
19
|
+
const dow = (d.getDay() + 6) % 7;
|
|
20
|
+
d.setDate(d.getDate() - dow);
|
|
21
|
+
return toDayKey(d.getTime());
|
|
22
|
+
}
|
|
23
|
+
function monthStartOf(dayKey) {
|
|
24
|
+
const d = dayKeyToDate(dayKey);
|
|
25
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
|
|
26
|
+
}
|
|
27
|
+
function yearStartOf(dayKey) {
|
|
28
|
+
return `${dayKeyToDate(dayKey).getFullYear()}-01-01`;
|
|
29
|
+
}
|
|
30
|
+
function addDays(dayKey, n) {
|
|
31
|
+
const d = dayKeyToDate(dayKey);
|
|
32
|
+
d.setDate(d.getDate() + n);
|
|
33
|
+
return toDayKey(d.getTime());
|
|
34
|
+
}
|
|
35
|
+
function listDaysInRange(startKey, endKey) {
|
|
36
|
+
const result = [];
|
|
37
|
+
let cur = startKey;
|
|
38
|
+
while (cur <= endKey) {
|
|
39
|
+
result.push(cur);
|
|
40
|
+
cur = addDays(cur, 1);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function weekRangeFor(dateKey) {
|
|
45
|
+
const s = weekStartOf(dateKey);
|
|
46
|
+
return [s, addDays(s, 6)];
|
|
47
|
+
}
|
|
48
|
+
function monthRangeFor(dateKey) {
|
|
49
|
+
const s = monthStartOf(dateKey);
|
|
50
|
+
const d = dayKeyToDate(s);
|
|
51
|
+
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
|
|
52
|
+
return [s, `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`];
|
|
53
|
+
}
|
|
54
|
+
/** Last ~90 days: sliding window for the "Quarter" tab (not calendar quarter). */
|
|
55
|
+
function quarterRangeFor(dateKey) {
|
|
56
|
+
return [addDays(dateKey, -89), dateKey];
|
|
57
|
+
}
|
|
58
|
+
function yearRangeFor(dateKey) {
|
|
59
|
+
const s = yearStartOf(dateKey);
|
|
60
|
+
return [s, `${dayKeyToDate(s).getFullYear()}-12-31`];
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/aggregation.ts
|
|
64
|
+
function emptyDay(dayKey) {
|
|
65
|
+
return {
|
|
66
|
+
dayKey,
|
|
67
|
+
totalTokens: 0,
|
|
68
|
+
uncachedInputTokens: 0,
|
|
69
|
+
cacheReadTokens: 0,
|
|
70
|
+
cacheWriteTokens: 0,
|
|
71
|
+
outputTokens: 0,
|
|
72
|
+
count: 0,
|
|
73
|
+
byModel: /* @__PURE__ */ new Map(),
|
|
74
|
+
byProvider: /* @__PURE__ */ new Map(),
|
|
75
|
+
hourlyTokens: new Array(24).fill(0),
|
|
76
|
+
winnerModel: null,
|
|
77
|
+
winnerProvider: null
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function sumByDay(days) {
|
|
81
|
+
return days.reduce((s, d) => s + d.totalTokens, 0);
|
|
82
|
+
}
|
|
83
|
+
function resolveDay(byDay, key) {
|
|
84
|
+
return byDay.get(key) ?? emptyDay(key);
|
|
85
|
+
}
|
|
86
|
+
function resolveRange(byDay, start, end) {
|
|
87
|
+
return listDaysInRange(start, end).map((k) => resolveDay(byDay, k));
|
|
88
|
+
}
|
|
89
|
+
function hourInShanghai(ms) {
|
|
90
|
+
const s = new Date(ms).toLocaleString("en-GB", {
|
|
91
|
+
timeZone: "Asia/Shanghai",
|
|
92
|
+
hour: "2-digit",
|
|
93
|
+
hour12: false
|
|
94
|
+
});
|
|
95
|
+
const h = Number(s);
|
|
96
|
+
return h === 24 ? 0 : h;
|
|
97
|
+
}
|
|
98
|
+
function aggregate(events, nowMs) {
|
|
99
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
100
|
+
const globalByModel = /* @__PURE__ */ new Map();
|
|
101
|
+
const globalByProvider = /* @__PURE__ */ new Map();
|
|
102
|
+
for (const ev of events) {
|
|
103
|
+
const dayKey = toDayKey(ev.time);
|
|
104
|
+
let agg = byDay.get(dayKey);
|
|
105
|
+
if (!agg) {
|
|
106
|
+
agg = emptyDay(dayKey);
|
|
107
|
+
byDay.set(dayKey, agg);
|
|
108
|
+
}
|
|
109
|
+
const u = ev.usage;
|
|
110
|
+
const total = (u.inputTokens ?? 0) + (u.cacheReadTokens ?? 0) + (u.cacheWriteTokens ?? 0) + (u.outputTokens ?? 0);
|
|
111
|
+
agg.totalTokens += total;
|
|
112
|
+
agg.uncachedInputTokens += u.inputTokens ?? 0;
|
|
113
|
+
agg.cacheReadTokens += u.cacheReadTokens ?? 0;
|
|
114
|
+
agg.cacheWriteTokens += u.cacheWriteTokens ?? 0;
|
|
115
|
+
agg.outputTokens += u.outputTokens ?? 0;
|
|
116
|
+
agg.count += 1;
|
|
117
|
+
const h = hourInShanghai(ev.time);
|
|
118
|
+
if (h >= 0 && h < 24) agg.hourlyTokens[h] += total;
|
|
119
|
+
const modelKey = ev.model || "unknown";
|
|
120
|
+
const providerKey = ev.provider || "unknown";
|
|
121
|
+
agg.byModel.set(modelKey, (agg.byModel.get(modelKey) ?? 0) + total);
|
|
122
|
+
agg.byProvider.set(providerKey, (agg.byProvider.get(providerKey) ?? 0) + total);
|
|
123
|
+
globalByModel.set(modelKey, (globalByModel.get(modelKey) ?? 0) + total);
|
|
124
|
+
globalByProvider.set(providerKey, (globalByProvider.get(providerKey) ?? 0) + total);
|
|
125
|
+
}
|
|
126
|
+
for (const agg of byDay.values()) {
|
|
127
|
+
let bestM = null;
|
|
128
|
+
let bestMVal = -1;
|
|
129
|
+
for (const [m, v] of agg.byModel) if (v > bestMVal) {
|
|
130
|
+
bestMVal = v;
|
|
131
|
+
bestM = m;
|
|
132
|
+
}
|
|
133
|
+
agg.winnerModel = bestM;
|
|
134
|
+
let bestP = null;
|
|
135
|
+
let bestPVal = -1;
|
|
136
|
+
for (const [p, v] of agg.byProvider) if (v > bestPVal) {
|
|
137
|
+
bestPVal = v;
|
|
138
|
+
bestP = p;
|
|
139
|
+
}
|
|
140
|
+
agg.winnerProvider = bestP;
|
|
141
|
+
}
|
|
142
|
+
const todayKey = toDayKey(nowMs);
|
|
143
|
+
const [wStart, wEnd] = weekRangeFor(todayKey);
|
|
144
|
+
const [mStart, mEnd] = monthRangeFor(todayKey);
|
|
145
|
+
const today = byDay.get(todayKey)?.totalTokens ?? 0;
|
|
146
|
+
const thisWeek = sumByDay(resolveRange(byDay, wStart, wEnd));
|
|
147
|
+
const thisMonth = sumByDay(resolveRange(byDay, mStart, mEnd));
|
|
148
|
+
let all = 0;
|
|
149
|
+
for (const v of byDay.values()) all += v.totalTokens;
|
|
150
|
+
const topModels = [...globalByModel.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([model, tokens]) => ({
|
|
151
|
+
model,
|
|
152
|
+
tokens
|
|
153
|
+
}));
|
|
154
|
+
const topProviders = [...globalByProvider.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([provider, tokens]) => ({
|
|
155
|
+
provider,
|
|
156
|
+
tokens
|
|
157
|
+
}));
|
|
158
|
+
function top5InWindow(days) {
|
|
159
|
+
const m = /* @__PURE__ */ new Map();
|
|
160
|
+
for (const d of days) for (const [model, tokens] of d.byModel) m.set(model, (m.get(model) ?? 0) + tokens);
|
|
161
|
+
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([model, tokens]) => ({
|
|
162
|
+
model,
|
|
163
|
+
tokens
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
function topProvidersInWindow(days) {
|
|
167
|
+
const m = /* @__PURE__ */ new Map();
|
|
168
|
+
for (const d of days) for (const [provider, tokens] of d.byProvider) m.set(provider, (m.get(provider) ?? 0) + tokens);
|
|
169
|
+
return [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([provider, tokens]) => ({
|
|
170
|
+
provider,
|
|
171
|
+
tokens
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
byDay,
|
|
176
|
+
totals: {
|
|
177
|
+
today,
|
|
178
|
+
thisWeek,
|
|
179
|
+
thisMonth,
|
|
180
|
+
all
|
|
181
|
+
},
|
|
182
|
+
topModels,
|
|
183
|
+
topProviders,
|
|
184
|
+
top5: topModels,
|
|
185
|
+
weekDays: (k) => {
|
|
186
|
+
const [s, e] = weekRangeFor(k);
|
|
187
|
+
return resolveRange(byDay, s, e);
|
|
188
|
+
},
|
|
189
|
+
monthDays: (k) => {
|
|
190
|
+
const [s, e] = monthRangeFor(k);
|
|
191
|
+
return resolveRange(byDay, s, e);
|
|
192
|
+
},
|
|
193
|
+
quarterDays: (k) => {
|
|
194
|
+
const [s, e] = quarterRangeFor(k);
|
|
195
|
+
return resolveRange(byDay, s, e);
|
|
196
|
+
},
|
|
197
|
+
yearDays: (k) => {
|
|
198
|
+
const [s, e] = yearRangeFor(k);
|
|
199
|
+
return resolveRange(byDay, s, e);
|
|
200
|
+
},
|
|
201
|
+
top5InWindow,
|
|
202
|
+
topProvidersInWindow
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/host/session-reader.ts
|
|
207
|
+
/** zstd frame magic: bytes 28 B5 2F FD, read as UInt32LE. */
|
|
208
|
+
const ZSTD_MAGIC = 4247762216;
|
|
209
|
+
/**
|
|
210
|
+
* Locate complete zstd frames inside a concatenated-frame container (the
|
|
211
|
+
* format DSH's session.jsonl.zstd uses: every appended batch is its own
|
|
212
|
+
* compressed frame). Scans the frame structure WITHOUT decompressing, mirroring
|
|
213
|
+
* @deepseek-ai/dsh-session-persistence-jsonl's scanZstdFrames. Returns
|
|
214
|
+
* [start,end) byte ranges, one per complete frame.
|
|
215
|
+
*/
|
|
216
|
+
function scanZstdFrames(buffer) {
|
|
217
|
+
const frames = [];
|
|
218
|
+
let offset = 0;
|
|
219
|
+
while (offset < buffer.length) {
|
|
220
|
+
const start = offset;
|
|
221
|
+
if (buffer.length - offset < 4) break;
|
|
222
|
+
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) break;
|
|
223
|
+
offset += 4;
|
|
224
|
+
if (offset === buffer.length) break;
|
|
225
|
+
const descriptor = buffer.readUInt8(offset);
|
|
226
|
+
offset += 1;
|
|
227
|
+
if ((descriptor & 24) !== 0) break;
|
|
228
|
+
const contentSizeFlag = descriptor >>> 6;
|
|
229
|
+
const singleSegment = (descriptor & 32) !== 0;
|
|
230
|
+
const checksum = (descriptor & 4) !== 0;
|
|
231
|
+
const dictionaryFlag = descriptor & 3;
|
|
232
|
+
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag;
|
|
233
|
+
const contentSizeBytes = contentSizeFlag === 0 ? singleSegment ? 1 : 0 : 1 << contentSizeFlag;
|
|
234
|
+
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes;
|
|
235
|
+
if (buffer.length - offset < remainingHeaderBytes) break;
|
|
236
|
+
offset += remainingHeaderBytes;
|
|
237
|
+
for (;;) {
|
|
238
|
+
if (buffer.length - offset < 3) break;
|
|
239
|
+
const blockHeader = buffer.readUIntLE(offset, 3);
|
|
240
|
+
offset += 3;
|
|
241
|
+
const lastBlock = (blockHeader & 1) !== 0;
|
|
242
|
+
const blockType = blockHeader >>> 1 & 3;
|
|
243
|
+
const blockSize = blockHeader >>> 3;
|
|
244
|
+
if (blockType === 3) break;
|
|
245
|
+
const payloadBytes = blockType === 1 ? 1 : blockSize;
|
|
246
|
+
if (buffer.length - offset < payloadBytes) break;
|
|
247
|
+
offset += payloadBytes;
|
|
248
|
+
if (lastBlock) break;
|
|
249
|
+
}
|
|
250
|
+
if (checksum) {
|
|
251
|
+
if (buffer.length - offset < 4) break;
|
|
252
|
+
offset += 4;
|
|
253
|
+
}
|
|
254
|
+
frames.push({
|
|
255
|
+
start,
|
|
256
|
+
end: offset
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return frames;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Decode a concatenated-frame zstd session log to its full UTF-8 text.
|
|
263
|
+
* Node's one-shot `zstdDecompressSync` decodes only the FIRST frame, so we
|
|
264
|
+
* locate every frame and decode each one separately (per
|
|
265
|
+
* dsh-session-persistence-jsonl's zstd-public-decoder). Returns null on any
|
|
266
|
+
* decode failure (callers treat a null text as "no data from this file").
|
|
267
|
+
*/
|
|
268
|
+
async function decodeSessionZstd(buffer) {
|
|
269
|
+
const zlib = await import("node:zlib");
|
|
270
|
+
if (typeof zlib.zstdDecompressSync !== "function") return null;
|
|
271
|
+
const frames = scanZstdFrames(buffer);
|
|
272
|
+
if (frames.length === 0) return null;
|
|
273
|
+
const parts = [];
|
|
274
|
+
for (const { start, end } of frames) try {
|
|
275
|
+
parts.push(zlib.zstdDecompressSync(buffer.subarray(start, end)).toString("utf-8"));
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
return parts.join("");
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Parse a batch of raw session events into RawUsageEvents.
|
|
283
|
+
* Rules:
|
|
284
|
+
* - request/header stores provider/model for fallback (per session)
|
|
285
|
+
* - assistant/chunk type=usage is provisional, keyed by sid/turn/step
|
|
286
|
+
* - assistant/message with usage is authoritative, overwrites provisional for same sid/turn/step
|
|
287
|
+
*
|
|
288
|
+
* The dedupe key MUST include the session id: turn/step counters restart per
|
|
289
|
+
* session (every session has a turn 1 step 1), so without `sid` different
|
|
290
|
+
* sessions overwrite each other and older sessions' usage silently disappears.
|
|
291
|
+
*/
|
|
292
|
+
function parseUsageEvents(events) {
|
|
293
|
+
const fallbackBySid = /* @__PURE__ */ new Map();
|
|
294
|
+
const fallbackFor = (sid) => fallbackBySid.get(sid ?? "") ?? {
|
|
295
|
+
provider: "",
|
|
296
|
+
model: ""
|
|
297
|
+
};
|
|
298
|
+
const byTurnStep = /* @__PURE__ */ new Map();
|
|
299
|
+
for (const ev of events) {
|
|
300
|
+
const sid = ev.sid ?? "";
|
|
301
|
+
if (ev.type === "request/header" && ev.data?.header?.config) {
|
|
302
|
+
const cfg = ev.data.header.config;
|
|
303
|
+
const fb = fallbackBySid.get(sid) ?? {
|
|
304
|
+
provider: "",
|
|
305
|
+
model: ""
|
|
306
|
+
};
|
|
307
|
+
if (cfg.provider) fb.provider = cfg.provider;
|
|
308
|
+
if (cfg.model) fb.model = cfg.model;
|
|
309
|
+
fallbackBySid.set(sid, fb);
|
|
310
|
+
}
|
|
311
|
+
if (ev.type === "request/context" && ev.data) {
|
|
312
|
+
const fb = fallbackBySid.get(sid) ?? {
|
|
313
|
+
provider: "",
|
|
314
|
+
model: ""
|
|
315
|
+
};
|
|
316
|
+
if (ev.data.provider) fb.provider = ev.data.provider;
|
|
317
|
+
if (ev.data.model) fb.model = ev.data.model;
|
|
318
|
+
fallbackBySid.set(sid, fb);
|
|
319
|
+
}
|
|
320
|
+
if (ev.type === "assistant/chunk" && ev.data?.chunk?.type === "usage") {
|
|
321
|
+
const key = `${sid}::${ev.data.turn}:${ev.data.step}`;
|
|
322
|
+
if (!byTurnStep.has(key)) {
|
|
323
|
+
const fb = fallbackFor(sid);
|
|
324
|
+
const u = ev.data.chunk.usage;
|
|
325
|
+
byTurnStep.set(key, {
|
|
326
|
+
time: ev.time ?? Date.now(),
|
|
327
|
+
provider: fb.provider,
|
|
328
|
+
model: fb.model,
|
|
329
|
+
sid,
|
|
330
|
+
usage: {
|
|
331
|
+
inputTokens: u.inputTokens ?? 0,
|
|
332
|
+
cacheReadTokens: u.cacheReadTokens ?? 0,
|
|
333
|
+
cacheWriteTokens: u.cacheWriteTokens ?? 0,
|
|
334
|
+
outputTokens: u.outputTokens ?? 0
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (ev.type === "assistant/message" && ev.data?.usage) {
|
|
340
|
+
const src = ev.data.message?.source;
|
|
341
|
+
const fb = fallbackFor(sid);
|
|
342
|
+
const provider = src?.provider ?? fb.provider;
|
|
343
|
+
const model = src?.model ?? fb.model;
|
|
344
|
+
const turn = ev.data.turn;
|
|
345
|
+
const step = ev.data.step;
|
|
346
|
+
const key = turn !== void 0 && step !== void 0 ? `${sid}::${turn}:${step}` : `${sid}::msg:${ev.seq ?? Math.random()}`;
|
|
347
|
+
const u = ev.data.usage;
|
|
348
|
+
byTurnStep.set(key, {
|
|
349
|
+
time: ev.time ?? Date.now(),
|
|
350
|
+
provider,
|
|
351
|
+
model,
|
|
352
|
+
sid,
|
|
353
|
+
usage: {
|
|
354
|
+
inputTokens: u.inputTokens ?? 0,
|
|
355
|
+
cacheReadTokens: u.cacheReadTokens ?? 0,
|
|
356
|
+
cacheWriteTokens: u.cacheWriteTokens ?? 0,
|
|
357
|
+
outputTokens: u.outputTokens ?? 0
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
const cur = fallbackBySid.get(sid) ?? {
|
|
361
|
+
provider: "",
|
|
362
|
+
model: ""
|
|
363
|
+
};
|
|
364
|
+
if (src?.provider) cur.provider = src.provider;
|
|
365
|
+
if (src?.model) cur.model = src.model;
|
|
366
|
+
fallbackBySid.set(sid, cur);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return [...byTurnStep.values()];
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Read all sessions' usage events: FULL disk history (every session, old + new)
|
|
373
|
+
* merged with live in-memory events that are not yet flushed to disk.
|
|
374
|
+
*
|
|
375
|
+
* NOTE: ctx.sessions.list() returns only LIVE sessions (created/resumed in the
|
|
376
|
+
* current process) — historical sessions from previous processes are NOT in it.
|
|
377
|
+
* The old implementation returned early whenever any live session had events,
|
|
378
|
+
* so it never scanned the disk and old sessions never showed up. Here we ALWAYS
|
|
379
|
+
* read the whole ~/.dsh/sessions tree and merge live events on top, deduping by
|
|
380
|
+
* sessionId:seq (events carry no sessionId; the disk path derives it from the
|
|
381
|
+
* session directory name).
|
|
382
|
+
*/
|
|
383
|
+
async function readAllUsageEvents(ctx) {
|
|
384
|
+
const allEvents = [];
|
|
385
|
+
const seen = /* @__PURE__ */ new Set();
|
|
386
|
+
const relevant = (o) => o.type === "request/header" || o.type === "request/context" || o.type === "assistant/chunk" || o.type === "assistant/message";
|
|
387
|
+
const pushUnique = (sid, o) => {
|
|
388
|
+
if (typeof o?.seq === "number") {
|
|
389
|
+
const key = `${sid}:${o.seq}`;
|
|
390
|
+
if (seen.has(key)) return;
|
|
391
|
+
seen.add(key);
|
|
392
|
+
}
|
|
393
|
+
allEvents.push({
|
|
394
|
+
...o,
|
|
395
|
+
sid
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
try {
|
|
399
|
+
const { readdirSync, existsSync } = await import("node:fs");
|
|
400
|
+
const { join, basename, dirname } = await import("node:path");
|
|
401
|
+
const sessionsDir = join(process.env.HOME ?? "/home/dell", ".dsh", "sessions");
|
|
402
|
+
if (existsSync(sessionsDir)) {
|
|
403
|
+
const files = [];
|
|
404
|
+
(function walk(dir) {
|
|
405
|
+
try {
|
|
406
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
407
|
+
const p = join(dir, entry.name);
|
|
408
|
+
if (entry.isDirectory()) walk(p);
|
|
409
|
+
else if (entry.name === "session.jsonl.zstd" || entry.name === "session.jsonl") files.push(p);
|
|
410
|
+
}
|
|
411
|
+
} catch {}
|
|
412
|
+
})(sessionsDir);
|
|
413
|
+
for (const f of files) {
|
|
414
|
+
const sid = basename(dirname(f));
|
|
415
|
+
try {
|
|
416
|
+
let text;
|
|
417
|
+
if (f.endsWith(".zstd")) {
|
|
418
|
+
const { readFileSync } = await import("node:fs");
|
|
419
|
+
const textFromFrames = await decodeSessionZstd(readFileSync(f));
|
|
420
|
+
if (textFromFrames !== null) text = textFromFrames;
|
|
421
|
+
else {
|
|
422
|
+
const { execSync } = await import("node:child_process");
|
|
423
|
+
text = execSync(`zstd -dc ${JSON.stringify(f)}`, {
|
|
424
|
+
encoding: "utf-8",
|
|
425
|
+
maxBuffer: 52428800
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
} else {
|
|
429
|
+
const { readFileSync } = await import("node:fs");
|
|
430
|
+
text = readFileSync(f, "utf-8");
|
|
431
|
+
}
|
|
432
|
+
for (const line of text.split("\n")) {
|
|
433
|
+
if (!line.trim()) continue;
|
|
434
|
+
try {
|
|
435
|
+
const obj = JSON.parse(line);
|
|
436
|
+
if (relevant(obj)) pushUnique(sid, obj);
|
|
437
|
+
} catch {}
|
|
438
|
+
}
|
|
439
|
+
} catch {}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
} catch {}
|
|
443
|
+
try {
|
|
444
|
+
if (ctx.sessions?.list) {
|
|
445
|
+
const sessions = await ctx.sessions.list();
|
|
446
|
+
for (const s of sessions ?? []) {
|
|
447
|
+
const sid = s?.id ?? "live";
|
|
448
|
+
for (const event of s?.events ?? []) if (relevant(event)) pushUnique(sid, event);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
} catch {}
|
|
452
|
+
return parseUsageEvents(allEvents);
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Read usage from LIVE sessions only (ctx.sessions.list() = sessions created
|
|
456
|
+
* or resumed in the current process). Cheap — no disk scan. Used by
|
|
457
|
+
* store.refresh() after init() has already captured the full disk history,
|
|
458
|
+
* so refreshes do not re-decompress ~/.dsh/sessions on every tick.
|
|
459
|
+
*
|
|
460
|
+
* A live session's `events` includes its full stored log, so the result is a
|
|
461
|
+
* complete snapshot per live session; callers must REPLACE (not append) that
|
|
462
|
+
* session's events to avoid double counting.
|
|
463
|
+
*/
|
|
464
|
+
async function readLiveUsageEvents(ctx) {
|
|
465
|
+
const events = [];
|
|
466
|
+
const relevant = (o) => o.type === "request/header" || o.type === "request/context" || o.type === "assistant/chunk" || o.type === "assistant/message";
|
|
467
|
+
try {
|
|
468
|
+
if (ctx.sessions?.list) {
|
|
469
|
+
const sessions = await ctx.sessions.list();
|
|
470
|
+
for (const s of sessions ?? []) {
|
|
471
|
+
const sid = s?.id ?? "live";
|
|
472
|
+
for (const event of s?.events ?? []) if (relevant(event)) events.push({
|
|
473
|
+
...event,
|
|
474
|
+
sid
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
} catch {}
|
|
479
|
+
return parseUsageEvents(events);
|
|
480
|
+
}
|
|
481
|
+
//#endregion
|
|
482
|
+
//#region src/host/persist.ts
|
|
483
|
+
function persistPath() {
|
|
484
|
+
const dir = join(homedir(), ".dsh", "storages", "dsh-token-pulse");
|
|
485
|
+
return join(dir, "daily.json");
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* One-time migration from the pre-rename identity `dsh-token-heatmap`.
|
|
489
|
+
* The plugin was renamed to `dsh-token-pulse` (to avoid a bare-name collision
|
|
490
|
+
* with an unrelated plugin in the awesome-dsh-plugin catalog). Existing users
|
|
491
|
+
* have their accumulated history under storages/dsh-token-heatmap/ and their
|
|
492
|
+
* settings under localStorage keys dsh-token-heatmap:*; without this they would
|
|
493
|
+
* see an empty heatmap after the update. Copy the persisted file forward the
|
|
494
|
+
* first time the new location is missing but the legacy one exists.
|
|
495
|
+
*/
|
|
496
|
+
let migrated = false;
|
|
497
|
+
function migrateLegacyPersist() {
|
|
498
|
+
if (migrated) return;
|
|
499
|
+
migrated = true;
|
|
500
|
+
try {
|
|
501
|
+
const next = persistPath();
|
|
502
|
+
if (existsSync(next)) return;
|
|
503
|
+
const legacy = join(homedir(), ".dsh", "storages", "dsh-token-heatmap", "daily.json");
|
|
504
|
+
if (!existsSync(legacy)) return;
|
|
505
|
+
mkdirSync(dirname(next), { recursive: true });
|
|
506
|
+
copyFileSync(legacy, next);
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
function libDailyPath() {
|
|
510
|
+
try {
|
|
511
|
+
const candidates = [join(homedir(), ".dsh", "profiles", "web", "node_modules", "dsh-token-pulse", "lib", "daily.json"), join("/home/dell/testdsh/dsh-token-pulse/lib/daily.json")];
|
|
512
|
+
for (const c of candidates) try {
|
|
513
|
+
mkdirSync(dirname(c), { recursive: true });
|
|
514
|
+
return c;
|
|
515
|
+
} catch {}
|
|
516
|
+
return candidates[0];
|
|
517
|
+
} catch {
|
|
518
|
+
return null;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
function toPersistedDay(day) {
|
|
522
|
+
return {
|
|
523
|
+
dayKey: day.dayKey,
|
|
524
|
+
totalTokens: day.totalTokens,
|
|
525
|
+
uncachedInputTokens: day.uncachedInputTokens,
|
|
526
|
+
cacheReadTokens: day.cacheReadTokens,
|
|
527
|
+
cacheWriteTokens: day.cacheWriteTokens,
|
|
528
|
+
outputTokens: day.outputTokens,
|
|
529
|
+
count: day.count,
|
|
530
|
+
byModel: Object.fromEntries(day.byModel),
|
|
531
|
+
byProvider: Object.fromEntries(day.byProvider),
|
|
532
|
+
hourlyTokens: [...day.hourlyTokens ?? new Array(24).fill(0)],
|
|
533
|
+
winnerModel: day.winnerModel,
|
|
534
|
+
winnerProvider: day.winnerProvider
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
function fromPersistedDay(p) {
|
|
538
|
+
const byModel = new Map(Object.entries(p.byModel ?? {}));
|
|
539
|
+
const byProvider = new Map(Object.entries(p.byProvider ?? {}));
|
|
540
|
+
const hourlyTokens = Array.isArray(p.hourlyTokens) && p.hourlyTokens.length === 24 ? [...p.hourlyTokens] : new Array(24).fill(0);
|
|
541
|
+
return {
|
|
542
|
+
dayKey: p.dayKey,
|
|
543
|
+
totalTokens: p.totalTokens,
|
|
544
|
+
uncachedInputTokens: p.uncachedInputTokens ?? 0,
|
|
545
|
+
cacheReadTokens: p.cacheReadTokens ?? 0,
|
|
546
|
+
cacheWriteTokens: p.cacheWriteTokens ?? 0,
|
|
547
|
+
outputTokens: p.outputTokens ?? 0,
|
|
548
|
+
count: p.count ?? 0,
|
|
549
|
+
byModel,
|
|
550
|
+
byProvider,
|
|
551
|
+
hourlyTokens,
|
|
552
|
+
winnerModel: p.winnerModel ?? null,
|
|
553
|
+
winnerProvider: p.winnerProvider ?? null
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
function loadPersisted() {
|
|
557
|
+
try {
|
|
558
|
+
migrateLegacyPersist();
|
|
559
|
+
const p = persistPath();
|
|
560
|
+
if (!existsSync(p)) return /* @__PURE__ */ new Map();
|
|
561
|
+
const raw = readFileSync(p, "utf-8");
|
|
562
|
+
const parsed = JSON.parse(raw);
|
|
563
|
+
if (!parsed || typeof parsed.days !== "object") return /* @__PURE__ */ new Map();
|
|
564
|
+
const m = /* @__PURE__ */ new Map();
|
|
565
|
+
for (const [k, v] of Object.entries(parsed.days)) try {
|
|
566
|
+
m.set(k, fromPersistedDay(v));
|
|
567
|
+
} catch {}
|
|
568
|
+
return m;
|
|
569
|
+
} catch {
|
|
570
|
+
return /* @__PURE__ */ new Map();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function savePersisted(days) {
|
|
574
|
+
try {
|
|
575
|
+
const out = {
|
|
576
|
+
version: 1,
|
|
577
|
+
days: Object.fromEntries([...days.entries()].map(([k, v]) => [k, toPersistedDay(v)]))
|
|
578
|
+
};
|
|
579
|
+
const json = JSON.stringify(out, null, 2);
|
|
580
|
+
const p = persistPath();
|
|
581
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
582
|
+
const tmp = p + ".tmp";
|
|
583
|
+
writeFileSync(tmp, json, "utf-8");
|
|
584
|
+
renameSync(tmp, p);
|
|
585
|
+
const libPath = libDailyPath();
|
|
586
|
+
if (libPath) {
|
|
587
|
+
try {
|
|
588
|
+
writeFileSync(libPath, json, "utf-8");
|
|
589
|
+
} catch {}
|
|
590
|
+
try {
|
|
591
|
+
const wsLib = join("/home/dell/testdsh/dsh-token-pulse/lib/daily.json");
|
|
592
|
+
if (wsLib !== libPath) writeFileSync(wsLib, json, "utf-8");
|
|
593
|
+
} catch {}
|
|
594
|
+
}
|
|
595
|
+
} catch {}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Merge strategy: persisted is cumulative historical truth that never shrinks.
|
|
599
|
+
* For each dayKey, if live has data, take element-wise MAX for additive fields,
|
|
600
|
+
* and merge byModel/byProvider by taking max per key (or summing? No - live total is sum of remaining sessions, persisted is sum of all-time events. Taking max per day total prevents loss on deletion while allowing growth on new events. Per-model we also take max per model per day? But that would undercount if a new model appears. Better: merge by taking max of total, and for byModel: for each model, max(persisted, live). If a new model appears only in live, it propagates.
|
|
601
|
+
* For count: max as well.
|
|
602
|
+
*/
|
|
603
|
+
function mergePersistedAndLive(persisted, live) {
|
|
604
|
+
const result = /* @__PURE__ */ new Map();
|
|
605
|
+
for (const [k, v] of persisted) result.set(k, cloneDay$1(v));
|
|
606
|
+
for (const [k, liveDay] of live) {
|
|
607
|
+
const prev = result.get(k);
|
|
608
|
+
if (!prev) {
|
|
609
|
+
result.set(k, cloneDay$1(liveDay));
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
const mergedHourly = (prev.hourlyTokens ?? new Array(24).fill(0)).map((v, i) => Math.max(v, liveDay.hourlyTokens?.[i] ?? 0));
|
|
613
|
+
const merged = {
|
|
614
|
+
dayKey: k,
|
|
615
|
+
totalTokens: Math.max(prev.totalTokens, liveDay.totalTokens),
|
|
616
|
+
uncachedInputTokens: Math.max(prev.uncachedInputTokens, liveDay.uncachedInputTokens),
|
|
617
|
+
cacheReadTokens: Math.max(prev.cacheReadTokens, liveDay.cacheReadTokens),
|
|
618
|
+
cacheWriteTokens: Math.max(prev.cacheWriteTokens, liveDay.cacheWriteTokens),
|
|
619
|
+
outputTokens: Math.max(prev.outputTokens, liveDay.outputTokens),
|
|
620
|
+
count: Math.max(prev.count, liveDay.count),
|
|
621
|
+
byModel: mergeMapMax(prev.byModel, liveDay.byModel),
|
|
622
|
+
byProvider: mergeMapMax(prev.byProvider, liveDay.byProvider),
|
|
623
|
+
hourlyTokens: mergedHourly,
|
|
624
|
+
winnerModel: null,
|
|
625
|
+
winnerProvider: null
|
|
626
|
+
};
|
|
627
|
+
let bestM = null;
|
|
628
|
+
let bestMVal = -1;
|
|
629
|
+
for (const [m, v2] of merged.byModel) if (v2 > bestMVal) {
|
|
630
|
+
bestMVal = v2;
|
|
631
|
+
bestM = m;
|
|
632
|
+
}
|
|
633
|
+
merged.winnerModel = bestM;
|
|
634
|
+
let bestP = null;
|
|
635
|
+
let bestPVal = -1;
|
|
636
|
+
for (const [p, v2] of merged.byProvider) if (v2 > bestPVal) {
|
|
637
|
+
bestPVal = v2;
|
|
638
|
+
bestP = p;
|
|
639
|
+
}
|
|
640
|
+
merged.winnerProvider = bestP;
|
|
641
|
+
result.set(k, merged);
|
|
642
|
+
}
|
|
643
|
+
return result;
|
|
644
|
+
}
|
|
645
|
+
function cloneDay$1(d) {
|
|
646
|
+
return {
|
|
647
|
+
dayKey: d.dayKey,
|
|
648
|
+
totalTokens: d.totalTokens,
|
|
649
|
+
uncachedInputTokens: d.uncachedInputTokens,
|
|
650
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
651
|
+
cacheWriteTokens: d.cacheWriteTokens,
|
|
652
|
+
outputTokens: d.outputTokens,
|
|
653
|
+
count: d.count,
|
|
654
|
+
byModel: new Map(d.byModel),
|
|
655
|
+
byProvider: new Map(d.byProvider),
|
|
656
|
+
hourlyTokens: [...d.hourlyTokens ?? new Array(24).fill(0)],
|
|
657
|
+
winnerModel: d.winnerModel,
|
|
658
|
+
winnerProvider: d.winnerProvider
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function mergeMapMax(a, b) {
|
|
662
|
+
const r = new Map(a);
|
|
663
|
+
for (const [k, v] of b) r.set(k, Math.max(r.get(k) ?? 0, v));
|
|
664
|
+
return r;
|
|
665
|
+
}
|
|
666
|
+
//#endregion
|
|
667
|
+
//#region src/host/store.ts
|
|
668
|
+
var HeatmapStore = class HeatmapStore {
|
|
669
|
+
events = [];
|
|
670
|
+
aggregated;
|
|
671
|
+
listeners = /* @__PURE__ */ new Set();
|
|
672
|
+
byTurnStep = /* @__PURE__ */ new Map();
|
|
673
|
+
fallbackBySid = /* @__PURE__ */ new Map();
|
|
674
|
+
ctx;
|
|
675
|
+
persistedDays = /* @__PURE__ */ new Map();
|
|
676
|
+
/** True after init() has finished scanning disk; false until then so
|
|
677
|
+
* getAggregated() does not serve an incomplete (persisted-only) snapshot
|
|
678
|
+
* that makes wide windows like Quarter look empty until a hard refresh. */
|
|
679
|
+
initialized = false;
|
|
680
|
+
constructor(ctx) {
|
|
681
|
+
this.ctx = ctx;
|
|
682
|
+
this.aggregated = aggregate([], Date.now());
|
|
683
|
+
}
|
|
684
|
+
async init() {
|
|
685
|
+
this.persistedDays = loadPersisted();
|
|
686
|
+
const evs = await readAllUsageEvents(this.ctx);
|
|
687
|
+
this.events = evs;
|
|
688
|
+
this.rebuildPersistedAndAggregated();
|
|
689
|
+
this.initialized = true;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Refresh from LIVE sessions only. The full disk scan happened once at
|
|
693
|
+
* init(); afterwards only live (in-memory) sessions change, so re-scanning
|
|
694
|
+
* the whole ~/.dsh/sessions tree on every refresh would be wasted work.
|
|
695
|
+
*
|
|
696
|
+
* A live session's snapshot includes its full stored log, so we REPLACE that
|
|
697
|
+
* session's events in place (never append) — history for sessions that are
|
|
698
|
+
* no longer live is kept as-is from init(). This keeps each event present at
|
|
699
|
+
* most once in this.events, so the max-merge into persistedDays never
|
|
700
|
+
* under- or over-counts.
|
|
701
|
+
*/
|
|
702
|
+
async refresh() {
|
|
703
|
+
try {
|
|
704
|
+
const live = await readLiveUsageEvents(this.ctx);
|
|
705
|
+
const liveSids = new Set(live.map((e) => e.sid).filter(Boolean));
|
|
706
|
+
this.events = [...this.events.filter((e) => !liveSids.has(e.sid)), ...live];
|
|
707
|
+
this.persistedDays = loadPersisted();
|
|
708
|
+
this.rebuildPersistedAndAggregated();
|
|
709
|
+
} catch (e) {
|
|
710
|
+
this.ctx?.logger?.warn?.(`dsh-token-pulse refresh failed: ${String(e)}`);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
rebuildPersistedAndAggregated() {
|
|
714
|
+
this.invalidateCache();
|
|
715
|
+
const live = aggregate(this.events, Date.now());
|
|
716
|
+
const mergedDays = mergePersistedAndLive(this.persistedDays, live.byDay);
|
|
717
|
+
if (mergedDays.size !== this.persistedDays.size || hasAnyIncrease(this.persistedDays, mergedDays)) {
|
|
718
|
+
this.persistedDays = mergedDays;
|
|
719
|
+
savePersisted(this.persistedDays);
|
|
720
|
+
} else {
|
|
721
|
+
this.persistedDays = mergedDays;
|
|
722
|
+
if (!mapsEqual(loadPersisted(), mergedDays)) savePersisted(this.persistedDays);
|
|
723
|
+
}
|
|
724
|
+
this.aggregated = aggregateFromPersisted(this.persistedDays, Date.now());
|
|
725
|
+
this.emit();
|
|
726
|
+
}
|
|
727
|
+
rebuildAggregated() {
|
|
728
|
+
this.invalidateCache();
|
|
729
|
+
this.aggregated = aggregateFromPersisted(this.persistedDays, Date.now());
|
|
730
|
+
this.emit();
|
|
731
|
+
}
|
|
732
|
+
cachedAgg = null;
|
|
733
|
+
cachedAt = 0;
|
|
734
|
+
static CACHE_TTL_MS = 3e4;
|
|
735
|
+
static MAX_FALLBACK_SIDS = 2e3;
|
|
736
|
+
getAggregated() {
|
|
737
|
+
if (!this.initialized) return aggregate([], Date.now());
|
|
738
|
+
const now = Date.now();
|
|
739
|
+
if (this.cachedAgg && now - this.cachedAt < HeatmapStore.CACHE_TTL_MS) return this.cachedAgg;
|
|
740
|
+
const live = aggregate(this.events, now);
|
|
741
|
+
const merged = mergePersistedAndLive(this.persistedDays, live.byDay);
|
|
742
|
+
this.cachedAgg = aggregateFromPersisted(merged, now);
|
|
743
|
+
this.cachedAt = now;
|
|
744
|
+
return this.cachedAgg;
|
|
745
|
+
}
|
|
746
|
+
/** True once the full disk scan (init) has completed. Exposed to API consumers
|
|
747
|
+
* so partial data can be distinguished from "still loading". */
|
|
748
|
+
isReady() {
|
|
749
|
+
return this.initialized;
|
|
750
|
+
}
|
|
751
|
+
invalidateCache() {
|
|
752
|
+
this.cachedAgg = null;
|
|
753
|
+
this.cachedAt = 0;
|
|
754
|
+
}
|
|
755
|
+
/** Ingest a single session event incrementally. Keys are scoped per session
|
|
756
|
+
* via `event.sid` (set by the plugin's session/event handler) so sessions
|
|
757
|
+
* with the same turn/step do not overwrite each other. */
|
|
758
|
+
ingest(event) {
|
|
759
|
+
const sid = event?.sid ?? "";
|
|
760
|
+
const fb = () => this.fallbackBySid.get(sid) ?? {
|
|
761
|
+
provider: "",
|
|
762
|
+
model: ""
|
|
763
|
+
};
|
|
764
|
+
const setFb = (provider, model) => {
|
|
765
|
+
const cur = this.fallbackBySid.get(sid) ?? {
|
|
766
|
+
provider: "",
|
|
767
|
+
model: ""
|
|
768
|
+
};
|
|
769
|
+
if (provider) cur.provider = provider;
|
|
770
|
+
if (model) cur.model = model;
|
|
771
|
+
this.fallbackBySid.set(sid, cur);
|
|
772
|
+
if (this.fallbackBySid.size > HeatmapStore.MAX_FALLBACK_SIDS) for (const k of this.fallbackBySid.keys()) {
|
|
773
|
+
if (k === "") continue;
|
|
774
|
+
this.fallbackBySid.delete(k);
|
|
775
|
+
if (this.fallbackBySid.size <= HeatmapStore.MAX_FALLBACK_SIDS - 100) break;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
if (event.type === "request/header" && event.data?.header?.config) {
|
|
779
|
+
const cfg = event.data.header.config;
|
|
780
|
+
setFb(cfg.provider, cfg.model);
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (event.type === "request/context" && event.data) {
|
|
784
|
+
setFb(event.data.provider, event.data.model);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
788
|
+
const key = `${sid}::${event.data.turn}:${event.data.step}`;
|
|
789
|
+
if (!this.byTurnStep.has(key)) {
|
|
790
|
+
const f = fb();
|
|
791
|
+
const u = event.data.chunk.usage;
|
|
792
|
+
const ev = {
|
|
793
|
+
time: event.time ?? Date.now(),
|
|
794
|
+
provider: f.provider,
|
|
795
|
+
model: f.model,
|
|
796
|
+
sid,
|
|
797
|
+
usage: {
|
|
798
|
+
inputTokens: u.inputTokens ?? 0,
|
|
799
|
+
cacheReadTokens: u.cacheReadTokens ?? 0,
|
|
800
|
+
cacheWriteTokens: u.cacheWriteTokens ?? 0,
|
|
801
|
+
outputTokens: u.outputTokens ?? 0
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
this.byTurnStep.set(key, ev);
|
|
805
|
+
this.events.push(ev);
|
|
806
|
+
this.persistIncremental(ev);
|
|
807
|
+
this.rebuildAggregated();
|
|
808
|
+
}
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (event.type === "assistant/message" && event.data?.usage) {
|
|
812
|
+
const src = event.data.message?.source;
|
|
813
|
+
const f = fb();
|
|
814
|
+
const provider = src?.provider ?? f.provider;
|
|
815
|
+
const model = src?.model ?? f.model;
|
|
816
|
+
const turn = event.data.turn;
|
|
817
|
+
const step = event.data.step;
|
|
818
|
+
const key = turn !== void 0 && step !== void 0 ? `${sid}::${turn}:${step}` : `${sid}::msg:${event.seq ?? Math.random()}`;
|
|
819
|
+
const existing = this.byTurnStep.get(key);
|
|
820
|
+
const u = event.data.usage;
|
|
821
|
+
const ev = {
|
|
822
|
+
time: event.time ?? Date.now(),
|
|
823
|
+
provider,
|
|
824
|
+
model,
|
|
825
|
+
sid,
|
|
826
|
+
usage: {
|
|
827
|
+
inputTokens: u.inputTokens ?? 0,
|
|
828
|
+
cacheReadTokens: u.cacheReadTokens ?? 0,
|
|
829
|
+
cacheWriteTokens: u.cacheWriteTokens ?? 0,
|
|
830
|
+
outputTokens: u.outputTokens ?? 0
|
|
831
|
+
}
|
|
832
|
+
};
|
|
833
|
+
if (existing) {
|
|
834
|
+
const idx = this.events.indexOf(existing);
|
|
835
|
+
if (idx !== -1) this.events.splice(idx, 1);
|
|
836
|
+
}
|
|
837
|
+
this.byTurnStep.set(key, ev);
|
|
838
|
+
this.events.push(ev);
|
|
839
|
+
setFb(src?.provider, src?.model);
|
|
840
|
+
this.persistIncremental(ev);
|
|
841
|
+
this.rebuildAggregated();
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
persistIncremental(ev) {
|
|
845
|
+
const live = aggregate(this.events, Date.now());
|
|
846
|
+
const dayKey = toDayKey(ev.time);
|
|
847
|
+
const liveDay = live.byDay.get(dayKey);
|
|
848
|
+
if (!liveDay) return;
|
|
849
|
+
const prev = this.persistedDays.get(dayKey);
|
|
850
|
+
const merged = prev ? mergeTwoDays(prev, liveDay) : cloneDay(liveDay);
|
|
851
|
+
this.persistedDays.set(dayKey, merged);
|
|
852
|
+
savePersisted(this.persistedDays);
|
|
853
|
+
}
|
|
854
|
+
onChange(cb) {
|
|
855
|
+
this.listeners.add(cb);
|
|
856
|
+
return () => this.listeners.delete(cb);
|
|
857
|
+
}
|
|
858
|
+
emit() {
|
|
859
|
+
for (const cb of this.listeners) cb();
|
|
860
|
+
}
|
|
861
|
+
dispose() {
|
|
862
|
+
this.listeners.clear();
|
|
863
|
+
}
|
|
864
|
+
/** For testing: inject events directly */
|
|
865
|
+
_injectEvents(evs) {
|
|
866
|
+
this.events.push(...evs);
|
|
867
|
+
this.rebuildAggregated();
|
|
868
|
+
}
|
|
869
|
+
getPersistedDays() {
|
|
870
|
+
return new Map(this.persistedDays);
|
|
871
|
+
}
|
|
872
|
+
clearPersisted() {
|
|
873
|
+
this.persistedDays.clear();
|
|
874
|
+
savePersisted(this.persistedDays);
|
|
875
|
+
this.rebuildAggregated();
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
function cloneDay(d) {
|
|
879
|
+
return {
|
|
880
|
+
dayKey: d.dayKey,
|
|
881
|
+
totalTokens: d.totalTokens,
|
|
882
|
+
uncachedInputTokens: d.uncachedInputTokens,
|
|
883
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
884
|
+
cacheWriteTokens: d.cacheWriteTokens,
|
|
885
|
+
outputTokens: d.outputTokens,
|
|
886
|
+
count: d.count,
|
|
887
|
+
byModel: new Map(d.byModel),
|
|
888
|
+
byProvider: new Map(d.byProvider),
|
|
889
|
+
winnerModel: d.winnerModel,
|
|
890
|
+
winnerProvider: d.winnerProvider
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
function mergeTwoDays(prev, live) {
|
|
894
|
+
const byModel = new Map(prev.byModel);
|
|
895
|
+
for (const [k, v] of live.byModel) byModel.set(k, Math.max(byModel.get(k) ?? 0, v));
|
|
896
|
+
const byProvider = new Map(prev.byProvider);
|
|
897
|
+
for (const [k, v] of live.byProvider) byProvider.set(k, Math.max(byProvider.get(k) ?? 0, v));
|
|
898
|
+
const merged = {
|
|
899
|
+
dayKey: prev.dayKey,
|
|
900
|
+
totalTokens: Math.max(prev.totalTokens, live.totalTokens),
|
|
901
|
+
uncachedInputTokens: Math.max(prev.uncachedInputTokens, live.uncachedInputTokens),
|
|
902
|
+
cacheReadTokens: Math.max(prev.cacheReadTokens, live.cacheReadTokens),
|
|
903
|
+
cacheWriteTokens: Math.max(prev.cacheWriteTokens, live.cacheWriteTokens),
|
|
904
|
+
outputTokens: Math.max(prev.outputTokens, live.outputTokens),
|
|
905
|
+
count: Math.max(prev.count, live.count),
|
|
906
|
+
byModel,
|
|
907
|
+
byProvider,
|
|
908
|
+
winnerModel: null,
|
|
909
|
+
winnerProvider: null
|
|
910
|
+
};
|
|
911
|
+
let bestM = null;
|
|
912
|
+
let bestMVal = -1;
|
|
913
|
+
for (const [m, v2] of byModel) if (v2 > bestMVal) {
|
|
914
|
+
bestMVal = v2;
|
|
915
|
+
bestM = m;
|
|
916
|
+
}
|
|
917
|
+
merged.winnerModel = bestM;
|
|
918
|
+
let bestP = null;
|
|
919
|
+
let bestPVal = -1;
|
|
920
|
+
for (const [p, v2] of byProvider) if (v2 > bestPVal) {
|
|
921
|
+
bestPVal = v2;
|
|
922
|
+
bestP = p;
|
|
923
|
+
}
|
|
924
|
+
merged.winnerProvider = bestP;
|
|
925
|
+
return merged;
|
|
926
|
+
}
|
|
927
|
+
function hasAnyIncrease(a, b) {
|
|
928
|
+
for (const [k, vb] of b) {
|
|
929
|
+
const va = a.get(k);
|
|
930
|
+
if (!va) return true;
|
|
931
|
+
if (vb.totalTokens > va.totalTokens) return true;
|
|
932
|
+
if (vb.count > va.count) return true;
|
|
933
|
+
}
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
function mapsEqual(a, b) {
|
|
937
|
+
if (a.size !== b.size) return false;
|
|
938
|
+
for (const [k, va] of a) {
|
|
939
|
+
const vb = b.get(k);
|
|
940
|
+
if (!vb) return false;
|
|
941
|
+
if (va.totalTokens !== vb.totalTokens || va.count !== vb.count) return false;
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
function aggregateFromPersisted(persistedDays, nowMs) {
|
|
946
|
+
const fakeEvents = [];
|
|
947
|
+
for (const day of persistedDays.values()) {
|
|
948
|
+
for (const [model, tokens] of day.byModel) fakeEvents.push({
|
|
949
|
+
time: (/* @__PURE__ */ new Date(day.dayKey + "T12:00:00+08:00")).getTime(),
|
|
950
|
+
provider: day.winnerProvider ?? "unknown",
|
|
951
|
+
model,
|
|
952
|
+
usage: {
|
|
953
|
+
inputTokens: tokens,
|
|
954
|
+
cacheReadTokens: 0,
|
|
955
|
+
cacheWriteTokens: 0,
|
|
956
|
+
outputTokens: 0
|
|
957
|
+
}
|
|
958
|
+
});
|
|
959
|
+
if (day.byModel.size === 0 && day.totalTokens > 0) fakeEvents.push({
|
|
960
|
+
time: (/* @__PURE__ */ new Date(day.dayKey + "T12:00:00+08:00")).getTime(),
|
|
961
|
+
provider: day.winnerProvider ?? "unknown",
|
|
962
|
+
model: day.winnerModel ?? "unknown",
|
|
963
|
+
usage: {
|
|
964
|
+
inputTokens: day.totalTokens,
|
|
965
|
+
cacheReadTokens: 0,
|
|
966
|
+
cacheWriteTokens: 0,
|
|
967
|
+
outputTokens: 0
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
const agg = aggregate(fakeEvents, nowMs);
|
|
972
|
+
const todayKey = toDayKey(nowMs);
|
|
973
|
+
const [wS, wE] = weekRangeFor(todayKey);
|
|
974
|
+
const [mS, mE] = monthRangeFor(todayKey);
|
|
975
|
+
const sumRange = (s, e) => {
|
|
976
|
+
let sum = 0;
|
|
977
|
+
let cur = s;
|
|
978
|
+
while (cur <= e) {
|
|
979
|
+
sum += persistedDays.get(cur)?.totalTokens ?? 0;
|
|
980
|
+
const d = /* @__PURE__ */ new Date(cur + "T12:00:00+08:00");
|
|
981
|
+
d.setDate(d.getDate() + 1);
|
|
982
|
+
cur = toDayKey(d.getTime());
|
|
983
|
+
if (cur > e) break;
|
|
984
|
+
}
|
|
985
|
+
return sum;
|
|
986
|
+
};
|
|
987
|
+
let all = 0;
|
|
988
|
+
for (const v of persistedDays.values()) all += v.totalTokens;
|
|
989
|
+
agg.totals = {
|
|
990
|
+
today: persistedDays.get(todayKey)?.totalTokens ?? 0,
|
|
991
|
+
thisWeek: sumRange(wS, wE),
|
|
992
|
+
thisMonth: sumRange(mS, mE),
|
|
993
|
+
all
|
|
994
|
+
};
|
|
995
|
+
agg.byDay = new Map(persistedDays);
|
|
996
|
+
return agg;
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/index.ts
|
|
1000
|
+
const name = "dsh-token-pulse";
|
|
1001
|
+
const inject = ["sessions"];
|
|
1002
|
+
function apply(ctx) {
|
|
1003
|
+
const store = new HeatmapStore(ctx);
|
|
1004
|
+
ctx.provide("heatmapStore", store);
|
|
1005
|
+
ctx.heatmapStore = store;
|
|
1006
|
+
let disposed = false;
|
|
1007
|
+
store.init().catch((e) => {
|
|
1008
|
+
ctx.logger?.warn?.(`dsh-token-pulse init failed: ${String(e)}`);
|
|
1009
|
+
});
|
|
1010
|
+
ctx.on("session/event", (session, event) => {
|
|
1011
|
+
if (disposed) return;
|
|
1012
|
+
store.ingest({
|
|
1013
|
+
...event,
|
|
1014
|
+
sid: session?.id ?? ""
|
|
1015
|
+
});
|
|
1016
|
+
});
|
|
1017
|
+
ctx.on("session/flush", () => {});
|
|
1018
|
+
let unregisterRoute = null;
|
|
1019
|
+
ctx.inject(["webServer"], (wsCtx) => {
|
|
1020
|
+
try {
|
|
1021
|
+
const handler = (req, res) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const agg = store.getAggregated();
|
|
1024
|
+
const days = [...agg.byDay.values()].map((d) => ({
|
|
1025
|
+
dayKey: d.dayKey,
|
|
1026
|
+
totalTokens: d.totalTokens,
|
|
1027
|
+
uncachedInputTokens: d.uncachedInputTokens,
|
|
1028
|
+
cacheReadTokens: d.cacheReadTokens,
|
|
1029
|
+
cacheWriteTokens: d.cacheWriteTokens,
|
|
1030
|
+
outputTokens: d.outputTokens,
|
|
1031
|
+
count: d.count,
|
|
1032
|
+
byModel: Object.fromEntries(d.byModel),
|
|
1033
|
+
byProvider: Object.fromEntries(d.byProvider),
|
|
1034
|
+
hourlyTokens: d.hourlyTokens,
|
|
1035
|
+
winnerModel: d.winnerModel,
|
|
1036
|
+
winnerProvider: d.winnerProvider
|
|
1037
|
+
}));
|
|
1038
|
+
const body = JSON.stringify({
|
|
1039
|
+
version: 1,
|
|
1040
|
+
ready: store.isReady(),
|
|
1041
|
+
totals: agg.totals,
|
|
1042
|
+
topModels: agg.topModels.map((x) => ({
|
|
1043
|
+
name: x.model,
|
|
1044
|
+
tokens: x.tokens
|
|
1045
|
+
})),
|
|
1046
|
+
topProviders: agg.topProviders.map((x) => ({
|
|
1047
|
+
name: x.provider,
|
|
1048
|
+
tokens: x.tokens
|
|
1049
|
+
})),
|
|
1050
|
+
days
|
|
1051
|
+
});
|
|
1052
|
+
res.writeHead(200, {
|
|
1053
|
+
"Content-Type": "application/json",
|
|
1054
|
+
"Cache-Control": "no-store"
|
|
1055
|
+
});
|
|
1056
|
+
res.end(body);
|
|
1057
|
+
} catch (e) {
|
|
1058
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1059
|
+
res.end(JSON.stringify({ error: String(e) }));
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
unregisterRoute = wsCtx.webServer.register({
|
|
1063
|
+
kind: "exact",
|
|
1064
|
+
path: "/api/dsh-token-pulse/daily.json",
|
|
1065
|
+
handler
|
|
1066
|
+
});
|
|
1067
|
+
ctx.logger.info("dsh-token-pulse: registered /api/dsh-token-pulse/daily.json");
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
ctx.logger?.warn?.(`dsh-token-pulse route register failed: ${String(e)}`);
|
|
1070
|
+
}
|
|
1071
|
+
});
|
|
1072
|
+
ctx.effect(() => () => {
|
|
1073
|
+
disposed = true;
|
|
1074
|
+
store.dispose();
|
|
1075
|
+
if (unregisterRoute) try {
|
|
1076
|
+
unregisterRoute();
|
|
1077
|
+
} catch {}
|
|
1078
|
+
}, "dsh-token-pulse.dispose");
|
|
1079
|
+
ctx.logger.info("dsh-token-pulse host up");
|
|
1080
|
+
}
|
|
1081
|
+
//#endregion
|
|
1082
|
+
export { apply, inject, name };
|
|
1083
|
+
|
|
1084
|
+
//# sourceMappingURL=index.mjs.map
|