dsh-token-chart 0.0.0-stage → 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 +200 -2
- package/cordis.patch.yml +7 -0
- package/icon.svg +7 -0
- package/lib/buckets.js +115 -0
- package/lib/chunk/echarts.entry.js +25 -0
- package/lib/client.echarts.js +58 -0
- package/lib/client.js +1743 -0
- package/lib/constants.js +22 -0
- package/lib/index.js +640 -0
- package/lib/paths.js +24 -0
- package/lib/prefs.js +54 -0
- package/lib/store.js +273 -0
- package/lib/token-chart.css +568 -0
- package/lib/types/buckets.d.ts +24 -0
- package/lib/types/chunk/echarts.entry.d.ts +11 -0
- package/lib/types/constants.d.ts +22 -0
- package/lib/types/index.d.ts +8 -0
- package/lib/types/paths.d.ts +3 -0
- package/lib/types/prefs.d.ts +8 -0
- package/lib/types/store.d.ts +72 -0
- package/lib/types/types.d.ts +230 -0
- package/lib/types/update.d.ts +24 -0
- package/lib/types.js +10 -0
- package/lib/update.js +110 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +110 -4
- package/src/buckets.ts +120 -0
- package/src/chunk/echarts.entry.ts +27 -0
- package/src/client/api.ts +126 -0
- package/src/client/charts.tsx +255 -0
- package/src/client/chrome.tsx +127 -0
- package/src/client/contract.ts +62 -0
- package/src/client/drawer.tsx +113 -0
- package/src/client/format.ts +36 -0
- package/src/client/globals.d.ts +21 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +164 -0
- package/src/client/models.ts +76 -0
- package/src/client/mount.tsx +312 -0
- package/src/client/panel.tsx +152 -0
- package/src/client/pill.ts +21 -0
- package/src/client/react.ts +12 -0
- package/src/client/series.tsx +140 -0
- package/src/client/settings.tsx +347 -0
- package/src/client/styles/components.less +191 -0
- package/src/client/styles/index.less +7 -0
- package/src/client/styles/tailwind.css +21 -0
- package/src/client/styles/tokens.less +25 -0
- package/src/client/table.tsx +153 -0
- package/src/client/ui.tsx +87 -0
- package/src/constants.ts +26 -0
- package/src/index.ts +673 -0
- package/src/paths.ts +28 -0
- package/src/prefs.ts +61 -0
- package/src/store.ts +295 -0
- package/src/types.ts +216 -0
- package/src/update.ts +106 -0
package/lib/prefs.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preference defaults, shared by the Host half and the browser half.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-token-chart/prefs
|
|
5
|
+
*/
|
|
6
|
+
import { CHART_IDS, METRIC_IDS } from './constants.js';
|
|
7
|
+
/** Preference defaults — the single definition shared by Host and browser halves. */
|
|
8
|
+
export const DEFAULT_PREFS = {
|
|
9
|
+
/** Session drawer chart: bar | line | pie | table | multi-bar | multi-line | bar-line */
|
|
10
|
+
sessionChart: 'multi-bar',
|
|
11
|
+
/** Daily panel chart, same vocabulary. */
|
|
12
|
+
dailyChart: 'multi-bar',
|
|
13
|
+
/** Metrics plotted in the session drawer. */
|
|
14
|
+
sessionMetrics: ['input', 'output', 'cache', 'total'],
|
|
15
|
+
/** Metrics plotted in the daily panel. */
|
|
16
|
+
dailyMetrics: ['input', 'output', 'cache', 'total'],
|
|
17
|
+
/** Also render the data table under the chart. */
|
|
18
|
+
showTable: true,
|
|
19
|
+
/** Cache-hit-rate line in the combined chart. */
|
|
20
|
+
showCacheHitRate: true,
|
|
21
|
+
/** Clicking the product's own "用量" pill opens the drawer instead of its dialog. */
|
|
22
|
+
interceptUsageButton: true,
|
|
23
|
+
};
|
|
24
|
+
/** Narrow one chart id, falling back to the default. */
|
|
25
|
+
function chartOf(value, fallback) {
|
|
26
|
+
return typeof value === 'string' && CHART_IDS.includes(value) ? value : fallback;
|
|
27
|
+
}
|
|
28
|
+
/** Narrow the metric list: unknown ids are dropped, an empty list falls back. */
|
|
29
|
+
function metricsOf(value, fallback) {
|
|
30
|
+
const list = Array.isArray(value)
|
|
31
|
+
? value.filter((item) => typeof item === 'string' && METRIC_IDS.includes(item))
|
|
32
|
+
: [];
|
|
33
|
+
return list.length > 0 ? [...new Set(list)] : [...fallback];
|
|
34
|
+
}
|
|
35
|
+
/** Narrow one boolean flag. */
|
|
36
|
+
function flagOf(value, fallback) {
|
|
37
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Coerce one stored preference document into the accepted shape.
|
|
41
|
+
* @param raw - whatever `prefs.json` (or a client patch) held.
|
|
42
|
+
*/
|
|
43
|
+
export function normalizePrefs(raw) {
|
|
44
|
+
const source = (raw !== null && typeof raw === 'object' ? raw : {});
|
|
45
|
+
return {
|
|
46
|
+
sessionChart: chartOf(source.sessionChart, DEFAULT_PREFS.sessionChart),
|
|
47
|
+
dailyChart: chartOf(source.dailyChart, DEFAULT_PREFS.dailyChart),
|
|
48
|
+
sessionMetrics: metricsOf(source.sessionMetrics, DEFAULT_PREFS.sessionMetrics),
|
|
49
|
+
dailyMetrics: metricsOf(source.dailyMetrics, DEFAULT_PREFS.dailyMetrics),
|
|
50
|
+
showTable: flagOf(source.showTable, DEFAULT_PREFS.showTable),
|
|
51
|
+
showCacheHitRate: flagOf(source.showCacheHitRate, DEFAULT_PREFS.showCacheHitRate),
|
|
52
|
+
interceptUsageButton: flagOf(source.interceptUsageButton, DEFAULT_PREFS.interceptUsageButton),
|
|
53
|
+
};
|
|
54
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rolling usage store: session → turn → bucket totals, with one slot per
|
|
3
|
+
* settled (turn, step, attempt) so the fold is idempotent.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-token-chart/store
|
|
6
|
+
*/
|
|
7
|
+
import { RETENTION_DAYS, STORE_VERSION } from './constants.js';
|
|
8
|
+
import { addInto, bucketsEqual, bucketsFrom, isCount, subtract, startOfLocalDay, usageOf, zeroBuckets, } from './buckets.js';
|
|
9
|
+
export class UsageStore {
|
|
10
|
+
/** Session id → its turns. */
|
|
11
|
+
sessions = new Map();
|
|
12
|
+
/** One session record, created on demand. */
|
|
13
|
+
session(sessionId) {
|
|
14
|
+
let record = this.sessions.get(sessionId);
|
|
15
|
+
if (record === undefined) {
|
|
16
|
+
record = { turns: new Map() };
|
|
17
|
+
this.sessions.set(sessionId, record);
|
|
18
|
+
}
|
|
19
|
+
return record;
|
|
20
|
+
}
|
|
21
|
+
#turn(sessionId, turn, time) {
|
|
22
|
+
const session = this.session(sessionId);
|
|
23
|
+
let record = session.turns.get(turn);
|
|
24
|
+
if (record === undefined) {
|
|
25
|
+
record = {
|
|
26
|
+
turn,
|
|
27
|
+
time,
|
|
28
|
+
lastTime: time,
|
|
29
|
+
// One slot per settled (step, attempt): folded in seq order the store is
|
|
30
|
+
// a pure function of the log, so replaying a session (backfill after a
|
|
31
|
+
// reload, a second read of the same log) can never add the same
|
|
32
|
+
// settlement twice.
|
|
33
|
+
slots: new Map(),
|
|
34
|
+
// Step → seq of the `llm/retry-started` that opened its current
|
|
35
|
+
// attempt. Using the event seq rather than a counter keeps the slot
|
|
36
|
+
// keys identical when the same log is folded again.
|
|
37
|
+
attempts: new Map(),
|
|
38
|
+
totals: zeroBuckets(),
|
|
39
|
+
routes: [],
|
|
40
|
+
};
|
|
41
|
+
session.turns.set(turn, record);
|
|
42
|
+
}
|
|
43
|
+
return record;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Fold one committed session event into the store.
|
|
47
|
+
*
|
|
48
|
+
* Mirrors `@deepseek-ai/dsh-token-meter`'s `tokenUsage` projection — each
|
|
49
|
+
* settlement contributes the last usage sample in its stream, a resample of
|
|
50
|
+
* the same slot replaces rather than adds, and `llm/retry-started` opens a new
|
|
51
|
+
* slot so a retried attempt bills again — but stores one bucket set per
|
|
52
|
+
* `(step, attempt)` instead of a single running slot. That difference is what
|
|
53
|
+
* makes the fold idempotent: folding the same log twice yields the same
|
|
54
|
+
* totals, which the product's single-slot fold does not survive.
|
|
55
|
+
* @param sessionId - the session the event was committed to.
|
|
56
|
+
* @param event - one durable session event.
|
|
57
|
+
* @returns whether the store changed.
|
|
58
|
+
*/
|
|
59
|
+
apply(sessionId, event) {
|
|
60
|
+
if (event === null || typeof event !== 'object')
|
|
61
|
+
return false;
|
|
62
|
+
const data = event.data;
|
|
63
|
+
if (data === null || typeof data !== 'object')
|
|
64
|
+
return false;
|
|
65
|
+
if (event.type === 'llm/retry-started' || event.type === 'llm/retry') {
|
|
66
|
+
const turn = data.turn;
|
|
67
|
+
const step = data.step;
|
|
68
|
+
if (!isCount(turn) || !isCount(step))
|
|
69
|
+
return false;
|
|
70
|
+
const record = this.sessions.get(sessionId)?.turns.get(turn);
|
|
71
|
+
if (record === undefined)
|
|
72
|
+
return false;
|
|
73
|
+
const seq = isCount(event.seq) ? event.seq : 0;
|
|
74
|
+
if ((record.attempts.get(step) ?? -1) === seq)
|
|
75
|
+
return false;
|
|
76
|
+
record.attempts.set(step, seq);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
if (event.type !== 'assistant/message' && event.type !== 'assistant/attempt')
|
|
80
|
+
return false;
|
|
81
|
+
const usage = usageOf(event);
|
|
82
|
+
if (usage === undefined)
|
|
83
|
+
return false;
|
|
84
|
+
const buckets = bucketsFrom(usage);
|
|
85
|
+
if (buckets === undefined)
|
|
86
|
+
return false;
|
|
87
|
+
const turn = data.turn;
|
|
88
|
+
const step = data.step;
|
|
89
|
+
if (!isCount(turn) || !isCount(step))
|
|
90
|
+
return false;
|
|
91
|
+
const time = isCount(event.time) ? event.time : Date.now();
|
|
92
|
+
const record = this.#turn(sessionId, turn, time);
|
|
93
|
+
const slot = `${step}#${record.attempts.get(step) ?? 0}`;
|
|
94
|
+
const previous = record.slots.get(slot) ?? null;
|
|
95
|
+
if (previous !== null && bucketsEqual(previous, buckets)) {
|
|
96
|
+
record.lastTime = Math.max(record.lastTime, time);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (previous !== null)
|
|
100
|
+
subtract(record.totals, previous);
|
|
101
|
+
addInto(record.totals, buckets);
|
|
102
|
+
record.slots.set(slot, buckets);
|
|
103
|
+
record.time = Math.min(record.time, time);
|
|
104
|
+
record.lastTime = Math.max(record.lastTime, time);
|
|
105
|
+
const source = data.message?.source;
|
|
106
|
+
if (source !== undefined && typeof source.provider === 'string' && typeof source.model === 'string') {
|
|
107
|
+
const route = `${source.provider}/${source.model}`;
|
|
108
|
+
if (!record.routes.includes(route))
|
|
109
|
+
record.routes.push(route);
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The session's whole-window totals, folded from the per-slot buckets.
|
|
115
|
+
*
|
|
116
|
+
* This is deliberately not "the sum of the turn rows a reader sees": it is
|
|
117
|
+
* one fold over every settled (turn, step, attempt) of the session, which is
|
|
118
|
+
* the same rule the product's own `tokenUsage` projection applies. The two
|
|
119
|
+
* agree by construction, but the intent — and any future change — belongs
|
|
120
|
+
* here, not in the presentation layer.
|
|
121
|
+
* @param sessionId - the session to total.
|
|
122
|
+
*/
|
|
123
|
+
sessionTotals(sessionId) {
|
|
124
|
+
const totals = zeroBuckets();
|
|
125
|
+
const session = this.sessions.get(sessionId);
|
|
126
|
+
if (session === undefined)
|
|
127
|
+
return totals;
|
|
128
|
+
for (const record of session.turns.values()) {
|
|
129
|
+
for (const buckets of record.slots.values())
|
|
130
|
+
addInto(totals, buckets);
|
|
131
|
+
}
|
|
132
|
+
return totals;
|
|
133
|
+
}
|
|
134
|
+
/** Drop turns older than the retention window and empty sessions. */
|
|
135
|
+
prune(now = Date.now()) {
|
|
136
|
+
const floor = startOfLocalDay(RETENTION_DAYS - 1);
|
|
137
|
+
let changed = false;
|
|
138
|
+
for (const [sessionId, session] of this.sessions) {
|
|
139
|
+
for (const [turn, record] of session.turns) {
|
|
140
|
+
if (record.lastTime < floor) {
|
|
141
|
+
session.turns.delete(turn);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (session.turns.size === 0 && floor > 0) {
|
|
146
|
+
this.sessions.delete(sessionId);
|
|
147
|
+
changed = true;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
void now;
|
|
151
|
+
return changed;
|
|
152
|
+
}
|
|
153
|
+
/** Every turn of every session inside the retention window, oldest first. */
|
|
154
|
+
turns() {
|
|
155
|
+
const rows = [];
|
|
156
|
+
for (const [sessionId, session] of this.sessions) {
|
|
157
|
+
for (const record of session.turns.values()) {
|
|
158
|
+
rows.push({
|
|
159
|
+
sessionId,
|
|
160
|
+
turn: record.turn,
|
|
161
|
+
time: record.time,
|
|
162
|
+
lastTime: record.lastTime,
|
|
163
|
+
totals: { ...record.totals },
|
|
164
|
+
routes: [...record.routes],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
rows.sort((left, right) => left.time - right.time || left.turn - right.turn);
|
|
169
|
+
return rows;
|
|
170
|
+
}
|
|
171
|
+
/** Per-turn rows of one session, ordered by turn. */
|
|
172
|
+
sessionTurns(sessionId) {
|
|
173
|
+
const session = this.sessions.get(sessionId);
|
|
174
|
+
if (session === undefined)
|
|
175
|
+
return [];
|
|
176
|
+
return [...session.turns.values()]
|
|
177
|
+
.sort((left, right) => left.turn - right.turn)
|
|
178
|
+
.map((record) => ({
|
|
179
|
+
sessionId,
|
|
180
|
+
turn: record.turn,
|
|
181
|
+
time: record.time,
|
|
182
|
+
lastTime: record.lastTime,
|
|
183
|
+
totals: { ...record.totals },
|
|
184
|
+
routes: [...record.routes],
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
hasSession(sessionId) {
|
|
188
|
+
return this.sessions.has(sessionId);
|
|
189
|
+
}
|
|
190
|
+
/** Serialize the retention window to plain JSON. */
|
|
191
|
+
toJSON() {
|
|
192
|
+
const sessions = [];
|
|
193
|
+
for (const [id, session] of this.sessions) {
|
|
194
|
+
if (session.turns.size === 0)
|
|
195
|
+
continue;
|
|
196
|
+
sessions.push({
|
|
197
|
+
id,
|
|
198
|
+
turns: [...session.turns.values()]
|
|
199
|
+
.sort((left, right) => left.turn - right.turn)
|
|
200
|
+
.map((record) => ({
|
|
201
|
+
turn: record.turn,
|
|
202
|
+
time: record.time,
|
|
203
|
+
lastTime: record.lastTime,
|
|
204
|
+
// The per-slot buckets are the source of truth; `totals` is kept for
|
|
205
|
+
// readability and is recomputed on load.
|
|
206
|
+
slots: [...record.slots.entries()].map(([slot, buckets]) => [slot, { ...buckets }]),
|
|
207
|
+
attempts: [...record.attempts.entries()],
|
|
208
|
+
routes: [...record.routes],
|
|
209
|
+
})),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return { version: STORE_VERSION, savedAt: Date.now(), retentionDays: RETENTION_DAYS, sessions };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Restore a serialized store, dropping anything outside the window.
|
|
216
|
+
*
|
|
217
|
+
* A document from an older schema is discarded rather than interpreted: the
|
|
218
|
+
* activation-time backfill rebuilds it from the session logs, and guessing at
|
|
219
|
+
* a superseded layout is exactly how a total drifts.
|
|
220
|
+
* @param document - the parsed store file.
|
|
221
|
+
* @returns whether the document was accepted.
|
|
222
|
+
*/
|
|
223
|
+
load(document) {
|
|
224
|
+
if (document === null || typeof document !== 'object')
|
|
225
|
+
return false;
|
|
226
|
+
const candidate = document;
|
|
227
|
+
if (!Array.isArray(candidate.sessions))
|
|
228
|
+
return false;
|
|
229
|
+
if (candidate.version !== STORE_VERSION)
|
|
230
|
+
return false;
|
|
231
|
+
for (const entry of candidate.sessions) {
|
|
232
|
+
if (entry === null || typeof entry !== 'object' || typeof entry.id !== 'string')
|
|
233
|
+
continue;
|
|
234
|
+
if (!Array.isArray(entry.turns))
|
|
235
|
+
continue;
|
|
236
|
+
const session = this.session(entry.id);
|
|
237
|
+
for (const turn of entry.turns) {
|
|
238
|
+
if (turn === null || typeof turn !== 'object')
|
|
239
|
+
continue;
|
|
240
|
+
if (!isCount(turn.turn) || !isCount(turn.time))
|
|
241
|
+
continue;
|
|
242
|
+
const slots = new Map();
|
|
243
|
+
const totals = zeroBuckets();
|
|
244
|
+
for (const pair of Array.isArray(turn.slots) ? turn.slots : []) {
|
|
245
|
+
if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== 'string')
|
|
246
|
+
continue;
|
|
247
|
+
const buckets = bucketsFrom({
|
|
248
|
+
inputTokens: pair[1]?.input,
|
|
249
|
+
outputTokens: pair[1]?.output,
|
|
250
|
+
cacheReadTokens: pair[1]?.cacheRead,
|
|
251
|
+
cacheWriteTokens: pair[1]?.cacheWrite,
|
|
252
|
+
});
|
|
253
|
+
if (buckets === undefined)
|
|
254
|
+
continue;
|
|
255
|
+
slots.set(pair[0], buckets);
|
|
256
|
+
addInto(totals, buckets);
|
|
257
|
+
}
|
|
258
|
+
session.turns.set(turn.turn, {
|
|
259
|
+
turn: turn.turn,
|
|
260
|
+
time: turn.time,
|
|
261
|
+
lastTime: isCount(turn.lastTime) ? turn.lastTime : turn.time,
|
|
262
|
+
slots,
|
|
263
|
+
attempts: new Map((Array.isArray(turn.attempts) ? turn.attempts : []).filter((pair) => Array.isArray(pair) && pair.length === 2 && isCount(pair[0]) && isCount(pair[1]))),
|
|
264
|
+
totals,
|
|
265
|
+
routes: Array.isArray(turn.routes) ? turn.routes.filter((route) => typeof route === 'string') : [],
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
this.prune();
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/* ------------------------------------------------------------------- prefs */
|