@shi-yangyang/dsh-token-dashboard 0.0.0-stage → 0.2.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.en.md +166 -0
- package/README.md +165 -2
- package/THIRD_PARTY_NOTICES.md +25 -0
- package/client/client.js +22897 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +13 -0
- package/lib/host/aggregate.d.ts +61 -0
- package/lib/host/aggregate.js +472 -0
- package/lib/host/fold.d.ts +103 -0
- package/lib/host/fold.js +375 -0
- package/lib/host/index.d.ts +24 -0
- package/lib/host/index.js +116 -0
- package/lib/host/prices.d.ts +42 -0
- package/lib/host/prices.js +118 -0
- package/lib/host/server.d.ts +30 -0
- package/lib/host/server.js +177 -0
- package/lib/host/source.d.ts +37 -0
- package/lib/host/source.js +179 -0
- package/lib/host/types.d.ts +133 -0
- package/lib/host/types.js +9 -0
- package/lib/host/verify.d.ts +14 -0
- package/lib/host/verify.js +79 -0
- package/lib/shared/builtin-prices.d.ts +27 -0
- package/lib/shared/builtin-prices.generated.d.ts +7 -0
- package/lib/shared/builtin-prices.generated.js +1433 -0
- package/lib/shared/builtin-prices.js +111 -0
- package/lib/shared/dates.d.ts +23 -0
- package/lib/shared/dates.js +79 -0
- package/lib/shared/export.d.ts +13 -0
- package/lib/shared/export.js +113 -0
- package/lib/shared/format.d.ts +23 -0
- package/lib/shared/format.js +108 -0
- package/lib/shared/model.d.ts +37 -0
- package/lib/shared/model.js +87 -0
- package/lib/shared/pricing.d.ts +70 -0
- package/lib/shared/pricing.js +192 -0
- package/lib/shared/range.d.ts +34 -0
- package/lib/shared/range.js +93 -0
- package/lib/shared/types.d.ts +168 -0
- package/lib/shared/types.js +10 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +93 -4
package/lib/host/fold.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure token fold.
|
|
3
|
+
*
|
|
4
|
+
* It mirrors the official `tokenUsage` projection from
|
|
5
|
+
* `@deepseek-ai/dsh-token-meter`: the four provider buckets
|
|
6
|
+
* (`inputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `outputTokens`) are
|
|
7
|
+
* disjoint, a later sample for the same (turn, step) replaces the previous
|
|
8
|
+
* sample instead of adding to it, and `llm/retry-started` closes that
|
|
9
|
+
* replacement scope so the retried attempt is billed separately. It also
|
|
10
|
+
* attributes each sample to the provider/model route the log records, buckets
|
|
11
|
+
* usage by local day and hour, and derives the session title.
|
|
12
|
+
*
|
|
13
|
+
* Events inherited by a seeded (forked) session are folded into `allTotals`
|
|
14
|
+
* for diagnostics but excluded from the session's own totals and from every
|
|
15
|
+
* global aggregate, so a forked prefix is never counted twice.
|
|
16
|
+
*/
|
|
17
|
+
import { dayKey, hourKey } from '../shared/dates.js';
|
|
18
|
+
import { addBuckets, cacheHitRate, emptyBuckets, modelKeyOf, subtractBuckets, totalTokens, } from '../shared/model.js';
|
|
19
|
+
const DEFAULT_KEEP_HOURS_MS = 48 * 60 * 60 * 1000;
|
|
20
|
+
/** Create an empty aggregate for one session. */
|
|
21
|
+
export function createAggregate(header, inheritedEventCount) {
|
|
22
|
+
const createdAt = finiteNumber(header.createdAt) ?? Date.now();
|
|
23
|
+
return {
|
|
24
|
+
totals: emptyBuckets(),
|
|
25
|
+
allTotals: emptyBuckets(),
|
|
26
|
+
requests: 0,
|
|
27
|
+
allRequests: 0,
|
|
28
|
+
models: {},
|
|
29
|
+
days: {},
|
|
30
|
+
hours: {},
|
|
31
|
+
title: null,
|
|
32
|
+
lastActivity: createdAt,
|
|
33
|
+
createdAt,
|
|
34
|
+
processed: 0,
|
|
35
|
+
inheritedEventCount: Math.max(0, Math.trunc(inheritedEventCount)),
|
|
36
|
+
requestSamples: [],
|
|
37
|
+
carry: {
|
|
38
|
+
lastSeq: -1,
|
|
39
|
+
own: null,
|
|
40
|
+
all: null,
|
|
41
|
+
currentProvider: '',
|
|
42
|
+
currentModel: '',
|
|
43
|
+
stepStart: null,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** Fold additional contiguous events into one aggregate in place. */
|
|
48
|
+
export function foldInto(aggregate, events, options = {}) {
|
|
49
|
+
const now = options.now ?? Date.now();
|
|
50
|
+
const keepHoursMs = options.keepHoursMs ?? DEFAULT_KEEP_HOURS_MS;
|
|
51
|
+
const inherited = aggregate.inheritedEventCount;
|
|
52
|
+
for (const event of events) {
|
|
53
|
+
if (!isEvent(event))
|
|
54
|
+
continue;
|
|
55
|
+
if (event.seq <= aggregate.carry.lastSeq)
|
|
56
|
+
continue;
|
|
57
|
+
const data = asRecord(event.data);
|
|
58
|
+
const time = finiteNumber(event.time) ?? aggregate.createdAt;
|
|
59
|
+
const isOwn = event.seq >= inherited;
|
|
60
|
+
if (event.type === 'request/header') {
|
|
61
|
+
const config = asRecord(asRecord(data?.header)?.config);
|
|
62
|
+
const provider = cleanText(config?.provider);
|
|
63
|
+
const model = cleanText(config?.model);
|
|
64
|
+
if (model !== '') {
|
|
65
|
+
aggregate.carry.currentProvider = provider;
|
|
66
|
+
aggregate.carry.currentModel = model;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else if (event.type === 'llm/retry-started') {
|
|
70
|
+
const turn = finiteNumber(data?.turn);
|
|
71
|
+
const step = finiteNumber(data?.step);
|
|
72
|
+
if (turn !== null && step !== null) {
|
|
73
|
+
if (aggregate.carry.own?.turn === turn && aggregate.carry.own.step === step)
|
|
74
|
+
aggregate.carry.own = null;
|
|
75
|
+
if (aggregate.carry.all?.turn === turn && aggregate.carry.all.step === step)
|
|
76
|
+
aggregate.carry.all = null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else if (event.type === 'step/start') {
|
|
80
|
+
const turn = finiteNumber(data?.turn);
|
|
81
|
+
const step = finiteNumber(data?.step);
|
|
82
|
+
if (turn !== null && step !== null) {
|
|
83
|
+
aggregate.carry.stepStart = { turn, step, time };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (event.type === 'session/title') {
|
|
87
|
+
const title = cleanText(data?.title);
|
|
88
|
+
if (title !== '')
|
|
89
|
+
aggregate.title = title;
|
|
90
|
+
}
|
|
91
|
+
else if (event.type === 'assistant/message' || event.type === 'assistant/attempt') {
|
|
92
|
+
const turn = finiteNumber(data?.turn);
|
|
93
|
+
const step = finiteNumber(data?.step);
|
|
94
|
+
const usage = readUsage(event);
|
|
95
|
+
if (turn !== null && step !== null && usage !== undefined) {
|
|
96
|
+
const buckets = bucketsOf(usage);
|
|
97
|
+
const identity = identityOf(event, aggregate.carry);
|
|
98
|
+
const timing = isOwn ? readTiming(data, aggregate.carry.stepStart, turn, step, time) : null;
|
|
99
|
+
const requestKey = `${event.seq}:${turn}:${step}`;
|
|
100
|
+
const next = {
|
|
101
|
+
turn,
|
|
102
|
+
step,
|
|
103
|
+
time,
|
|
104
|
+
buckets,
|
|
105
|
+
provider: identity.provider,
|
|
106
|
+
model: identity.model,
|
|
107
|
+
modelKey: modelKeyOf(identity.provider, identity.model),
|
|
108
|
+
timing,
|
|
109
|
+
requestKey,
|
|
110
|
+
};
|
|
111
|
+
const previousAll = aggregate.carry.all !== null
|
|
112
|
+
&& aggregate.carry.all.turn === turn
|
|
113
|
+
&& aggregate.carry.all.step === step
|
|
114
|
+
? aggregate.carry.all
|
|
115
|
+
: null;
|
|
116
|
+
if (previousAll === null) {
|
|
117
|
+
aggregate.allRequests += 1;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
subtractBuckets(aggregate.allTotals, previousAll.buckets);
|
|
121
|
+
}
|
|
122
|
+
addBuckets(aggregate.allTotals, buckets);
|
|
123
|
+
aggregate.carry.all = next;
|
|
124
|
+
if (isOwn) {
|
|
125
|
+
const previous = aggregate.carry.own !== null
|
|
126
|
+
&& aggregate.carry.own.turn === turn
|
|
127
|
+
&& aggregate.carry.own.step === step
|
|
128
|
+
? aggregate.carry.own
|
|
129
|
+
: null;
|
|
130
|
+
if (previous !== null) {
|
|
131
|
+
subtractBuckets(aggregate.totals, previous.buckets);
|
|
132
|
+
applyModelDelta(aggregate, previous, -1);
|
|
133
|
+
applyBucketDelta(aggregate, previous, -1, now, keepHoursMs);
|
|
134
|
+
aggregate.requestSamples = aggregate.requestSamples.filter(sample => sample.key !== previous.requestKey);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
aggregate.requests += 1;
|
|
138
|
+
}
|
|
139
|
+
addBuckets(aggregate.totals, next.buckets);
|
|
140
|
+
applyModelDelta(aggregate, next, 1);
|
|
141
|
+
applyBucketDelta(aggregate, next, 1, now, keepHoursMs);
|
|
142
|
+
aggregate.requestSamples.push(requestSample(next, timing));
|
|
143
|
+
aggregate.carry.own = next;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (isOwn && time > aggregate.lastActivity)
|
|
148
|
+
aggregate.lastActivity = time;
|
|
149
|
+
aggregate.carry.lastSeq = event.seq;
|
|
150
|
+
}
|
|
151
|
+
aggregate.processed = aggregate.carry.lastSeq + 1;
|
|
152
|
+
}
|
|
153
|
+
/** Fold one complete session log from scratch. */
|
|
154
|
+
export function foldSession(header, events, inheritedEventCount, options = {}) {
|
|
155
|
+
const aggregate = createAggregate(header, inheritedEventCount);
|
|
156
|
+
foldInto(aggregate, events, options);
|
|
157
|
+
return aggregate;
|
|
158
|
+
}
|
|
159
|
+
/** Detached, wire-safe session counters for diagnostics and row rendering. */
|
|
160
|
+
export function summarizeSession(aggregate) {
|
|
161
|
+
return {
|
|
162
|
+
totals: { ...aggregate.totals },
|
|
163
|
+
requests: aggregate.requests,
|
|
164
|
+
cacheHitRate: cacheHitRate(aggregate.totals),
|
|
165
|
+
models: Object.values(aggregate.models)
|
|
166
|
+
.filter(model => model.requests > 0 || totalTokens(model) > 0)
|
|
167
|
+
.sort((left, right) => totalTokens(right) - totalTokens(left)),
|
|
168
|
+
eventCount: aggregate.processed,
|
|
169
|
+
inheritedEventCount: aggregate.inheritedEventCount,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function applyModelDelta(aggregate, slot, sign) {
|
|
173
|
+
const row = aggregate.models[slot.modelKey] ?? (sign > 0
|
|
174
|
+
? (aggregate.models[slot.modelKey] = {
|
|
175
|
+
key: slot.modelKey,
|
|
176
|
+
provider: slot.provider,
|
|
177
|
+
model: slot.model,
|
|
178
|
+
requests: 0,
|
|
179
|
+
...emptyBuckets(),
|
|
180
|
+
})
|
|
181
|
+
: undefined);
|
|
182
|
+
if (row === undefined)
|
|
183
|
+
return;
|
|
184
|
+
if (sign > 0)
|
|
185
|
+
addBuckets(row, slot.buckets);
|
|
186
|
+
else
|
|
187
|
+
subtractBuckets(row, slot.buckets);
|
|
188
|
+
row.requests += sign;
|
|
189
|
+
if (row.requests <= 0 && totalTokens(row) <= 0)
|
|
190
|
+
delete aggregate.models[slot.modelKey];
|
|
191
|
+
}
|
|
192
|
+
/** Derive first-token latency and generation window from recorded stream times. */
|
|
193
|
+
function readTiming(data, stepStart, turn, step, messageTime) {
|
|
194
|
+
let first = null;
|
|
195
|
+
let last = null;
|
|
196
|
+
const stream = data?.stream;
|
|
197
|
+
if (Array.isArray(stream)) {
|
|
198
|
+
for (const item of stream) {
|
|
199
|
+
const record = asRecord(item);
|
|
200
|
+
if (record === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
const at = finiteNumber(record.time) ?? finiteNumber(record.time0);
|
|
203
|
+
if (at !== null) {
|
|
204
|
+
first = first === null ? at : Math.min(first, at);
|
|
205
|
+
last = last === null ? at : Math.max(last, at);
|
|
206
|
+
}
|
|
207
|
+
const start = finiteNumber(record.time0);
|
|
208
|
+
if (start !== null) {
|
|
209
|
+
first = first === null ? start : Math.min(first, start);
|
|
210
|
+
const dt = Array.isArray(record.dt) ? record.dt : [];
|
|
211
|
+
const elapsed = dt.reduce((sum, value) => sum + (finiteNumber(value) ?? 0), 0);
|
|
212
|
+
last = last === null ? start + elapsed : Math.max(last, start + elapsed);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const started = stepStart !== null && stepStart.turn === turn && stepStart.step === step
|
|
217
|
+
? stepStart.time
|
|
218
|
+
: null;
|
|
219
|
+
let firstTokenMs = null;
|
|
220
|
+
if (started !== null && first !== null && first >= started) {
|
|
221
|
+
firstTokenMs = first - started;
|
|
222
|
+
}
|
|
223
|
+
let generationMs = null;
|
|
224
|
+
if (first !== null && last !== null && last > first) {
|
|
225
|
+
generationMs = last - first;
|
|
226
|
+
}
|
|
227
|
+
else if (started !== null && messageTime > started) {
|
|
228
|
+
generationMs = messageTime - started;
|
|
229
|
+
}
|
|
230
|
+
else if (first !== null && messageTime > first) {
|
|
231
|
+
generationMs = messageTime - first;
|
|
232
|
+
}
|
|
233
|
+
const totalMs = firstTokenMs !== null && generationMs !== null
|
|
234
|
+
? firstTokenMs + generationMs
|
|
235
|
+
: generationMs ?? firstTokenMs;
|
|
236
|
+
return firstTokenMs === null && generationMs === null
|
|
237
|
+
? null
|
|
238
|
+
: { firstTokenMs, generationMs, totalMs };
|
|
239
|
+
}
|
|
240
|
+
function requestSample(slot, timing) {
|
|
241
|
+
const total = timing?.totalMs ?? null;
|
|
242
|
+
const speed = total !== null && total > 0 && slot.buckets.outputTokens > 0
|
|
243
|
+
? slot.buckets.outputTokens / (total / 1_000)
|
|
244
|
+
: null;
|
|
245
|
+
return {
|
|
246
|
+
key: slot.requestKey,
|
|
247
|
+
time: slot.time,
|
|
248
|
+
provider: slot.provider,
|
|
249
|
+
model: slot.model,
|
|
250
|
+
modelKey: slot.modelKey,
|
|
251
|
+
buckets: { ...slot.buckets },
|
|
252
|
+
speed,
|
|
253
|
+
firstToken: timing?.firstTokenMs ?? null,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
function applyBucketDelta(aggregate, slot, sign, now, keepHoursMs) {
|
|
257
|
+
applyOneBucket(aggregate.days, dayKey(slot.time), slot, sign);
|
|
258
|
+
if (slot.time >= now - keepHoursMs) {
|
|
259
|
+
applyOneBucket(aggregate.hours, hourKey(slot.time), slot, sign);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function applyOneBucket(buckets, key, slot, sign) {
|
|
263
|
+
const existing = buckets[key];
|
|
264
|
+
if (sign < 0) {
|
|
265
|
+
if (existing === undefined)
|
|
266
|
+
return;
|
|
267
|
+
subtractBuckets(existing, slot.buckets);
|
|
268
|
+
existing.requests += sign;
|
|
269
|
+
applyBucketModelDelta(existing, slot, sign);
|
|
270
|
+
if (existing.requests <= 0 && totalTokens(existing) <= 0)
|
|
271
|
+
delete buckets[key];
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const bucket = existing ?? (buckets[key] = { bucket: key, requests: 0, models: [], ...emptyBuckets() });
|
|
275
|
+
addBuckets(bucket, slot.buckets);
|
|
276
|
+
bucket.requests += sign;
|
|
277
|
+
applyBucketModelDelta(bucket, slot, sign);
|
|
278
|
+
}
|
|
279
|
+
function applyBucketModelDelta(bucket, slot, sign) {
|
|
280
|
+
const index = bucket.models.findIndex(model => model.key === slot.modelKey);
|
|
281
|
+
if (index === -1) {
|
|
282
|
+
if (sign < 0)
|
|
283
|
+
return;
|
|
284
|
+
bucket.models.push({
|
|
285
|
+
key: slot.modelKey,
|
|
286
|
+
provider: slot.provider,
|
|
287
|
+
model: slot.model,
|
|
288
|
+
requests: 1,
|
|
289
|
+
...slot.buckets,
|
|
290
|
+
});
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const row = bucket.models[index];
|
|
294
|
+
if (row === undefined)
|
|
295
|
+
return;
|
|
296
|
+
if (sign > 0)
|
|
297
|
+
addBuckets(row, slot.buckets);
|
|
298
|
+
else
|
|
299
|
+
subtractBuckets(row, slot.buckets);
|
|
300
|
+
row.requests += sign;
|
|
301
|
+
if (row.requests <= 0 && totalTokens(row) <= 0)
|
|
302
|
+
bucket.models.splice(index, 1);
|
|
303
|
+
}
|
|
304
|
+
function readUsage(event) {
|
|
305
|
+
const data = asRecord(event.data);
|
|
306
|
+
if (data === undefined)
|
|
307
|
+
return undefined;
|
|
308
|
+
if (event.type === 'assistant/message') {
|
|
309
|
+
const direct = asRecord(data.usage);
|
|
310
|
+
if (direct !== undefined)
|
|
311
|
+
return direct;
|
|
312
|
+
}
|
|
313
|
+
const stream = data.stream;
|
|
314
|
+
if (!Array.isArray(stream))
|
|
315
|
+
return undefined;
|
|
316
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
317
|
+
const record = asRecord(stream[index]);
|
|
318
|
+
if (record?.type !== 'chunk')
|
|
319
|
+
continue;
|
|
320
|
+
const chunk = asRecord(record.chunk);
|
|
321
|
+
if (chunk?.type !== 'usage')
|
|
322
|
+
continue;
|
|
323
|
+
const usage = asRecord(chunk.usage);
|
|
324
|
+
if (usage !== undefined)
|
|
325
|
+
return usage;
|
|
326
|
+
}
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
function bucketsOf(usage) {
|
|
330
|
+
return {
|
|
331
|
+
uncachedInputTokens: countOf(usage.inputTokens),
|
|
332
|
+
cacheReadTokens: countOf(usage.cacheReadTokens),
|
|
333
|
+
cacheWriteTokens: countOf(usage.cacheWriteTokens),
|
|
334
|
+
outputTokens: countOf(usage.outputTokens),
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
function identityOf(event, carry) {
|
|
338
|
+
if (event.type === 'assistant/message') {
|
|
339
|
+
const data = asRecord(event.data);
|
|
340
|
+
const source = asRecord(asRecord(data?.message)?.source);
|
|
341
|
+
if (source?.kind === 'model') {
|
|
342
|
+
const model = cleanText(source.model);
|
|
343
|
+
if (model !== '')
|
|
344
|
+
return { provider: cleanText(source.provider), model };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { provider: carry.currentProvider, model: carry.currentModel };
|
|
348
|
+
}
|
|
349
|
+
function isEvent(value) {
|
|
350
|
+
const record = asRecord(value);
|
|
351
|
+
return record !== undefined
|
|
352
|
+
&& typeof record.type === 'string'
|
|
353
|
+
&& typeof record.seq === 'number'
|
|
354
|
+
&& Number.isFinite(record.seq)
|
|
355
|
+
&& record.seq >= 0;
|
|
356
|
+
}
|
|
357
|
+
function asRecord(value) {
|
|
358
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
359
|
+
return undefined;
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
function finiteNumber(value) {
|
|
363
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
364
|
+
}
|
|
365
|
+
function countOf(value) {
|
|
366
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0)
|
|
367
|
+
return 0;
|
|
368
|
+
return Math.round(value);
|
|
369
|
+
}
|
|
370
|
+
function cleanText(value) {
|
|
371
|
+
if (typeof value !== 'string')
|
|
372
|
+
return '';
|
|
373
|
+
const trimmed = value.trim();
|
|
374
|
+
return trimmed.length > 160 ? trimmed.slice(0, 160) : trimmed;
|
|
375
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-dashboard — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Reads every local session through the official SessionPersistence service,
|
|
5
|
+
* folds provider-reported usage with token-meter's published semantics, keeps
|
|
6
|
+
* a revision-keyed in-memory cache, and serves the dashboard over one
|
|
7
|
+
* authenticated, read-only same-origin route.
|
|
8
|
+
*
|
|
9
|
+
* The plugin never writes session logs, never alters a model request, and
|
|
10
|
+
* holds no second ledger: the durable logs remain the only source of truth.
|
|
11
|
+
*/
|
|
12
|
+
export declare const name = "dsh-token-dashboard";
|
|
13
|
+
/** The durable session store is the one hard dependency. */
|
|
14
|
+
export declare const inject: string[];
|
|
15
|
+
/** Minimal Cordis Context surface used by this plugin. */
|
|
16
|
+
interface HostContextLike {
|
|
17
|
+
effect(callback: () => void | (() => void), label?: string): unknown;
|
|
18
|
+
on(event: string, listener: (...args: any[]) => void): unknown;
|
|
19
|
+
inject(services: readonly string[], callback: (ctx: HostContextLike) => void): unknown;
|
|
20
|
+
get(name: string): unknown;
|
|
21
|
+
}
|
|
22
|
+
/** Register the dashboard against one Host context. */
|
|
23
|
+
export declare function apply(ctx: HostContextLike): void;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-token-dashboard — Host half.
|
|
3
|
+
*
|
|
4
|
+
* Reads every local session through the official SessionPersistence service,
|
|
5
|
+
* folds provider-reported usage with token-meter's published semantics, keeps
|
|
6
|
+
* a revision-keyed in-memory cache, and serves the dashboard over one
|
|
7
|
+
* authenticated, read-only same-origin route.
|
|
8
|
+
*
|
|
9
|
+
* The plugin never writes session logs, never alters a model request, and
|
|
10
|
+
* holds no second ledger: the durable logs remain the only source of truth.
|
|
11
|
+
*/
|
|
12
|
+
import { TokenAggregator } from './aggregate.js';
|
|
13
|
+
import { createPriceService } from './prices.js';
|
|
14
|
+
import { registerDashboardRoutes } from './server.js';
|
|
15
|
+
import { createDshSource } from './source.js';
|
|
16
|
+
export const name = 'dsh-token-dashboard';
|
|
17
|
+
/** The durable session store is the one hard dependency. */
|
|
18
|
+
export const inject = ['sessionPersistence'];
|
|
19
|
+
/** Register the dashboard against one Host context. */
|
|
20
|
+
export function apply(ctx) {
|
|
21
|
+
const access = {
|
|
22
|
+
persistence: () => ctx.get('sessionPersistence'),
|
|
23
|
+
sessions: () => ctx.get('sessions'),
|
|
24
|
+
sessionQuery: () => ctx.get('sessionQuery'),
|
|
25
|
+
workspaces: () => ctx.get('workspaceRegistry'),
|
|
26
|
+
};
|
|
27
|
+
const source = createDshSource(access);
|
|
28
|
+
const aggregator = new TokenAggregator(source, { concurrency: 3 });
|
|
29
|
+
const prices = createPriceService();
|
|
30
|
+
wireLiveEvents(ctx, aggregator);
|
|
31
|
+
wireRoutes(ctx, aggregator, source, prices);
|
|
32
|
+
}
|
|
33
|
+
/** Fold live events without rescanning history; resync only on a gap. */
|
|
34
|
+
function wireLiveEvents(ctx, aggregator) {
|
|
35
|
+
ctx.effect(() => {
|
|
36
|
+
let timer = null;
|
|
37
|
+
const scheduleRefresh = () => {
|
|
38
|
+
if (aggregator.isInitialized === false)
|
|
39
|
+
return;
|
|
40
|
+
if (timer !== null)
|
|
41
|
+
return;
|
|
42
|
+
timer = setTimeout(() => {
|
|
43
|
+
timer = null;
|
|
44
|
+
void aggregator.refresh().catch(error => {
|
|
45
|
+
warn('live resync failed', error);
|
|
46
|
+
});
|
|
47
|
+
}, 700);
|
|
48
|
+
const handle = timer;
|
|
49
|
+
handle.unref?.();
|
|
50
|
+
};
|
|
51
|
+
const disposers = [];
|
|
52
|
+
const remember = (value) => {
|
|
53
|
+
if (typeof value === 'function')
|
|
54
|
+
disposers.push(value);
|
|
55
|
+
};
|
|
56
|
+
remember(ctx.on('session/event', (session, event) => {
|
|
57
|
+
// A passive observer must never throw into the Session append path.
|
|
58
|
+
try {
|
|
59
|
+
if (aggregator.isInitialized === false)
|
|
60
|
+
return;
|
|
61
|
+
const id = session?.id;
|
|
62
|
+
if (typeof id !== 'string' || id === '')
|
|
63
|
+
return;
|
|
64
|
+
if (!aggregator.applyLiveEvent(id, event))
|
|
65
|
+
scheduleRefresh();
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
warn('live fold failed', error);
|
|
69
|
+
scheduleRefresh();
|
|
70
|
+
}
|
|
71
|
+
}));
|
|
72
|
+
remember(ctx.on('session/created', () => {
|
|
73
|
+
scheduleRefresh();
|
|
74
|
+
}));
|
|
75
|
+
remember(ctx.on('session/disposed', () => {
|
|
76
|
+
scheduleRefresh();
|
|
77
|
+
}));
|
|
78
|
+
return () => {
|
|
79
|
+
if (timer !== null)
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
for (const dispose of disposers) {
|
|
82
|
+
try {
|
|
83
|
+
dispose();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// A listener already removed by its owning fiber is not an error.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}, 'dsh-token-dashboard: live usage');
|
|
91
|
+
}
|
|
92
|
+
/** Register the read-only HTTP surface when the deployment serves a web UI. */
|
|
93
|
+
function wireRoutes(ctx, aggregator, source, prices) {
|
|
94
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
95
|
+
const server = webCtx.get('webServer');
|
|
96
|
+
if (server === undefined)
|
|
97
|
+
return;
|
|
98
|
+
webCtx.effect(() => registerDashboardRoutes({
|
|
99
|
+
server,
|
|
100
|
+
aggregator,
|
|
101
|
+
source,
|
|
102
|
+
prices,
|
|
103
|
+
connection: () => ctx.get('connection'),
|
|
104
|
+
}), 'dsh-token-dashboard: dashboard routes');
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
function warn(message, error) {
|
|
108
|
+
console.warn(`[dsh-token-dashboard] ${message}: ${errorName(error)}`);
|
|
109
|
+
}
|
|
110
|
+
function errorName(error) {
|
|
111
|
+
if (error instanceof Error) {
|
|
112
|
+
const code = error.code;
|
|
113
|
+
return typeof code === 'string' ? `${error.name}:${code}` : error.name;
|
|
114
|
+
}
|
|
115
|
+
return 'UnknownError';
|
|
116
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional online price lookup. The Host fetches the public OpenRouter model
|
|
3
|
+
* list only when the user presses "Refresh online"; no request is made at
|
|
4
|
+
* startup and no session data leaves the dashboard.
|
|
5
|
+
*/
|
|
6
|
+
import type { ModelPrice } from '../shared/pricing.js';
|
|
7
|
+
export interface PricePayload {
|
|
8
|
+
source: string;
|
|
9
|
+
fetchedAt: string;
|
|
10
|
+
prices: Record<string, ModelPrice>;
|
|
11
|
+
}
|
|
12
|
+
export interface PriceLookup {
|
|
13
|
+
lookup(models: readonly string[], force: boolean): Promise<PricePayload>;
|
|
14
|
+
}
|
|
15
|
+
interface FetchResponseLike {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
status: number;
|
|
18
|
+
json(): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
export type PriceFetch = (url: string, init: {
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
}) => Promise<FetchResponseLike>;
|
|
24
|
+
export interface PriceServiceOptions {
|
|
25
|
+
fetch?: PriceFetch;
|
|
26
|
+
now?: () => number;
|
|
27
|
+
ttlMs?: number;
|
|
28
|
+
url?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface OpenRouterEntry {
|
|
31
|
+
id: string;
|
|
32
|
+
keys: readonly string[];
|
|
33
|
+
price: ModelPrice;
|
|
34
|
+
rank: number;
|
|
35
|
+
}
|
|
36
|
+
/** Parse the subset of the OpenRouter response the dashboard needs. */
|
|
37
|
+
export declare function parseOpenRouterEntries(payload: unknown): OpenRouterEntry[];
|
|
38
|
+
/** Resolve one requested model against the parsed list, preferring standard variants. */
|
|
39
|
+
export declare function resolveOpenRouterPrice(entries: readonly OpenRouterEntry[], requested: string): ModelPrice | undefined;
|
|
40
|
+
/** Create a cached lookup service for the Host route. */
|
|
41
|
+
export declare function createPriceService(options?: PriceServiceOptions): PriceLookup;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional online price lookup. The Host fetches the public OpenRouter model
|
|
3
|
+
* list only when the user presses "Refresh online"; no request is made at
|
|
4
|
+
* startup and no session data leaves the dashboard.
|
|
5
|
+
*/
|
|
6
|
+
import { modelKeyCandidates, modelVariantRank } from '../shared/builtin-prices.js';
|
|
7
|
+
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/models';
|
|
8
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1_000;
|
|
9
|
+
/** Parse the subset of the OpenRouter response the dashboard needs. */
|
|
10
|
+
export function parseOpenRouterEntries(payload) {
|
|
11
|
+
if (payload === null || typeof payload !== 'object')
|
|
12
|
+
return [];
|
|
13
|
+
const data = payload.data;
|
|
14
|
+
if (!Array.isArray(data))
|
|
15
|
+
return [];
|
|
16
|
+
const entries = [];
|
|
17
|
+
for (const raw of data) {
|
|
18
|
+
if (raw === null || typeof raw !== 'object')
|
|
19
|
+
continue;
|
|
20
|
+
const record = raw;
|
|
21
|
+
if (typeof record.id !== 'string' || record.id.trim() === '')
|
|
22
|
+
continue;
|
|
23
|
+
const pricing = record.pricing;
|
|
24
|
+
if (pricing === null || typeof pricing !== 'object' || Array.isArray(pricing))
|
|
25
|
+
continue;
|
|
26
|
+
const priceRecord = pricing;
|
|
27
|
+
const input = perMillion(priceRecord.prompt);
|
|
28
|
+
const output = perMillion(priceRecord.completion);
|
|
29
|
+
if (input === null || output === null)
|
|
30
|
+
continue;
|
|
31
|
+
entries.push({
|
|
32
|
+
id: record.id,
|
|
33
|
+
keys: modelKeyCandidates(record.id),
|
|
34
|
+
price: {
|
|
35
|
+
currency: 'USD',
|
|
36
|
+
input,
|
|
37
|
+
output,
|
|
38
|
+
cacheRead: perMillion(priceRecord.input_cache_read) ?? 0,
|
|
39
|
+
cacheWrite: perMillion(priceRecord.input_cache_write) ?? 0,
|
|
40
|
+
},
|
|
41
|
+
rank: modelVariantRank(record.id),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return entries;
|
|
45
|
+
}
|
|
46
|
+
/** Resolve one requested model against the parsed list, preferring standard variants. */
|
|
47
|
+
export function resolveOpenRouterPrice(entries, requested) {
|
|
48
|
+
const keys = new Set(modelKeyCandidates(requested));
|
|
49
|
+
if (keys.size === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
let best;
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.keys.some(key => keys.has(key)))
|
|
54
|
+
continue;
|
|
55
|
+
if (best === undefined || entry.rank < best.rank)
|
|
56
|
+
best = entry;
|
|
57
|
+
}
|
|
58
|
+
return best === undefined ? undefined : { ...best.price };
|
|
59
|
+
}
|
|
60
|
+
/** Create a cached lookup service for the Host route. */
|
|
61
|
+
export function createPriceService(options = {}) {
|
|
62
|
+
const now = options.now ?? Date.now;
|
|
63
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
64
|
+
const url = options.url ?? OPENROUTER_URL;
|
|
65
|
+
const fetchImpl = options.fetch
|
|
66
|
+
?? ((input, init) => globalThis.fetch(input, init));
|
|
67
|
+
let cache = null;
|
|
68
|
+
const load = async (force) => {
|
|
69
|
+
if (!force && cache !== null && now() - cache.at < ttlMs)
|
|
70
|
+
return cache.entries;
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
const timer = setTimeout(() => controller.abort(), 8_000);
|
|
73
|
+
try {
|
|
74
|
+
const response = await fetchImpl(url, {
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
headers: {
|
|
77
|
+
accept: 'application/json',
|
|
78
|
+
'user-agent': 'dsh-token-dashboard/0.1',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
if (!response.ok)
|
|
82
|
+
throw new Error(`HTTP ${response.status}`);
|
|
83
|
+
const entries = parseOpenRouterEntries(await response.json());
|
|
84
|
+
cache = { at: now(), entries };
|
|
85
|
+
return entries;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
return {
|
|
92
|
+
async lookup(models, force) {
|
|
93
|
+
if (models.length === 0) {
|
|
94
|
+
return { source: 'OpenRouter', fetchedAt: new Date(now()).toISOString(), prices: {} };
|
|
95
|
+
}
|
|
96
|
+
const entries = await load(force);
|
|
97
|
+
const prices = {};
|
|
98
|
+
for (const model of models) {
|
|
99
|
+
const price = resolveOpenRouterPrice(entries, model);
|
|
100
|
+
if (price !== undefined)
|
|
101
|
+
prices[model] = price;
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
source: 'OpenRouter',
|
|
105
|
+
fetchedAt: new Date(now()).toISOString(),
|
|
106
|
+
prices,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function perMillion(value) {
|
|
112
|
+
const rate = typeof value === 'number'
|
|
113
|
+
? value
|
|
114
|
+
: typeof value === 'string' && value.trim() !== '' ? Number(value) : Number.NaN;
|
|
115
|
+
if (!Number.isFinite(rate) || rate < 0)
|
|
116
|
+
return null;
|
|
117
|
+
return Math.round(rate * 1_000_000 * 1_000_000) / 1_000_000;
|
|
118
|
+
}
|