@duke-dsh-plugins/dsh-token-stats 1.3.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/LICENSE +21 -0
- package/README.md +105 -0
- package/client.js +677 -0
- package/cordis.patch.yml +4 -0
- package/index.js +455 -0
- package/package.json +48 -0
- package/typert.host.js +151 -0
package/index.js
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-stats — Host half.
|
|
3
|
+
*
|
|
4
|
+
* A Cordis "class plugin": this module exports a `TokenStatsService` extending
|
|
5
|
+
* `TypertRemoteService`. The DSH loader instantiates the class and registers it
|
|
6
|
+
* as the `tokenStats` service; the Typert Gateway exposes its `@Remote`-marked
|
|
7
|
+
* methods to the browser Client half under the `tokenStats` Remote namespace.
|
|
8
|
+
*
|
|
9
|
+
* Data collection (single authoritative source = the session log):
|
|
10
|
+
* - LIVE: `session/event` feed — every `assistant/message` append carries the
|
|
11
|
+
* step's `TokenUsage` plus the model/provider provenance
|
|
12
|
+
* (`message.source`, kind === 'model'). A root-mounted plugin context is
|
|
13
|
+
* untagged, so the dsh-scope filter admits it for every session.
|
|
14
|
+
* - HISTORY: `sessionQuery.readSession(id)` backfills each persisted session
|
|
15
|
+
* once, folding events that happened before the plugin started.
|
|
16
|
+
* - DEDUP: a per-session seq watermark — each event is folded exactly once,
|
|
17
|
+
* whichever source (live feed or backfill) reaches it first. Both paths
|
|
18
|
+
* do the check-and-set synchronously, so no event is ever double counted.
|
|
19
|
+
* - PERSIST: aggregates, watermarks and the set of fully-folded sessions
|
|
20
|
+
* are written to `<DSH_HOME>/data/dsh-token-stats/stats.json` (debounced,
|
|
21
|
+
* atomic tmp+rename, flushed synchronously on dispose). A cold start
|
|
22
|
+
* restores them and skips `readSession` for already-folded sessions;
|
|
23
|
+
* only sessions created since the last run are scanned. Delete the file
|
|
24
|
+
* to force a full rescan.
|
|
25
|
+
*
|
|
26
|
+
* Aggregates are kept per local calendar day per model key
|
|
27
|
+
* (`<provider>::<model>`), which is exactly what the Client charts consume.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
31
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
32
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
33
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
34
|
+
import { homedir } from "node:os";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 86400000;
|
|
38
|
+
/** How many days of history to keep (a bit over one year). */
|
|
39
|
+
const WINDOW_DAYS = 400;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Durable store path. The session pool lives under `<DSH_HOME>/sessions`
|
|
43
|
+
* (global, not per-profile), so the store sits next to it under
|
|
44
|
+
* `<DSH_HOME>/data/dsh-token-stats/` and survives plugin reinstall/upgrade.
|
|
45
|
+
*/
|
|
46
|
+
const STORE_VERSION = 1;
|
|
47
|
+
const SAVE_DEBOUNCE_MS = 1500;
|
|
48
|
+
const DSH_HOME = process.env.DSH_HOME || join(homedir(), ".dsh");
|
|
49
|
+
const STORE_DIR = join(DSH_HOME, "data", "dsh-token-stats");
|
|
50
|
+
const STORE_FILE = join(STORE_DIR, "stats.json");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mark one instance method as a Remote export without relying on decorator
|
|
54
|
+
* syntax (Node ESM does not support the proposal decorators here). We drive
|
|
55
|
+
* the same `Remote(name)` decorator manually through a synthetic decorator
|
|
56
|
+
* context and run the registered initializers against the instance.
|
|
57
|
+
*
|
|
58
|
+
* @param {object} instance - live service instance whose prototype is marked.
|
|
59
|
+
* @param {string} method - public instance method name.
|
|
60
|
+
* @param {string} [exportName] - wire export name; defaults to the method name.
|
|
61
|
+
*/
|
|
62
|
+
function markRemoteMethod(instance, method, exportName) {
|
|
63
|
+
const decorator = Remote(method, undefined);
|
|
64
|
+
const initializers = [];
|
|
65
|
+
decorator(undefined, {
|
|
66
|
+
kind: "method",
|
|
67
|
+
name: method,
|
|
68
|
+
static: false,
|
|
69
|
+
private: false,
|
|
70
|
+
addInitializer: (fn) => initializers.push(fn),
|
|
71
|
+
});
|
|
72
|
+
for (const fn of initializers) fn.call(instance);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class TokenStatsService extends TypertRemoteService {
|
|
76
|
+
/**
|
|
77
|
+
* No hard service dependencies: `sessionQuery` is read through `ctx.get()`
|
|
78
|
+
* and backfill retries on the first `getStats()` call if it was not mounted
|
|
79
|
+
* when the plugin activated.
|
|
80
|
+
*/
|
|
81
|
+
static inject = [];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Cordis instantiates class plugins with `new Callback(ctx, config)` — the
|
|
85
|
+
* second argument is the plugin config, NOT the service key. Pass the exact
|
|
86
|
+
* service key to `super()` (see dsh-archive-manager's constructor fix).
|
|
87
|
+
*/
|
|
88
|
+
constructor(ctx, config) {
|
|
89
|
+
super(ctx, "tokenStats");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Cordis class-plugin initializer: runs right after construction, before the
|
|
94
|
+
* service is published. Mark the Remote method, then start collecting.
|
|
95
|
+
*/
|
|
96
|
+
[Service.init]() {
|
|
97
|
+
markRemoteMethod(this, "getStats", "getStats");
|
|
98
|
+
|
|
99
|
+
/** dayKey ('YYYY-MM-DD', local) -> Map<modelKey, day aggregate>. */
|
|
100
|
+
this._byDay = new Map();
|
|
101
|
+
/** modelKey -> { key, name, provider }. */
|
|
102
|
+
this._modelMeta = new Map();
|
|
103
|
+
/** sessionId -> highest folded event seq (dedup watermark). */
|
|
104
|
+
this._watermark = new Map();
|
|
105
|
+
this._backfill = { started: false, done: false, total: 0, doneCount: 0, error: null };
|
|
106
|
+
/** sessionIds whose complete log (up to the watermark) has been folded. */
|
|
107
|
+
this._folded = new Set();
|
|
108
|
+
this._dirty = false;
|
|
109
|
+
this._saveTimer = null;
|
|
110
|
+
this._saving = false;
|
|
111
|
+
|
|
112
|
+
// Restore the previous run's aggregates so a cold start only scans new sessions.
|
|
113
|
+
this._loadStore();
|
|
114
|
+
|
|
115
|
+
// Best-effort synchronous flush when the plugin fiber is disposed.
|
|
116
|
+
this.ctx.effect(() => () => {
|
|
117
|
+
if (this._saveTimer) {
|
|
118
|
+
clearTimeout(this._saveTimer);
|
|
119
|
+
this._saveTimer = null;
|
|
120
|
+
}
|
|
121
|
+
if (this._dirty) this._saveSync();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Live capture: every committed session append.
|
|
125
|
+
this.ctx.on("session/event", (session, event) => this._onSessionEvent(session, event));
|
|
126
|
+
|
|
127
|
+
this._startBackfill();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---- data helpers ---------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
_dayKeyOf(time) {
|
|
133
|
+
const d = new Date(time);
|
|
134
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
135
|
+
const dd = String(d.getDate()).padStart(2, "0");
|
|
136
|
+
return `${d.getFullYear()}-${m}-${dd}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
_ensureModel(provider, model) {
|
|
140
|
+
const key = `${provider || "?"}::${model}`;
|
|
141
|
+
let meta = this._modelMeta.get(key);
|
|
142
|
+
if (!meta) {
|
|
143
|
+
meta = { key, name: model, provider: provider || "" };
|
|
144
|
+
this._modelMeta.set(key, meta);
|
|
145
|
+
}
|
|
146
|
+
return key;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_addUsage(provider, model, usage, time) {
|
|
150
|
+
if (!usage || typeof usage !== "object") return false;
|
|
151
|
+
const input = Number(usage.inputTokens) || 0;
|
|
152
|
+
const output = Number(usage.outputTokens) || 0;
|
|
153
|
+
const cr = Number(usage.cacheReadTokens) || 0;
|
|
154
|
+
const cw = Number(usage.cacheWriteTokens) || 0;
|
|
155
|
+
const reason = Number(usage.reasoningTokens) || 0;
|
|
156
|
+
const total = input + output + cr + cw;
|
|
157
|
+
if (total <= 0) return false;
|
|
158
|
+
const key = this._ensureModel(provider, model);
|
|
159
|
+
const dk = this._dayKeyOf(time);
|
|
160
|
+
let day = this._byDay.get(dk);
|
|
161
|
+
if (!day) {
|
|
162
|
+
day = new Map();
|
|
163
|
+
this._byDay.set(dk, day);
|
|
164
|
+
}
|
|
165
|
+
let rec = day.get(key);
|
|
166
|
+
if (!rec) {
|
|
167
|
+
rec = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, total: 0 };
|
|
168
|
+
day.set(key, rec);
|
|
169
|
+
}
|
|
170
|
+
rec.input += input;
|
|
171
|
+
rec.output += output;
|
|
172
|
+
rec.cacheRead += cr;
|
|
173
|
+
rec.cacheWrite += cw;
|
|
174
|
+
rec.reasoning += reason;
|
|
175
|
+
rec.total += total;
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Fold one `assistant/message` event (model provenance + usage). */
|
|
180
|
+
_foldEvent(event) {
|
|
181
|
+
try {
|
|
182
|
+
if (!event || typeof event !== "object" || event.type !== "assistant/message") return false;
|
|
183
|
+
const data = event.data;
|
|
184
|
+
if (!data || !data.usage) return false;
|
|
185
|
+
const src = data.message && data.message.source;
|
|
186
|
+
if (!src || src.kind !== "model") return false;
|
|
187
|
+
return this._addUsage(
|
|
188
|
+
src.provider || "",
|
|
189
|
+
src.model || "",
|
|
190
|
+
data.usage,
|
|
191
|
+
typeof event.time === "number" ? event.time : Date.now(),
|
|
192
|
+
);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
/* keep stats robust */
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_onSessionEvent(session, event) {
|
|
200
|
+
try {
|
|
201
|
+
if (!event || event.type !== "assistant/message") return;
|
|
202
|
+
const sid = session && session.id;
|
|
203
|
+
if (!sid || typeof event.seq !== "number") return;
|
|
204
|
+
const wm = this._watermark.get(sid) ?? -1;
|
|
205
|
+
if (event.seq <= wm) return;
|
|
206
|
+
this._watermark.set(sid, event.seq);
|
|
207
|
+
if (this._foldEvent(event)) this._scheduleSave();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
/* keep stats robust */
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- backfill -------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
async _startBackfill() {
|
|
216
|
+
if (this._backfill.started || this._backfill.done) return;
|
|
217
|
+
const sessionQuery = this.ctx.get("sessionQuery");
|
|
218
|
+
if (!sessionQuery || typeof sessionQuery.listSessions !== "function") return;
|
|
219
|
+
this._backfill.started = true;
|
|
220
|
+
try {
|
|
221
|
+
let sessions = [];
|
|
222
|
+
try {
|
|
223
|
+
sessions = (await sessionQuery.listSessions()) || [];
|
|
224
|
+
} catch (e) {
|
|
225
|
+
sessions = [];
|
|
226
|
+
}
|
|
227
|
+
this._backfill.total = sessions.length;
|
|
228
|
+
let next = 0;
|
|
229
|
+
const worker = async () => {
|
|
230
|
+
for (;;) {
|
|
231
|
+
const idx = next++;
|
|
232
|
+
if (idx >= sessions.length) return;
|
|
233
|
+
const rec = sessions[idx];
|
|
234
|
+
const header = rec && rec.header;
|
|
235
|
+
if (!header || !header.id) {
|
|
236
|
+
this._backfill.doneCount++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
// Restored from the store: this session's log was fully folded in a
|
|
240
|
+
// previous run — skip the expensive readSession entirely.
|
|
241
|
+
if (this._folded.has(header.id)) {
|
|
242
|
+
this._backfill.doneCount++;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const snap = await sessionQuery.readSession(header.id);
|
|
247
|
+
if (snap && Array.isArray(snap.events)) {
|
|
248
|
+
let wm = this._watermark.get(header.id) ?? -1;
|
|
249
|
+
for (const ev of snap.events) {
|
|
250
|
+
if (!ev || typeof ev.seq !== "number" || ev.seq <= wm) continue;
|
|
251
|
+
wm = ev.seq;
|
|
252
|
+
this._foldEvent(ev);
|
|
253
|
+
}
|
|
254
|
+
this._watermark.set(header.id, wm);
|
|
255
|
+
}
|
|
256
|
+
this._folded.add(header.id);
|
|
257
|
+
this._scheduleSave();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
/* skip unreadable session */
|
|
260
|
+
}
|
|
261
|
+
this._backfill.doneCount++;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
await Promise.all([worker(), worker(), worker(), worker()]);
|
|
265
|
+
this._backfill.done = true;
|
|
266
|
+
this._saveNow();
|
|
267
|
+
} catch (e) {
|
|
268
|
+
this._backfill.error = String((e && e.message) || e);
|
|
269
|
+
this._backfill.done = true;
|
|
270
|
+
this._saveNow();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ---- persistence ----------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
_serializeStore() {
|
|
277
|
+
const cutoff = Date.now() - WINDOW_DAYS * DAY_MS;
|
|
278
|
+
const days = {};
|
|
279
|
+
for (const [dk, day] of this._byDay) {
|
|
280
|
+
const t = new Date(`${dk}T00:00:00`).getTime();
|
|
281
|
+
if (!Number.isNaN(t) && t < cutoff) continue;
|
|
282
|
+
const models = {};
|
|
283
|
+
for (const [key, rec] of day) {
|
|
284
|
+
models[key] = {
|
|
285
|
+
input: rec.input,
|
|
286
|
+
output: rec.output,
|
|
287
|
+
cacheRead: rec.cacheRead,
|
|
288
|
+
cacheWrite: rec.cacheWrite,
|
|
289
|
+
reasoning: rec.reasoning,
|
|
290
|
+
total: rec.total,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
days[dk] = models;
|
|
294
|
+
}
|
|
295
|
+
const watermarks = {};
|
|
296
|
+
for (const [sid, seq] of this._watermark) watermarks[sid] = seq;
|
|
297
|
+
const modelMeta = {};
|
|
298
|
+
for (const [key, meta] of this._modelMeta) {
|
|
299
|
+
modelMeta[key] = { key: meta.key, name: meta.name, provider: meta.provider };
|
|
300
|
+
}
|
|
301
|
+
return JSON.stringify({
|
|
302
|
+
version: STORE_VERSION,
|
|
303
|
+
savedAt: Date.now(),
|
|
304
|
+
folded: [...this._folded],
|
|
305
|
+
watermarks,
|
|
306
|
+
modelMeta,
|
|
307
|
+
days,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
_loadStore() {
|
|
312
|
+
try {
|
|
313
|
+
const data = JSON.parse(readFileSync(STORE_FILE, "utf8"));
|
|
314
|
+
if (!data || data.version !== STORE_VERSION) return;
|
|
315
|
+
const watermarks = data.watermarks || {};
|
|
316
|
+
for (const sid of Object.keys(watermarks)) {
|
|
317
|
+
const seq = watermarks[sid];
|
|
318
|
+
if (typeof seq === "number" && Number.isFinite(seq)) this._watermark.set(sid, seq);
|
|
319
|
+
}
|
|
320
|
+
const folded = Array.isArray(data.folded) ? data.folded : [];
|
|
321
|
+
for (const sid of folded) {
|
|
322
|
+
// A folded id is only meaningful together with its watermark.
|
|
323
|
+
if (typeof sid === "string" && this._watermark.has(sid)) this._folded.add(sid);
|
|
324
|
+
}
|
|
325
|
+
const modelMeta = data.modelMeta || {};
|
|
326
|
+
for (const key of Object.keys(modelMeta)) {
|
|
327
|
+
const meta = modelMeta[key];
|
|
328
|
+
if (!meta || typeof meta !== "object") continue;
|
|
329
|
+
this._modelMeta.set(key, {
|
|
330
|
+
key: typeof meta.key === "string" ? meta.key : key,
|
|
331
|
+
name: typeof meta.name === "string" ? meta.name : key,
|
|
332
|
+
provider: typeof meta.provider === "string" ? meta.provider : "",
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const days = data.days || {};
|
|
336
|
+
for (const dk of Object.keys(days)) {
|
|
337
|
+
const models = days[dk];
|
|
338
|
+
if (!models || typeof models !== "object") continue;
|
|
339
|
+
const day = new Map();
|
|
340
|
+
for (const key of Object.keys(models)) {
|
|
341
|
+
const rec = models[key];
|
|
342
|
+
if (!rec || typeof rec !== "object") continue;
|
|
343
|
+
day.set(key, {
|
|
344
|
+
input: Number(rec.input) || 0,
|
|
345
|
+
output: Number(rec.output) || 0,
|
|
346
|
+
cacheRead: Number(rec.cacheRead) || 0,
|
|
347
|
+
cacheWrite: Number(rec.cacheWrite) || 0,
|
|
348
|
+
reasoning: Number(rec.reasoning) || 0,
|
|
349
|
+
total: Number(rec.total) || 0,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
if (day.size > 0) this._byDay.set(dk, day);
|
|
353
|
+
}
|
|
354
|
+
} catch (e) {
|
|
355
|
+
/* missing or corrupt store: start empty and let backfill do a full scan */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Debounced async persist (trailing edge); cheap to call on every fold. */
|
|
360
|
+
_scheduleSave() {
|
|
361
|
+
this._dirty = true;
|
|
362
|
+
if (this._saveTimer) return;
|
|
363
|
+
this._saveTimer = setTimeout(() => {
|
|
364
|
+
this._saveTimer = null;
|
|
365
|
+
this._saveNow();
|
|
366
|
+
}, SAVE_DEBOUNCE_MS);
|
|
367
|
+
if (typeof this._saveTimer.unref === "function") this._saveTimer.unref();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async _saveNow() {
|
|
371
|
+
if (!this._dirty || this._saving) return;
|
|
372
|
+
this._dirty = false;
|
|
373
|
+
this._saving = true;
|
|
374
|
+
try {
|
|
375
|
+
const text = this._serializeStore();
|
|
376
|
+
await mkdir(STORE_DIR, { recursive: true });
|
|
377
|
+
const tmp = `${STORE_FILE}.tmp`;
|
|
378
|
+
await writeFile(tmp, text, "utf8");
|
|
379
|
+
await rename(tmp, STORE_FILE);
|
|
380
|
+
} catch (e) {
|
|
381
|
+
this._dirty = true; // retried on the next trigger or the dispose flush
|
|
382
|
+
} finally {
|
|
383
|
+
this._saving = false;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Synchronous flush for plugin disposal (async may not get loop turns). */
|
|
388
|
+
_saveSync() {
|
|
389
|
+
try {
|
|
390
|
+
const text = this._serializeStore();
|
|
391
|
+
mkdirSync(STORE_DIR, { recursive: true });
|
|
392
|
+
const tmp = `${STORE_FILE}.tmp`;
|
|
393
|
+
writeFileSync(tmp, text, "utf8");
|
|
394
|
+
renameSync(tmp, STORE_FILE);
|
|
395
|
+
this._dirty = false;
|
|
396
|
+
} catch (e) {
|
|
397
|
+
/* shutdown flush is best-effort */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ---- Remote API -----------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whole-dataset snapshot for the Client: every day (last WINDOW_DAYS) with
|
|
405
|
+
* per-model aggregates, plus the ordered model list. The Client derives the
|
|
406
|
+
* 7/30-day windows, the stacked bar chart, the donut, and the heatmap from
|
|
407
|
+
* this single payload, so tab switching needs no extra round trips.
|
|
408
|
+
*/
|
|
409
|
+
async getStats() {
|
|
410
|
+
if (!this._backfill.started && !this._backfill.done) this._startBackfill();
|
|
411
|
+
const cutoff = Date.now() - WINDOW_DAYS * DAY_MS;
|
|
412
|
+
const days = [];
|
|
413
|
+
for (const [dk, day] of this._byDay) {
|
|
414
|
+
const t = new Date(`${dk}T00:00:00`).getTime();
|
|
415
|
+
if (!Number.isNaN(t) && t < cutoff) continue;
|
|
416
|
+
const models = [];
|
|
417
|
+
let total = 0;
|
|
418
|
+
for (const [key, rec] of day) {
|
|
419
|
+
const meta = this._modelMeta.get(key) || { key, name: key, provider: "" };
|
|
420
|
+
models.push({
|
|
421
|
+
key: meta.key,
|
|
422
|
+
name: meta.name,
|
|
423
|
+
provider: meta.provider,
|
|
424
|
+
input: rec.input,
|
|
425
|
+
output: rec.output,
|
|
426
|
+
cacheRead: rec.cacheRead,
|
|
427
|
+
cacheWrite: rec.cacheWrite,
|
|
428
|
+
reasoning: rec.reasoning,
|
|
429
|
+
total: rec.total,
|
|
430
|
+
});
|
|
431
|
+
total += rec.total;
|
|
432
|
+
}
|
|
433
|
+
models.sort((a, b) => b.total - a.total);
|
|
434
|
+
days.push({ date: dk, total, models });
|
|
435
|
+
}
|
|
436
|
+
days.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
437
|
+
const models = [];
|
|
438
|
+
for (const meta of this._modelMeta.values()) {
|
|
439
|
+
models.push({ key: meta.key, name: meta.name, provider: meta.provider });
|
|
440
|
+
}
|
|
441
|
+
return {
|
|
442
|
+
ok: true,
|
|
443
|
+
value: {
|
|
444
|
+
ready: this._backfill.done,
|
|
445
|
+
collecting: this._backfill.started && !this._backfill.done,
|
|
446
|
+
progress: { done: this._backfill.doneCount, total: this._backfill.total },
|
|
447
|
+
error: this._backfill.error,
|
|
448
|
+
days,
|
|
449
|
+
models,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export default TokenStatsService;
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@duke-dsh-plugins/dsh-token-stats",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Token usage statistics for DeepSeek Harness: last-7/30-day per-model daily and total consumption (bar chart + donut), plus a GitHub-style contributions heatmap over the last year.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/MoonlitDropOfBlood/dsh-token-stats.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/MoonlitDropOfBlood/dsh-token-stats",
|
|
10
|
+
"bugs": { "url": "https://github.com/MoonlitDropOfBlood/dsh-token-stats/issues" },
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"keywords": ["dsh", "deepseek-harness", "plugin", "token", "stats"],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "index.js",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./index.js",
|
|
17
|
+
"./client": "./client.js",
|
|
18
|
+
"./typert": "./typert.host.js",
|
|
19
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"index.js",
|
|
24
|
+
"client.js",
|
|
25
|
+
"typert.host.js",
|
|
26
|
+
"cordis.patch.yml"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dsh": {
|
|
30
|
+
"bundle": {
|
|
31
|
+
"patch": "./cordis.patch.yml"
|
|
32
|
+
},
|
|
33
|
+
"client": {
|
|
34
|
+
"platform": "web"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
39
|
+
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.7"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"zod": "^4.4.3"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"check": "node --check index.js && node --check client.js && node --check typert.host.js",
|
|
46
|
+
"smoke": "node scripts/smoke-store.mjs"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/typert.host.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-stats — Typert Host manifest.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written TYPERT manifest (the format the DSH typert-loader consumes from
|
|
5
|
+
* the package's `./typert` export). It describes the `tokenStats` Remote
|
|
6
|
+
* service the Host half publishes so the browser Client half can call it
|
|
7
|
+
* through `ctx.remote.tokenStats.getStats()`.
|
|
8
|
+
*
|
|
9
|
+
* Keep the invocation ids, service/namespace names and method names in sync
|
|
10
|
+
* with `index.js` (TokenStatsService) and `client.js`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
// ---- shared shapes ---------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
const modelDaySchema = z
|
|
18
|
+
.object({
|
|
19
|
+
key: z.string(),
|
|
20
|
+
name: z.string(),
|
|
21
|
+
provider: z.string(),
|
|
22
|
+
input: z.number(),
|
|
23
|
+
output: z.number(),
|
|
24
|
+
cacheRead: z.number(),
|
|
25
|
+
cacheWrite: z.number(),
|
|
26
|
+
reasoning: z.number(),
|
|
27
|
+
total: z.number(),
|
|
28
|
+
})
|
|
29
|
+
.readonly();
|
|
30
|
+
|
|
31
|
+
const daySchema = z
|
|
32
|
+
.object({
|
|
33
|
+
date: z.string(),
|
|
34
|
+
total: z.number(),
|
|
35
|
+
models: z.array(modelDaySchema).readonly(),
|
|
36
|
+
})
|
|
37
|
+
.readonly();
|
|
38
|
+
|
|
39
|
+
const modelRefSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
key: z.string(),
|
|
42
|
+
name: z.string(),
|
|
43
|
+
provider: z.string(),
|
|
44
|
+
})
|
|
45
|
+
.readonly();
|
|
46
|
+
|
|
47
|
+
const statsValueSchema = z
|
|
48
|
+
.object({
|
|
49
|
+
ready: z.boolean(),
|
|
50
|
+
collecting: z.boolean(),
|
|
51
|
+
progress: z
|
|
52
|
+
.object({
|
|
53
|
+
done: z.number(),
|
|
54
|
+
total: z.number(),
|
|
55
|
+
})
|
|
56
|
+
.readonly(),
|
|
57
|
+
error: z.union([z.string(), z.null()]),
|
|
58
|
+
days: z.array(daySchema).readonly(),
|
|
59
|
+
models: z.array(modelRefSchema).readonly(),
|
|
60
|
+
})
|
|
61
|
+
.readonly();
|
|
62
|
+
|
|
63
|
+
const tokenStatsResultSchema = z.union([
|
|
64
|
+
z
|
|
65
|
+
.object({
|
|
66
|
+
ok: z.literal(true).readonly(),
|
|
67
|
+
value: statsValueSchema,
|
|
68
|
+
})
|
|
69
|
+
.readonly(),
|
|
70
|
+
z
|
|
71
|
+
.object({
|
|
72
|
+
ok: z.literal(false).readonly(),
|
|
73
|
+
error: z
|
|
74
|
+
.object({
|
|
75
|
+
code: z.string().readonly(),
|
|
76
|
+
message: z.string().readonly().optional(),
|
|
77
|
+
})
|
|
78
|
+
.readonly(),
|
|
79
|
+
})
|
|
80
|
+
.readonly(),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
export const TYPERT = {
|
|
84
|
+
package: "dsh-token-stats",
|
|
85
|
+
face: "host",
|
|
86
|
+
schemas: [],
|
|
87
|
+
invocations: [
|
|
88
|
+
{
|
|
89
|
+
id: "dsh-token-stats#tokenStats/getStats",
|
|
90
|
+
service: "tokenStats",
|
|
91
|
+
namespace: "tokenStats",
|
|
92
|
+
method: "getStats",
|
|
93
|
+
invocation: { kind: "direct" },
|
|
94
|
+
parameters: [],
|
|
95
|
+
result: {
|
|
96
|
+
mode: "strict",
|
|
97
|
+
typeSymbol: "dsh-token-stats#TokenStatsResult",
|
|
98
|
+
schema: tokenStatsResultSchema,
|
|
99
|
+
},
|
|
100
|
+
sourceLocation: { file: "index.js", line: 1, column: 1 },
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
model: {
|
|
104
|
+
services: [
|
|
105
|
+
{
|
|
106
|
+
description:
|
|
107
|
+
"Token usage statistics service: aggregates per-model token consumption from the durable session log for the DeepSeek Harness web UI.",
|
|
108
|
+
summary: "Token usage statistics service.",
|
|
109
|
+
tags: [],
|
|
110
|
+
jsDoc: "/**\n * Token usage statistics service: per-day, per-model token aggregates.\n */",
|
|
111
|
+
key: "tokenStats",
|
|
112
|
+
exportName: "TokenStatsService",
|
|
113
|
+
members: [
|
|
114
|
+
{
|
|
115
|
+
kind: "method",
|
|
116
|
+
name: "getStats",
|
|
117
|
+
signature: "@Remote('getStats') async getStats(): Promise<TokenStatsResult>",
|
|
118
|
+
summary:
|
|
119
|
+
"Return the whole-dataset snapshot (last ~400 days, per-model per-day) for the Client charts.",
|
|
120
|
+
jsDoc:
|
|
121
|
+
"/**\n * Return the whole-dataset snapshot: ready/collecting state, backfill progress,\n * per-day per-model aggregates and the ordered model list.\n * @returns success or a business failure.\n */",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
types: [
|
|
125
|
+
{
|
|
126
|
+
name: "TokenStatsValue",
|
|
127
|
+
declaration:
|
|
128
|
+
"export interface TokenStatsValue {\n readonly ready: boolean;\n readonly collecting: boolean;\n readonly progress: { readonly done: number; readonly total: number };\n readonly error: string | null;\n readonly days: readonly { readonly date: string; readonly total: number; readonly models: readonly TokenStatsModelDay[] }[];\n readonly models: readonly TokenStatsModelRef[];\n}",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "TokenStatsModelDay",
|
|
132
|
+
declaration:
|
|
133
|
+
"export interface TokenStatsModelDay {\n readonly key: string;\n readonly name: string;\n readonly provider: string;\n readonly input: number;\n readonly output: number;\n readonly cacheRead: number;\n readonly cacheWrite: number;\n readonly reasoning: number;\n readonly total: number;\n}",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "TokenStatsModelRef",
|
|
137
|
+
declaration:
|
|
138
|
+
"export interface TokenStatsModelRef {\n readonly key: string;\n readonly name: string;\n readonly provider: string;\n}",
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "TokenStatsResult",
|
|
142
|
+
declaration:
|
|
143
|
+
"export type TokenStatsResult = { ok: true; value: TokenStatsValue } | { ok: false; error: { code: string; message?: string } };",
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
events: [],
|
|
149
|
+
objects: [],
|
|
150
|
+
},
|
|
151
|
+
};
|