@ychris12138/dsh-usage-stats 0.2.6

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/lib/usage.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * dsh-usage-stats — pure per-day, per-model token-usage aggregation over
3
+ * session event logs. Kept free of cordis imports so it can be unit-tested
4
+ * and validated against real logs outside the running harness.
5
+ *
6
+ * Aggregation semantics mirror `dsh-token-meter`'s `tokenUsage` projection:
7
+ * a usage sample rides an `assistant/chunk` (`data.chunk.type === "usage"`)
8
+ * or an `assistant/message` (`data.usage`); a repeated sample for the same
9
+ * (turn, step) REPLACES the earlier value instead of double counting it, and
10
+ * the replacement is re-attributed to the day of the later event.
11
+ *
12
+ * Each sample is additionally attributed to the model that produced it:
13
+ * `assistant/message` carries `data.message.source.model`; usage chunks fall
14
+ * back to the last `request/header` `data.header.config.model`; samples with
15
+ * no model information land in the `unknown` bucket.
16
+ *
17
+ * @module dsh-usage-stats/usage
18
+ */
19
+
20
+ /** Local-calendar `YYYY-MM-DD` key for a millisecond epoch. */
21
+ export function dayKey(timeMs) {
22
+ const date = new Date(timeMs);
23
+ const month = String(date.getMonth() + 1).padStart(2, "0");
24
+ const day = String(date.getDate()).padStart(2, "0");
25
+ return `${date.getFullYear()}-${month}-${day}`;
26
+ }
27
+
28
+ /** Empty token bucket. */
29
+ export function zeroBuckets() {
30
+ return {
31
+ inputTokens: 0,
32
+ outputTokens: 0,
33
+ cacheReadTokens: 0,
34
+ cacheWriteTokens: 0
35
+ };
36
+ }
37
+
38
+ /** Provider usage → buckets (missing cache fields are absent in some reports). */
39
+ export function bucketsOf(usage) {
40
+ return {
41
+ inputTokens: usage.inputTokens ?? 0,
42
+ outputTokens: usage.outputTokens ?? 0,
43
+ cacheReadTokens: usage.cacheReadTokens ?? 0,
44
+ cacheWriteTokens: usage.cacheWriteTokens ?? 0
45
+ };
46
+ }
47
+
48
+ /** Total tokens across all buckets. */
49
+ export function totalTokens(buckets) {
50
+ return buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens;
51
+ }
52
+
53
+ /**
54
+ * Prompt-side cache hit rate in percent (0–100, one decimal), or null when
55
+ * no prompt tokens were reported at all. Hits over the whole prompt side:
56
+ * cacheRead / (input + cacheRead + cacheWrite).
57
+ */
58
+ export function cacheHitRate(buckets) {
59
+ const input = buckets.inputTokens ?? 0;
60
+ const cacheRead = buckets.cacheReadTokens ?? 0;
61
+ const cacheWrite = buckets.cacheWriteTokens ?? 0;
62
+ const promptTokens = input + cacheRead + cacheWrite;
63
+ if (promptTokens <= 0) return null;
64
+ return Math.round((cacheRead / promptTokens) * 1000) / 10;
65
+ }
66
+
67
+ function addInto(target, source) {
68
+ target.inputTokens += source.inputTokens;
69
+ target.outputTokens += source.outputTokens;
70
+ target.cacheReadTokens += source.cacheReadTokens;
71
+ target.cacheWriteTokens += source.cacheWriteTokens;
72
+ return target;
73
+ }
74
+
75
+ function subtractFrom(target, source) {
76
+ target.inputTokens -= source.inputTokens;
77
+ target.outputTokens -= source.outputTokens;
78
+ target.cacheReadTokens -= source.cacheReadTokens;
79
+ target.cacheWriteTokens -= source.cacheWriteTokens;
80
+ return target;
81
+ }
82
+
83
+ /** Extract the usage sample an event carries, if any. */
84
+ function sampleOf(event) {
85
+ if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
86
+ return {
87
+ key: `${event.data.turn}:${event.data.step}`,
88
+ usage: event.data.chunk.usage
89
+ };
90
+ }
91
+ if (event.type === "assistant/message" && event.data?.usage !== void 0) {
92
+ return {
93
+ key: `${event.data.turn}:${event.data.step}`,
94
+ usage: event.data.usage
95
+ };
96
+ }
97
+ return void 0;
98
+ }
99
+
100
+ /**
101
+ * The `provider/model` attribution key of a usage sample: the exact provider
102
+ * route (dsh adapter id or pi-ai route) plus the model id, so the SAME model
103
+ * served by different providers stays distinct. `assistant/message` names
104
+ * its provider via `data.message.source`; usage chunks fall back to the last
105
+ * `request/header` `data.header.config`; samples with no model information
106
+ * land in the `unknown/unknown` bucket.
107
+ */
108
+ function modelOf(event) {
109
+ const source = event.data?.message?.source;
110
+ if (source !== void 0 && typeof source.model === "string") {
111
+ return `${typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown"}/${source.model}`;
112
+ }
113
+ const config = event.data?.header?.config;
114
+ if (config !== void 0 && typeof config.model === "string") {
115
+ return `${typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown"}/${config.model}`;
116
+ }
117
+ return void 0;
118
+ }
119
+
120
+ /** Day entry: totals plus a per-model bucket map. */
121
+ function entryOf(byDay, day) {
122
+ let entry = byDay.get(day);
123
+ if (entry === void 0) {
124
+ entry = {
125
+ totals: zeroBuckets(),
126
+ models: new Map()
127
+ };
128
+ byDay.set(day, entry);
129
+ }
130
+ return entry;
131
+ }
132
+
133
+ /**
134
+ * One session's incremental fold state. `days` holds the already-folded
135
+ * per-day entries; `lastSample`/`currentModel` let a later event slice keep
136
+ * the replace-last-sample semantics and model attribution across fold
137
+ * boundaries without replaying the whole log.
138
+ */
139
+ export function createUsageState() {
140
+ return {
141
+ days: new Map(),
142
+ lastSample: null,
143
+ currentModel: null,
144
+ consumed: 0
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Fold a slice of NEW events onto an existing session state (mutating).
150
+ * Replacements for the same (turn, step) subtract the previous sample's
151
+ * buckets from the day/model bucket they were attributed to, so a slice
152
+ * starting mid-step (e.g. a usage chunk at the tail of the previous fold)
153
+ * stays exact.
154
+ * @param state - session fold state (mutated in place).
155
+ * @param events - the new events, in seq order, starting after the last fold.
156
+ */
157
+ export function applyUsageDelta(state, events) {
158
+ let last = state.lastSample;
159
+ let currentModel = state.currentModel;
160
+ for (const event of events) {
161
+ if (event.type === "request/header") {
162
+ const model = modelOf(event);
163
+ if (model !== void 0) currentModel = model;
164
+ }
165
+ const sample = sampleOf(event);
166
+ if (sample === void 0) continue;
167
+ const buckets = bucketsOf(sample.usage);
168
+ const model = modelOf(event) ?? currentModel ?? "unknown/unknown";
169
+ const day = dayKey(event.time);
170
+ const entry = entryOf(state.days, day);
171
+ if (last !== null && last.key === sample.key) {
172
+ // Same turn/step re-reported: replace instead of double counting.
173
+ const previous = state.days.get(last.day);
174
+ if (previous !== void 0) {
175
+ subtractFrom(previous.totals, last.buckets);
176
+ const previousModel = previous.models.get(last.model);
177
+ if (previousModel !== void 0) subtractFrom(previousModel, last.buckets);
178
+ }
179
+ }
180
+ addInto(entry.totals, buckets);
181
+ let modelBucket = entry.models.get(model);
182
+ if (modelBucket === void 0) {
183
+ modelBucket = zeroBuckets();
184
+ entry.models.set(model, modelBucket);
185
+ }
186
+ addInto(modelBucket, buckets);
187
+ last = { key: sample.key, day, model, buckets };
188
+ }
189
+ state.lastSample = last;
190
+ state.currentModel = currentModel;
191
+ }
192
+
193
+ /**
194
+ * Fold one session's events into per-day, per-model token buckets.
195
+ * @param events - session event log in seq order.
196
+ * @returns Map<`YYYY-MM-DD`, { totals, models: Map<model, buckets> }> with
197
+ * only days that saw usage.
198
+ */
199
+ export function foldUsage(events) {
200
+ const state = createUsageState();
201
+ applyUsageDelta(state, events);
202
+ return state.days;
203
+ }
204
+
205
+ /**
206
+ * Merge one session's folded days into a global per-day map.
207
+ * @param byDay - global map to mutate.
208
+ * @param sessionDays - session day map (from foldUsage or a state).
209
+ */
210
+ export function mergeInto(byDay, sessionDays) {
211
+ for (const [day, entry] of sessionDays) {
212
+ const target = entryOf(byDay, day);
213
+ addInto(target.totals, entry.totals);
214
+ for (const [model, buckets] of entry.models) {
215
+ let modelBucket = target.models.get(model);
216
+ if (modelBucket === void 0) {
217
+ modelBucket = zeroBuckets();
218
+ target.models.set(model, modelBucket);
219
+ }
220
+ addInto(modelBucket, buckets);
221
+ }
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Merge one session fold into a global per-day map (convenience wrapper).
227
+ * @param byDay - global map to mutate.
228
+ * @param events - session events.
229
+ */
230
+ export function consumeEvents(byDay, events) {
231
+ mergeInto(byDay, foldUsage(events));
232
+ }
233
+
234
+ /**
235
+ * Render a global per-day map into the wire shape for the usage endpoint.
236
+ * @param byDay - day → entry map.
237
+ * @param updatedAt - computation timestamp.
238
+ * @returns `{ days, total, updatedAt }` with `days` sorted ascending; each
239
+ * day carries `models` (descending by tokens) and a `cacheHitRate` percent.
240
+ */
241
+ export function renderUsage(byDay, updatedAt) {
242
+ const days = [...byDay.entries()]
243
+ .map(([date, entry]) => {
244
+ const models = [...entry.models.entries()]
245
+ .map(([model, buckets]) => ({
246
+ model,
247
+ ...buckets,
248
+ tokens: totalTokens(buckets),
249
+ cacheHitRate: cacheHitRate(buckets)
250
+ }))
251
+ // All-zero buckets come from warmup requests that report
252
+ // {input:0, output:0}; rendering them produces empty "0 tokens"
253
+ // model rows (#23).
254
+ .filter((entry) => entry.tokens > 0)
255
+ .sort((a, b) => b.tokens - a.tokens);
256
+ return {
257
+ date,
258
+ ...entry.totals,
259
+ tokens: totalTokens(entry.totals),
260
+ cacheHitRate: cacheHitRate(entry.totals),
261
+ models
262
+ };
263
+ })
264
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
265
+ const total = zeroBuckets();
266
+ for (const [, entry] of byDay) addInto(total, entry.totals);
267
+ return {
268
+ days,
269
+ total: {
270
+ ...total,
271
+ tokens: totalTokens(total),
272
+ cacheHitRate: cacheHitRate(total)
273
+ },
274
+ updatedAt
275
+ };
276
+ }
package/package.json ADDED
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "@ychris12138/dsh-usage-stats",
3
+ "description": "Token usage heatmap, provider balances, and subscription quotas for the dsh web GUI",
4
+ "version": "0.2.6",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/Ychris12138/dsh-usage-stats.git"
8
+ },
9
+ "homepage": "https://github.com/Ychris12138/dsh-usage-stats#readme",
10
+ "bugs": "https://github.com/Ychris12138/dsh-usage-stats/issues",
11
+ "keywords": [
12
+ "deepseek",
13
+ "deepseek-harness",
14
+ "dsh",
15
+ "token-usage"
16
+ ],
17
+ "bin": {
18
+ "dsh-usage-stats-install": "scripts/install.mjs"
19
+ },
20
+ "files": [
21
+ "lib/",
22
+ "cordis.patch.yml",
23
+ "docs/images/usage-panel.svg",
24
+ "scripts/install.mjs",
25
+ "README.md",
26
+ "LICENSE",
27
+ "SECURITY.md"
28
+ ],
29
+ "type": "module",
30
+ "main": "lib/index.js",
31
+ "exports": {
32
+ ".": "./lib/index.js",
33
+ "./client": "./lib/client.js",
34
+ "./usage": "./lib/usage.js",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ },
41
+ "client": {
42
+ "platform": "web",
43
+ "inject": [
44
+ "@deepseek-ai/dsh-client-locale",
45
+ "@deepseek-ai/dsh-client-runtime",
46
+ "@deepseek-ai/dsh-client-ui-primitives"
47
+ ]
48
+ }
49
+ },
50
+ "scripts": {
51
+ "check": "node --check lib/index.js && node --check lib/usage.js && node --check lib/balance.js && node --check lib/subscriptions.js && node --check lib/accounts.js && node --check lib/client.js && node --check scripts/install.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-overlay-layering.mjs && node --check scripts/test-bundle.mjs && node --check scripts/test-install.mjs && node --check scripts/test-server.mjs && node --check scripts/test-balance.mjs && node --check scripts/test-subscriptions.mjs && node --check scripts/test-accounts.mjs && node --check scripts/validate-fold.mjs && node --check scripts/verify-raw.mjs && node --check scripts/check-balance.mjs",
52
+ "test": "npm run test:bundle && npm run test:client && npm run test:overlay && npm run test:server && npm run test:balance && npm run test:subscriptions && npm run test:accounts && npm run test:install",
53
+ "test:bundle": "node scripts/test-bundle.mjs",
54
+ "test:client": "node scripts/smoke-client.mjs",
55
+ "test:overlay": "node scripts/test-overlay-layering.mjs",
56
+ "test:install": "node scripts/test-install.mjs",
57
+ "test:server": "node scripts/test-server.mjs",
58
+ "test:balance": "node scripts/test-balance.mjs",
59
+ "test:subscriptions": "node scripts/test-subscriptions.mjs",
60
+ "test:accounts": "node scripts/test-accounts.mjs",
61
+ "validate:live": "node scripts/validate-fold.mjs && node scripts/verify-raw.mjs",
62
+ "prepublishOnly": "npm run check && npm test"
63
+ },
64
+ "devDependencies": {
65
+ "react": "^18.2.0",
66
+ "react-dom": "^18.2.0"
67
+ },
68
+ "license": "MIT",
69
+ "publishConfig": {
70
+ "access": "public"
71
+ }
72
+ }
@@ -0,0 +1,142 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const knownFlags = new Set(["--check", "--dry-run", "--no-enable", "--help"]);
9
+ const args = new Set(process.argv.slice(2));
10
+ for (const arg of args) {
11
+ if (!knownFlags.has(arg)) {
12
+ console.error(`Unknown option: ${arg}`);
13
+ process.exit(2);
14
+ }
15
+ }
16
+
17
+ if (args.has("--help")) {
18
+ console.log(`dsh-usage-stats installer
19
+
20
+ Usage:
21
+ npx --yes github:Ychris12138/dsh-usage-stats [options]
22
+
23
+ Options:
24
+ --check Verify the installed package and Cordis patch without changing them
25
+ --dry-run Print the resolved paths and planned changes
26
+ --no-enable Install files without editing cordis.patch.yml
27
+ --help Show this help
28
+
29
+ Set DSH_HOME to override the default ~/.dsh location.`);
30
+ process.exit(0);
31
+ }
32
+
33
+ const sourceRoot = dirname(dirname(fileURLToPath(import.meta.url)));
34
+ const sourcePackage = JSON.parse(await readFile(join(sourceRoot, "package.json"), "utf8"));
35
+ const dshHome = process.env.DSH_HOME ?? join(homedir(), ".dsh");
36
+ const target = join(dshHome, "profiles", "node_modules", "dsh-usage-stats");
37
+ const patchPath = join(dshHome, "profiles", "web", "cordis.patch.yml");
38
+ const pluginLine = /^\s+name:\s*dsh-usage-stats\s*$/gm;
39
+ const patchBlock = `# dsh-usage-stats: token usage heatmap + DeepSeek balance
40
+ - insert:
41
+ - id: usage-stats
42
+ name: dsh-usage-stats
43
+ `;
44
+ const emptySequenceRoot = /^\[\](?:[ \t]+#.*)?$/;
45
+
46
+ function meaningfulPatchLines(text) {
47
+ return String(text).split(/\r?\n/).map((line, index) => ({
48
+ index,
49
+ indent: line.match(/^[ \t]*/)?.[0].length ?? 0,
50
+ content: line.trim()
51
+ })).filter(({ content }) => content !== "" && !content.startsWith("#") && content !== "---" && content !== "...");
52
+ }
53
+
54
+ /** Remove a YAML document whose only value is the empty root sequence `[]`. */
55
+ function withoutEmptySequenceRoot(text) {
56
+ const meaningful = meaningfulPatchLines(text);
57
+ if (meaningful.length === 0) return text;
58
+ const rootIndent = Math.min(...meaningful.map(({ indent }) => indent));
59
+ const emptyRoot = meaningful.find(({ indent, content }) => indent === rootIndent && emptySequenceRoot.test(content));
60
+ if (emptyRoot === void 0) return text;
61
+ const lines = String(text).split(/\r?\n/);
62
+ const inlineComment = lines[emptyRoot.index].match(/^([ \t]*)\[\][ \t]+(#.*)$/);
63
+ if (inlineComment === null) lines.splice(emptyRoot.index, 1);
64
+ else lines[emptyRoot.index] = `${inlineComment[1]}${inlineComment[2]}`;
65
+ return lines.filter((line) => line.trim() !== "...").join("\n").trimEnd();
66
+ }
67
+
68
+ /** Detect the exact invalid shape produced by older installers: `[]` plus list entries. */
69
+ function assertNoEmptyRootConflict(text) {
70
+ const meaningful = meaningfulPatchLines(text);
71
+ if (meaningful.length < 2) return;
72
+ const rootIndent = Math.min(...meaningful.map(({ indent }) => indent));
73
+ const roots = meaningful.filter(({ indent }) => indent === rootIndent);
74
+ if (roots.some(({ content }) => emptySequenceRoot.test(content)) && roots.length > 1) {
75
+ throw new Error(`invalid YAML in ${patchPath}: empty root sequence [] cannot be combined with patch entries; rerun the installer to repair it`);
76
+ }
77
+ }
78
+
79
+ /** Preserve existing YAML/comments while adding exactly one plugin patch entry. */
80
+ function enablePluginInPatch(text) {
81
+ const base = withoutEmptySequenceRoot(text);
82
+ if ([...base.matchAll(pluginLine)].length > 0) return base;
83
+ return base.trim() === "" ? patchBlock : `${base.trimEnd()}\n\n${patchBlock}`;
84
+ }
85
+
86
+ async function readOptional(path) {
87
+ try {
88
+ return await readFile(path, "utf8");
89
+ } catch (error) {
90
+ if (error?.code === "ENOENT") return null;
91
+ throw error;
92
+ }
93
+ }
94
+
95
+ async function verify(expectEnabled) {
96
+ const installedRaw = await readOptional(join(target, "package.json"));
97
+ if (installedRaw === null) throw new Error(`package is not installed at ${target}`);
98
+ const installed = JSON.parse(installedRaw);
99
+ if (installed.name !== sourcePackage.name || installed.version !== sourcePackage.version) {
100
+ throw new Error(`installed package is ${installed.name ?? "unknown"}@${installed.version ?? "unknown"}; expected ${sourcePackage.name}@${sourcePackage.version}`);
101
+ }
102
+ if (expectEnabled) {
103
+ const patch = await readOptional(patchPath);
104
+ const count = patch === null ? 0 : [...patch.matchAll(pluginLine)].length;
105
+ if (count !== 1) throw new Error(`expected exactly one dsh-usage-stats entry in ${patchPath}; found ${count}`);
106
+ assertNoEmptyRootConflict(patch);
107
+ }
108
+ console.log(`Verified ${sourcePackage.name}@${sourcePackage.version}`);
109
+ console.log(` package: ${target}`);
110
+ if (expectEnabled) console.log(` patch: ${patchPath}`);
111
+ }
112
+
113
+ const enable = !args.has("--no-enable");
114
+ if (args.has("--dry-run")) {
115
+ console.log(`Would install ${sourcePackage.name}@${sourcePackage.version}`);
116
+ console.log(` package: ${target}`);
117
+ console.log(` patch: ${enable ? patchPath : "unchanged (--no-enable)"}`);
118
+ process.exit(0);
119
+ }
120
+
121
+ if (args.has("--check")) {
122
+ await verify(enable);
123
+ process.exit(0);
124
+ }
125
+
126
+ await mkdir(target, { recursive: true });
127
+ for (const entry of ["lib", "cordis.patch.yml", "package.json", "README.md", "LICENSE", "SECURITY.md"]) {
128
+ await cp(join(sourceRoot, entry), join(target, entry), { recursive: true, force: true });
129
+ }
130
+ await mkdir(join(target, "scripts"), { recursive: true });
131
+ await cp(fileURLToPath(import.meta.url), join(target, "scripts", "install.mjs"), { force: true });
132
+
133
+ if (enable) {
134
+ await mkdir(dirname(patchPath), { recursive: true });
135
+ const current = await readOptional(patchPath) ?? "";
136
+ const enabledPatch = enablePluginInPatch(current);
137
+ if (enabledPatch !== current) await writeFile(patchPath, enabledPatch, "utf8");
138
+ }
139
+
140
+ await verify(enable);
141
+ console.log("Installation complete. Restart dsh web, then hard-refresh the browser.");
142
+ console.log("Balance is optional: configure DEEPSEEK_API_KEY in <DSH_HOME>/.credentials.yaml.");