@kidli1412/dsh-token-heatmap 0.1.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 +64 -0
- package/cordis.patch.yml +12 -0
- package/docs//347/203/255/345/212/233/345/233/276.jpg +0 -0
- package/lib/client.js +908 -0
- package/lib/config.js +54 -0
- package/lib/index.js +474 -0
- package/lib/usage.js +251 -0
- package/package.json +60 -0
package/lib/usage.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-heatmap — 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
|
+
* (and the reference plugin dsh-usage-stats, MIT © Ychris12138): a usage
|
|
8
|
+
* sample rides an `assistant/chunk` (`data.chunk.type === "usage"`) or an
|
|
9
|
+
* `assistant/message` (`data.usage`); a repeated sample for the same
|
|
10
|
+
* (turn, step) REPLACES the earlier value instead of double counting it, and
|
|
11
|
+
* the replacement is re-attributed to the day of the later event.
|
|
12
|
+
*
|
|
13
|
+
* Each sample is additionally attributed to the model that produced it:
|
|
14
|
+
* `assistant/message` carries `data.message.source.model`; usage chunks fall
|
|
15
|
+
* back to the last `request/header` `data.header.config.model`; samples with
|
|
16
|
+
* no model information land in the `unknown/unknown` bucket.
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-token-heatmap/usage
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Local-calendar `YYYY-MM-DD` key for a millisecond epoch. */
|
|
22
|
+
export function dayKey(timeMs) {
|
|
23
|
+
const date = new Date(timeMs);
|
|
24
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
25
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
26
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Empty token bucket. */
|
|
30
|
+
export function zeroBuckets() {
|
|
31
|
+
return {
|
|
32
|
+
inputTokens: 0,
|
|
33
|
+
outputTokens: 0,
|
|
34
|
+
cacheReadTokens: 0,
|
|
35
|
+
cacheWriteTokens: 0
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Provider usage → buckets (missing cache fields are absent in some reports). */
|
|
40
|
+
export function bucketsOf(usage) {
|
|
41
|
+
return {
|
|
42
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
43
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
44
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
45
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Total tokens across all buckets. */
|
|
50
|
+
export function totalTokens(buckets) {
|
|
51
|
+
return buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Prompt-side cache hit rate in percent (0–100, one decimal), or null when no prompt tokens were reported. */
|
|
55
|
+
export function cacheHitRate(buckets) {
|
|
56
|
+
const input = buckets.inputTokens ?? 0;
|
|
57
|
+
const cacheRead = buckets.cacheReadTokens ?? 0;
|
|
58
|
+
const cacheWrite = buckets.cacheWriteTokens ?? 0;
|
|
59
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
60
|
+
if (promptTokens <= 0) return null;
|
|
61
|
+
return Math.round((cacheRead / promptTokens) * 1000) / 10;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addInto(target, source) {
|
|
65
|
+
target.inputTokens += source.inputTokens;
|
|
66
|
+
target.outputTokens += source.outputTokens;
|
|
67
|
+
target.cacheReadTokens += source.cacheReadTokens;
|
|
68
|
+
target.cacheWriteTokens += source.cacheWriteTokens;
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function subtractFrom(target, source) {
|
|
73
|
+
target.inputTokens -= source.inputTokens;
|
|
74
|
+
target.outputTokens -= source.outputTokens;
|
|
75
|
+
target.cacheReadTokens -= source.cacheReadTokens;
|
|
76
|
+
target.cacheWriteTokens -= source.cacheWriteTokens;
|
|
77
|
+
return target;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Extract the usage sample an event carries, if any. */
|
|
81
|
+
function sampleOf(event) {
|
|
82
|
+
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
83
|
+
return {
|
|
84
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
85
|
+
usage: event.data.chunk.usage
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (event.type === "assistant/message" && event.data?.usage !== void 0) {
|
|
89
|
+
return {
|
|
90
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
91
|
+
usage: event.data.usage
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
return void 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `provider/model` attribution key of a usage sample: the exact provider
|
|
99
|
+
* route plus the model id, so the SAME model served by different providers
|
|
100
|
+
* stays distinct. `assistant/message` names its provider via
|
|
101
|
+
* `data.message.source`; usage chunks fall back to the last `request/header`
|
|
102
|
+
* `data.header.config`; samples with no model information land in
|
|
103
|
+
* `unknown/unknown`.
|
|
104
|
+
*/
|
|
105
|
+
function modelOf(event) {
|
|
106
|
+
const source = event.data?.message?.source;
|
|
107
|
+
if (source !== void 0 && typeof source.model === "string") {
|
|
108
|
+
return `${typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown"}/${source.model}`;
|
|
109
|
+
}
|
|
110
|
+
const config = event.data?.header?.config;
|
|
111
|
+
if (config !== void 0 && typeof config.model === "string") {
|
|
112
|
+
return `${typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown"}/${config.model}`;
|
|
113
|
+
}
|
|
114
|
+
return void 0;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Day entry: totals plus a per-model bucket map. */
|
|
118
|
+
function entryOf(byDay, day) {
|
|
119
|
+
let entry = byDay.get(day);
|
|
120
|
+
if (entry === void 0) {
|
|
121
|
+
entry = {
|
|
122
|
+
totals: zeroBuckets(),
|
|
123
|
+
models: new Map()
|
|
124
|
+
};
|
|
125
|
+
byDay.set(day, entry);
|
|
126
|
+
}
|
|
127
|
+
return entry;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* One session's incremental fold state. `days` holds the already-folded
|
|
132
|
+
* per-day entries; `lastSample`/`currentModel` let a later event slice keep
|
|
133
|
+
* the replace-last-sample semantics and model attribution across fold
|
|
134
|
+
* boundaries without replaying the whole log.
|
|
135
|
+
*/
|
|
136
|
+
export function createUsageState() {
|
|
137
|
+
return {
|
|
138
|
+
days: new Map(),
|
|
139
|
+
lastSample: null,
|
|
140
|
+
currentModel: null,
|
|
141
|
+
consumed: 0
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Fold a slice of NEW events onto an existing session state (mutating).
|
|
147
|
+
* Replacements for the same (turn, step) subtract the previous sample's
|
|
148
|
+
* buckets from the day/model bucket they were attributed to, so a slice
|
|
149
|
+
* starting mid-step (e.g. a usage chunk at the tail of the previous fold)
|
|
150
|
+
* stays exact.
|
|
151
|
+
* @param state - session fold state (mutated in place).
|
|
152
|
+
* @param events - the new events, in seq order, starting after the last fold.
|
|
153
|
+
*/
|
|
154
|
+
export function applyUsageDelta(state, events) {
|
|
155
|
+
let last = state.lastSample;
|
|
156
|
+
let currentModel = state.currentModel;
|
|
157
|
+
for (const event of events) {
|
|
158
|
+
if (event.type === "request/header") {
|
|
159
|
+
const model = modelOf(event);
|
|
160
|
+
if (model !== void 0) currentModel = model;
|
|
161
|
+
}
|
|
162
|
+
const sample = sampleOf(event);
|
|
163
|
+
if (sample === void 0) continue;
|
|
164
|
+
const buckets = bucketsOf(sample.usage);
|
|
165
|
+
const model = modelOf(event) ?? currentModel ?? "unknown/unknown";
|
|
166
|
+
const day = dayKey(event.time);
|
|
167
|
+
const entry = entryOf(state.days, day);
|
|
168
|
+
if (last !== null && last.key === sample.key) {
|
|
169
|
+
// Same turn/step re-reported: replace instead of double counting.
|
|
170
|
+
const previous = state.days.get(last.day);
|
|
171
|
+
if (previous !== void 0) {
|
|
172
|
+
subtractFrom(previous.totals, last.buckets);
|
|
173
|
+
const previousModel = previous.models.get(last.model);
|
|
174
|
+
if (previousModel !== void 0) subtractFrom(previousModel, last.buckets);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
addInto(entry.totals, buckets);
|
|
178
|
+
let modelBucket = entry.models.get(model);
|
|
179
|
+
if (modelBucket === void 0) {
|
|
180
|
+
modelBucket = zeroBuckets();
|
|
181
|
+
entry.models.set(model, modelBucket);
|
|
182
|
+
}
|
|
183
|
+
addInto(modelBucket, buckets);
|
|
184
|
+
last = { key: sample.key, day, model, buckets };
|
|
185
|
+
}
|
|
186
|
+
state.lastSample = last;
|
|
187
|
+
state.currentModel = currentModel;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Fold one session's events into per-day, per-model token buckets.
|
|
192
|
+
* @param events - session event log in seq order.
|
|
193
|
+
* @returns Map<`YYYY-MM-DD`, { totals, models: Map<model, buckets> }> with
|
|
194
|
+
* only days that saw usage.
|
|
195
|
+
*/
|
|
196
|
+
export function foldUsage(events) {
|
|
197
|
+
const state = createUsageState();
|
|
198
|
+
applyUsageDelta(state, events);
|
|
199
|
+
return state.days;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Merge one session's folded days into a global per-day map.
|
|
204
|
+
* @param byDay - global map to mutate.
|
|
205
|
+
* @param sessionDays - session day map (from foldUsage or a state).
|
|
206
|
+
*/
|
|
207
|
+
export function mergeInto(byDay, sessionDays) {
|
|
208
|
+
for (const [day, entry] of sessionDays) {
|
|
209
|
+
const target = entryOf(byDay, day);
|
|
210
|
+
addInto(target.totals, entry.totals);
|
|
211
|
+
for (const [model, buckets] of entry.models) {
|
|
212
|
+
let modelBucket = target.models.get(model);
|
|
213
|
+
if (modelBucket === void 0) {
|
|
214
|
+
modelBucket = zeroBuckets();
|
|
215
|
+
target.models.set(model, modelBucket);
|
|
216
|
+
}
|
|
217
|
+
addInto(modelBucket, buckets);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Render a global per-day map into the wire shape for the usage endpoint.
|
|
224
|
+
* @param byDay - day → entry map.
|
|
225
|
+
* @param updatedAt - computation timestamp.
|
|
226
|
+
* @returns `{ days, total, updatedAt }` with `days` sorted ascending; each
|
|
227
|
+
* day carries `models` (descending by tokens) and a `cacheHitRate` percent.
|
|
228
|
+
*/
|
|
229
|
+
export function renderUsage(byDay, updatedAt) {
|
|
230
|
+
const days = [...byDay.entries()]
|
|
231
|
+
.map(([date, entry]) => {
|
|
232
|
+
const models = [...entry.models.entries()]
|
|
233
|
+
.map(([model, buckets]) => ({
|
|
234
|
+
model,
|
|
235
|
+
...buckets,
|
|
236
|
+
tokens: totalTokens(buckets),
|
|
237
|
+
cacheHitRate: cacheHitRate(buckets)
|
|
238
|
+
}))
|
|
239
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
240
|
+
return {
|
|
241
|
+
date,
|
|
242
|
+
...entry.totals,
|
|
243
|
+
tokens: totalTokens(entry.totals),
|
|
244
|
+
cacheHitRate: cacheHitRate(entry.totals),
|
|
245
|
+
models
|
|
246
|
+
};
|
|
247
|
+
})
|
|
248
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
249
|
+
const total = days.reduce((sum, day) => sum + day.tokens, 0);
|
|
250
|
+
return { days, total, updatedAt };
|
|
251
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kidli1412/dsh-token-heatmap",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DSH web plugin: GitHub-style daily token-usage heatmap on the new-session screen with a selectable calendar-year view, green/blue color schemes and a display switch (设置 → 插件 → 插件配置), plus today / this-month / all-time totals.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"docs",
|
|
15
|
+
"cordis.patch.yml",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"dsh": {
|
|
20
|
+
"bundle": {
|
|
21
|
+
"patch": "./cordis.patch.yml"
|
|
22
|
+
},
|
|
23
|
+
"client": {
|
|
24
|
+
"platform": "web",
|
|
25
|
+
"inject": [
|
|
26
|
+
"@deepseek-ai/dsh-client-locale",
|
|
27
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
28
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"check": "node --check lib/usage.js && node --check lib/config.js && node --check lib/index.js && node --check lib/client.js",
|
|
34
|
+
"test": "node scripts/smoke.mjs",
|
|
35
|
+
"prepublishOnly": "npm run check && npm test"
|
|
36
|
+
},
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
40
|
+
},
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "git+https://github.com/KIDLi1412/dsh-token-heatmap.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/KIDLi1412/dsh-token-heatmap#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/KIDLi1412/dsh-token-heatmap/issues"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"dsh",
|
|
54
|
+
"deepseek-harness",
|
|
55
|
+
"plugin",
|
|
56
|
+
"token",
|
|
57
|
+
"usage",
|
|
58
|
+
"heatmap"
|
|
59
|
+
]
|
|
60
|
+
}
|