@wannanbigpig/dsh-usage-stats 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 +211 -0
- package/cordis.patch.yml +5 -0
- package/lib/balance.js +87 -0
- package/lib/client.js +2428 -0
- package/lib/index.js +1390 -0
- package/lib/ledger.js +253 -0
- package/lib/pricing.js +85 -0
- package/lib/tokenizer.js +55 -0
- package/lib/usage.js +481 -0
- package/package.json +73 -0
package/lib/usage.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-stats — pure per-day / per-hour / per-model token-usage
|
|
3
|
+
* aggregation over session event logs, plus model-priced cost estimation.
|
|
4
|
+
*
|
|
5
|
+
* Kept free of cordis imports so it can be unit-tested and validated against
|
|
6
|
+
* real logs outside the running harness.
|
|
7
|
+
*
|
|
8
|
+
* Aggregation semantics mirror `dsh-token-meter`'s `tokenUsage` projection:
|
|
9
|
+
* a usage sample rides an `assistant/chunk` (`data.chunk.type === "usage"`)
|
|
10
|
+
* or an `assistant/message` (`data.usage`); a repeated sample for the same
|
|
11
|
+
* (turn, step) REPLACES the earlier value instead of double counting it, and
|
|
12
|
+
* the replacement is re-attributed to the day/hour/model of the later event.
|
|
13
|
+
*
|
|
14
|
+
* Each sample is attributed to the model that produced it:
|
|
15
|
+
* `assistant/message` carries `data.message.source.model`; usage chunks fall
|
|
16
|
+
* back to the last `request/header` `data.header.config.model`; samples with
|
|
17
|
+
* no model information land in the `unknown/unknown` bucket.
|
|
18
|
+
*
|
|
19
|
+
* Hourly buckets record totals AND the per-model split, so both the hourly
|
|
20
|
+
* token chart and the hourly cost (peak/off-peak pricing) can be rendered
|
|
21
|
+
* exactly.
|
|
22
|
+
*
|
|
23
|
+
* @module dsh-usage-stats/usage
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
27
|
+
import { defaultPricingVersion, normalizePricing } from "./pricing.js";
|
|
28
|
+
|
|
29
|
+
/** Beijing-calendar `YYYY-MM-DD` key for a millisecond epoch. */
|
|
30
|
+
export function dayKey(timeMs) {
|
|
31
|
+
const date = new Date(timeMs + BEIJING_OFFSET_MS);
|
|
32
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
|
|
33
|
+
const day = String(date.getUTCDate()).padStart(2, "0");
|
|
34
|
+
return `${date.getUTCFullYear()}-${month}-${day}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Beijing hour (0–23) for a millisecond epoch. */
|
|
38
|
+
export function hourKey(timeMs) {
|
|
39
|
+
return new Date(timeMs + BEIJING_OFFSET_MS).getUTCHours();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Empty token bucket. */
|
|
43
|
+
export function zeroBuckets() {
|
|
44
|
+
return {
|
|
45
|
+
inputTokens: 0,
|
|
46
|
+
outputTokens: 0,
|
|
47
|
+
cacheReadTokens: 0,
|
|
48
|
+
cacheWriteTokens: 0
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Provider usage → buckets (missing cache fields are absent in some reports). */
|
|
53
|
+
export function bucketsOf(usage) {
|
|
54
|
+
return {
|
|
55
|
+
inputTokens: usage.inputTokens ?? 0,
|
|
56
|
+
outputTokens: usage.outputTokens ?? 0,
|
|
57
|
+
cacheReadTokens: usage.cacheReadTokens ?? 0,
|
|
58
|
+
cacheWriteTokens: usage.cacheWriteTokens ?? 0
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Total tokens across all buckets. */
|
|
63
|
+
export function totalTokens(buckets) {
|
|
64
|
+
return buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Prompt-side cache hit rate in percent (0–100, one decimal), or null when
|
|
69
|
+
* no prompt tokens were reported at all. Hits over the whole prompt side:
|
|
70
|
+
* cacheRead / (input + cacheRead + cacheWrite).
|
|
71
|
+
*/
|
|
72
|
+
export function cacheHitRate(buckets) {
|
|
73
|
+
const input = buckets.inputTokens ?? 0;
|
|
74
|
+
const cacheRead = buckets.cacheReadTokens ?? 0;
|
|
75
|
+
const cacheWrite = buckets.cacheWriteTokens ?? 0;
|
|
76
|
+
const promptTokens = input + cacheRead + cacheWrite;
|
|
77
|
+
if (promptTokens <= 0) return null;
|
|
78
|
+
return Math.round((cacheRead / promptTokens) * 1000) / 10;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function addInto(target, source) {
|
|
82
|
+
target.inputTokens += source.inputTokens;
|
|
83
|
+
target.outputTokens += source.outputTokens;
|
|
84
|
+
target.cacheReadTokens += source.cacheReadTokens;
|
|
85
|
+
target.cacheWriteTokens += source.cacheWriteTokens;
|
|
86
|
+
return target;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function subtractFrom(target, source) {
|
|
90
|
+
target.inputTokens -= source.inputTokens;
|
|
91
|
+
target.outputTokens -= source.outputTokens;
|
|
92
|
+
target.cacheReadTokens -= source.cacheReadTokens;
|
|
93
|
+
target.cacheWriteTokens -= source.cacheWriteTokens;
|
|
94
|
+
return target;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when a bucket has no tokens left (used to prune replaced samples). */
|
|
98
|
+
export function isZeroBucket(buckets) {
|
|
99
|
+
return (buckets.inputTokens ?? 0) === 0
|
|
100
|
+
&& (buckets.outputTokens ?? 0) === 0
|
|
101
|
+
&& (buckets.cacheReadTokens ?? 0) === 0
|
|
102
|
+
&& (buckets.cacheWriteTokens ?? 0) === 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Extract the usage sample an event carries, if any. */
|
|
106
|
+
function sampleOf(event) {
|
|
107
|
+
if (event.type === "assistant/chunk" && event.data?.chunk?.type === "usage") {
|
|
108
|
+
return {
|
|
109
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
110
|
+
usage: event.data.chunk.usage
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (event.type === "assistant/message" && event.data?.usage !== void 0) {
|
|
114
|
+
return {
|
|
115
|
+
key: `${event.data.turn}:${event.data.step}`,
|
|
116
|
+
usage: event.data.usage
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return void 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The `provider/model` attribution key of a usage sample: the exact provider
|
|
124
|
+
* route (dsh adapter id or pi-ai route) plus the model id, so the SAME model
|
|
125
|
+
* served by different providers stays distinct. `assistant/message` names
|
|
126
|
+
* its provider via `data.message.source`; usage chunks fall back to the last
|
|
127
|
+
* `request/header` `data.header.config`; samples with no model information
|
|
128
|
+
* land in the `unknown/unknown` bucket.
|
|
129
|
+
*/
|
|
130
|
+
function modelOf(event) {
|
|
131
|
+
const source = event.data?.message?.source;
|
|
132
|
+
if (source !== void 0 && typeof source.model === "string") {
|
|
133
|
+
return `${typeof source.provider === "string" && source.provider.length > 0 ? source.provider : "unknown"}/${source.model}`;
|
|
134
|
+
}
|
|
135
|
+
const config = event.data?.header?.config;
|
|
136
|
+
if (config !== void 0 && typeof config.model === "string") {
|
|
137
|
+
return `${typeof config.provider === "string" && config.provider.length > 0 ? config.provider : "unknown"}/${config.model}`;
|
|
138
|
+
}
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Day entry: totals plus a per-model bucket map and per-hour per-model buckets. */
|
|
143
|
+
function entryOf(byDay, day) {
|
|
144
|
+
let entry = byDay.get(day);
|
|
145
|
+
if (entry === void 0) {
|
|
146
|
+
entry = {
|
|
147
|
+
totals: zeroBuckets(),
|
|
148
|
+
models: new Map(),
|
|
149
|
+
hours: new Map()
|
|
150
|
+
};
|
|
151
|
+
byDay.set(day, entry);
|
|
152
|
+
}
|
|
153
|
+
return entry;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Hour entry inside a day: hour → Map<model, buckets>. */
|
|
157
|
+
function hourEntryOf(entry, hour) {
|
|
158
|
+
let hours = entry.hours.get(hour);
|
|
159
|
+
if (hours === void 0) {
|
|
160
|
+
hours = new Map();
|
|
161
|
+
entry.hours.set(hour, hours);
|
|
162
|
+
}
|
|
163
|
+
return hours;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* One session's incremental fold state. `days` holds the already-folded
|
|
168
|
+
* per-day entries; `lastSample`/`currentModel` let a later event slice keep
|
|
169
|
+
* the replace-last-sample semantics and model attribution across fold
|
|
170
|
+
* boundaries without replaying the whole log.
|
|
171
|
+
*/
|
|
172
|
+
export function createUsageState() {
|
|
173
|
+
return {
|
|
174
|
+
days: new Map(),
|
|
175
|
+
lastSample: null,
|
|
176
|
+
currentModel: null,
|
|
177
|
+
consumed: 0
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Fold a slice of NEW events onto an existing session state (mutating).
|
|
183
|
+
* Replacements for the same (turn, step) subtract the previous sample's
|
|
184
|
+
* buckets from the day/hour/model buckets they were attributed to, so a slice
|
|
185
|
+
* starting mid-step (e.g. a usage chunk at the tail of the previous fold)
|
|
186
|
+
* stays exact.
|
|
187
|
+
* @param state - session fold state (mutated in place).
|
|
188
|
+
* @param events - the new events, in seq order, starting after the last fold.
|
|
189
|
+
*/
|
|
190
|
+
export function applyUsageDelta(state, events) {
|
|
191
|
+
let last = state.lastSample;
|
|
192
|
+
let currentModel = state.currentModel;
|
|
193
|
+
for (const event of events) {
|
|
194
|
+
if (event.type === "request/header") {
|
|
195
|
+
const model = modelOf(event);
|
|
196
|
+
if (model !== void 0) currentModel = model;
|
|
197
|
+
}
|
|
198
|
+
const sample = sampleOf(event);
|
|
199
|
+
if (sample === void 0) continue;
|
|
200
|
+
const buckets = bucketsOf(sample.usage);
|
|
201
|
+
const model = modelOf(event) ?? currentModel ?? "unknown/unknown";
|
|
202
|
+
const day = dayKey(event.time);
|
|
203
|
+
const hour = hourKey(event.time);
|
|
204
|
+
const entry = entryOf(state.days, day);
|
|
205
|
+
if (last !== null && last.key === sample.key) {
|
|
206
|
+
// Same turn/step re-reported: replace instead of double counting.
|
|
207
|
+
const previous = state.days.get(last.day);
|
|
208
|
+
if (previous !== void 0) {
|
|
209
|
+
subtractFrom(previous.totals, last.buckets);
|
|
210
|
+
const previousModel = previous.models.get(last.model);
|
|
211
|
+
if (previousModel !== void 0) {
|
|
212
|
+
subtractFrom(previousModel, last.buckets);
|
|
213
|
+
if (isZeroBucket(previousModel)) previous.models.delete(last.model);
|
|
214
|
+
}
|
|
215
|
+
const previousHour = previous.hours.get(last.hour);
|
|
216
|
+
const previousHourModel = previousHour === void 0 ? void 0 : previousHour.get(last.model);
|
|
217
|
+
if (previousHourModel !== void 0) {
|
|
218
|
+
subtractFrom(previousHourModel, last.buckets);
|
|
219
|
+
if (isZeroBucket(previousHourModel)) previousHour.delete(last.model);
|
|
220
|
+
if (previousHour.size === 0) previous.hours.delete(last.hour);
|
|
221
|
+
}
|
|
222
|
+
// A fully replaced sample leaves the OLD day empty: drop it so
|
|
223
|
+
// zero-token days never surface in renders or the cache. Only
|
|
224
|
+
// when the replacement moved to a different day — on the same
|
|
225
|
+
// day the add below repopulates the very same entry.
|
|
226
|
+
if (last.day !== day && isZeroBucket(previous.totals) && previous.models.size === 0 && previous.hours.size === 0) {
|
|
227
|
+
state.days.delete(last.day);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
addInto(entry.totals, buckets);
|
|
232
|
+
let modelBucket = entry.models.get(model);
|
|
233
|
+
if (modelBucket === void 0) {
|
|
234
|
+
modelBucket = zeroBuckets();
|
|
235
|
+
entry.models.set(model, modelBucket);
|
|
236
|
+
}
|
|
237
|
+
addInto(modelBucket, buckets);
|
|
238
|
+
const hourModels = hourEntryOf(entry, hour);
|
|
239
|
+
let hourModelBucket = hourModels.get(model);
|
|
240
|
+
if (hourModelBucket === void 0) {
|
|
241
|
+
hourModelBucket = zeroBuckets();
|
|
242
|
+
hourModels.set(model, hourModelBucket);
|
|
243
|
+
}
|
|
244
|
+
addInto(hourModelBucket, buckets);
|
|
245
|
+
last = { key: sample.key, day, hour, model, buckets };
|
|
246
|
+
}
|
|
247
|
+
state.lastSample = last;
|
|
248
|
+
state.currentModel = currentModel;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fold one session's events into per-day, per-hour, per-model token buckets.
|
|
253
|
+
* @param events - session event log in seq order.
|
|
254
|
+
* @returns Map<`YYYY-MM-DD`, { totals, models, hours }> with only days that
|
|
255
|
+
* saw usage.
|
|
256
|
+
*/
|
|
257
|
+
export function foldUsage(events) {
|
|
258
|
+
const state = createUsageState();
|
|
259
|
+
applyUsageDelta(state, events);
|
|
260
|
+
return state.days;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Merge one session's folded days into a global per-day map.
|
|
265
|
+
* @param byDay - global map to mutate.
|
|
266
|
+
* @param sessionDays - session day map (from foldUsage or a state).
|
|
267
|
+
*/
|
|
268
|
+
export function mergeInto(byDay, sessionDays) {
|
|
269
|
+
for (const [day, entry] of sessionDays) {
|
|
270
|
+
const target = entryOf(byDay, day);
|
|
271
|
+
addInto(target.totals, entry.totals);
|
|
272
|
+
for (const [model, buckets] of entry.models) {
|
|
273
|
+
let modelBucket = target.models.get(model);
|
|
274
|
+
if (modelBucket === void 0) {
|
|
275
|
+
modelBucket = zeroBuckets();
|
|
276
|
+
target.models.set(model, modelBucket);
|
|
277
|
+
}
|
|
278
|
+
addInto(modelBucket, buckets);
|
|
279
|
+
}
|
|
280
|
+
for (const [hour, hourModels] of entry.hours) {
|
|
281
|
+
const targetHour = hourEntryOf(target, hour);
|
|
282
|
+
for (const [model, buckets] of hourModels) {
|
|
283
|
+
let hourModelBucket = targetHour.get(model);
|
|
284
|
+
if (hourModelBucket === void 0) {
|
|
285
|
+
hourModelBucket = zeroBuckets();
|
|
286
|
+
targetHour.set(model, hourModelBucket);
|
|
287
|
+
}
|
|
288
|
+
addInto(hourModelBucket, buckets);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* DeepSeek-style idle-time pricing in CNY per 1M tokens. Official peak hours
|
|
296
|
+
* are Beijing time 09:00-12:00 and 14:00-18:00, billed at 2x idle prices.
|
|
297
|
+
*
|
|
298
|
+
* Defaults track https://api-docs.deepseek.com/quick_start/pricing
|
|
299
|
+
* (deepseek-v4-flash / deepseek-v4-pro, off-peak rates).
|
|
300
|
+
*/
|
|
301
|
+
export function defaultPricing() {
|
|
302
|
+
return normalizePricing({
|
|
303
|
+
...defaultPricingVersion(),
|
|
304
|
+
pricing: {
|
|
305
|
+
"deepseek-v4-flash": { inputMiss: 1.5, inputHit: 0.05, output: 4.5 },
|
|
306
|
+
"deepseek-v4-pro": { inputMiss: 4.5, inputHit: 0.15, output: 13.5 }
|
|
307
|
+
},
|
|
308
|
+
peakMultiplier: 2,
|
|
309
|
+
peakHours: [[9, 12], [14, 18]],
|
|
310
|
+
currency: "CNY"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** The bare model id part of a `provider/model` attribution key. */
|
|
315
|
+
export function modelIdOf(modelKey) {
|
|
316
|
+
if (typeof modelKey !== "string") return "";
|
|
317
|
+
const slash = modelKey.indexOf("/");
|
|
318
|
+
return slash === -1 ? modelKey : modelKey.slice(slash + 1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The provider part of a `provider/model` attribution key ("" when absent). */
|
|
322
|
+
export function providerOf(modelKey) {
|
|
323
|
+
if (typeof modelKey !== "string") return "";
|
|
324
|
+
const slash = modelKey.indexOf("/");
|
|
325
|
+
return slash === -1 ? "" : modelKey.slice(0, slash);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** True when a local hour lands in any peak window (windows are UTC). */
|
|
329
|
+
export function isPeakHour(hour, peakHours = [[9, 12], [14, 18]]) {
|
|
330
|
+
for (const [start, end] of peakHours) {
|
|
331
|
+
if (hour >= start && hour < end) return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Look up the per-1M-token price for a `provider/model` key. Unknown models
|
|
338
|
+
* stay unpriced so a configuration mistake cannot become a false bill.
|
|
339
|
+
*/
|
|
340
|
+
export function priceOf(modelKey, pricingConfig = {}) {
|
|
341
|
+
const pricing = pricingConfig.pricing ?? {};
|
|
342
|
+
const model = modelIdOf(modelKey);
|
|
343
|
+
const row = pricing[model] ?? pricingConfig.models?.[model]?.offPeak;
|
|
344
|
+
if (row === void 0 || row === null || typeof row !== "object") return null;
|
|
345
|
+
return {
|
|
346
|
+
inputMiss: Number(row.inputMiss) || 0,
|
|
347
|
+
inputHit: Number(row.inputHit) || 0,
|
|
348
|
+
output: Number(row.output) || 0
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Estimated CNY cost of one model's buckets in a given Beijing-time hour.
|
|
354
|
+
*
|
|
355
|
+
* DeepSeek context caching bills cache-hit prompt tokens at the cache-hit
|
|
356
|
+
* rate and everything else on the prompt side (fresh input + cache writes)
|
|
357
|
+
* at the cache-miss rate. Peak-hour windows are billed at `peakMultiplier`×.
|
|
358
|
+
*
|
|
359
|
+
* @param modelKey - `provider/model` attribution key.
|
|
360
|
+
* @param buckets - token buckets for that model.
|
|
361
|
+
* @param hour - local hour 0–23.
|
|
362
|
+
* @param pricingConfig - merged pricing configuration.
|
|
363
|
+
* @returns estimated cost in CNY (float).
|
|
364
|
+
*/
|
|
365
|
+
export function costOf(modelKey, buckets, hour, pricingConfig = {}) {
|
|
366
|
+
const price = priceOf(modelKey, pricingConfig);
|
|
367
|
+
if (price === null) return null;
|
|
368
|
+
const explicit = pricingConfig.models?.[modelIdOf(modelKey)];
|
|
369
|
+
const tierPrice = isPeakHour(hour, pricingConfig.peakHours ?? [[9, 12], [14, 18]]) ? explicit?.peak : explicit?.offPeak;
|
|
370
|
+
const effectivePrice = tierPrice && Number.isFinite(Number(tierPrice.inputMiss)) ? tierPrice : price;
|
|
371
|
+
const peakMultiplier = Number(pricingConfig.peakMultiplier) || 1;
|
|
372
|
+
const missTokens = (buckets.inputTokens ?? 0) + (buckets.cacheWriteTokens ?? 0);
|
|
373
|
+
const base = (missTokens * effectivePrice.inputMiss + (buckets.cacheReadTokens ?? 0) * effectivePrice.inputHit + (buckets.outputTokens ?? 0) * effectivePrice.output) / 1e6;
|
|
374
|
+
return explicit ? base : (isPeakHour(hour, pricingConfig.peakHours ?? [[9, 12], [14, 18]]) ? base * peakMultiplier : base);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Round a cost to a fixed number of decimals. */
|
|
378
|
+
export function roundCost(value, digits = 6) {
|
|
379
|
+
if (!Number.isFinite(value)) return 0;
|
|
380
|
+
const factor = 10 ** digits;
|
|
381
|
+
return Math.round(value * factor) / factor;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Render a global per-day map into the wire shape for the usage endpoint.
|
|
386
|
+
* @param byDay - day → entry map.
|
|
387
|
+
* @param updatedAt - computation timestamp.
|
|
388
|
+
* @param pricingConfig - merged pricing configuration (see defaultPricing).
|
|
389
|
+
* @returns `{ days, total, updatedAt }` with `days` sorted ascending; each
|
|
390
|
+
* day carries `models` (descending by tokens), a `cacheHitRate` percent, an
|
|
391
|
+
* estimated `cost`, and a 24-entry `hours` array (descending by tokens is
|
|
392
|
+
* not applied — hours are index-ordered 0–23).
|
|
393
|
+
*/
|
|
394
|
+
export function renderUsage(byDay, updatedAt, pricingConfig = defaultPricing()) {
|
|
395
|
+
const days = [...byDay.entries()]
|
|
396
|
+
.map(([date, entry]) => {
|
|
397
|
+
const models = [...entry.models.entries()]
|
|
398
|
+
.map(([model, buckets]) => {
|
|
399
|
+
// A model's daily cost is the sum of its per-hour costs
|
|
400
|
+
// (peak/off-peak aware).
|
|
401
|
+
let cost = 0;
|
|
402
|
+
let priced = true;
|
|
403
|
+
for (const [hour, hourModels] of entry.hours) {
|
|
404
|
+
const hourBuckets = hourModels.get(model);
|
|
405
|
+
if (hourBuckets !== void 0) {
|
|
406
|
+
const hourCost = costOf(model, hourBuckets, hour, pricingConfig);
|
|
407
|
+
if (hourCost === null) priced = false;
|
|
408
|
+
else cost += hourCost;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
model,
|
|
413
|
+
...buckets,
|
|
414
|
+
tokens: totalTokens(buckets),
|
|
415
|
+
cacheHitRate: cacheHitRate(buckets),
|
|
416
|
+
cost: priced ? roundCost(cost) : null
|
|
417
|
+
};
|
|
418
|
+
})
|
|
419
|
+
// All-zero buckets come from warmup requests that report
|
|
420
|
+
// {input:0, output:0}; rendering them produces empty "0 tokens"
|
|
421
|
+
// model rows.
|
|
422
|
+
.filter((entry) => entry.tokens > 0)
|
|
423
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
424
|
+
const hours = [];
|
|
425
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
426
|
+
const hourModels = entry.hours.get(hour);
|
|
427
|
+
const totals = zeroBuckets();
|
|
428
|
+
let cost = 0;
|
|
429
|
+
let priced = true;
|
|
430
|
+
const hourModelRows = [];
|
|
431
|
+
if (hourModels !== void 0) {
|
|
432
|
+
for (const [model, buckets] of hourModels) {
|
|
433
|
+
addInto(totals, buckets);
|
|
434
|
+
const modelCost = costOf(model, buckets, hour, pricingConfig);
|
|
435
|
+
if (modelCost === null) priced = false;
|
|
436
|
+
else cost += modelCost;
|
|
437
|
+
hourModelRows.push({
|
|
438
|
+
model,
|
|
439
|
+
...buckets,
|
|
440
|
+
tokens: totalTokens(buckets),
|
|
441
|
+
cost: modelCost === null ? null : roundCost(modelCost)
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
hourModelRows.sort((a, b) => b.tokens - a.tokens);
|
|
445
|
+
}
|
|
446
|
+
hours.push({
|
|
447
|
+
hour,
|
|
448
|
+
...totals,
|
|
449
|
+
tokens: totalTokens(totals),
|
|
450
|
+
cost: priced ? roundCost(cost) : null,
|
|
451
|
+
models: hourModelRows
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
date,
|
|
456
|
+
...entry.totals,
|
|
457
|
+
tokens: totalTokens(entry.totals),
|
|
458
|
+
cacheHitRate: cacheHitRate(entry.totals),
|
|
459
|
+
cost: models.some((model) => model.cost === null)
|
|
460
|
+
? null
|
|
461
|
+
: roundCost(models.reduce((sum, model) => sum + model.cost, 0)),
|
|
462
|
+
models,
|
|
463
|
+
hours
|
|
464
|
+
};
|
|
465
|
+
})
|
|
466
|
+
.sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
|
|
467
|
+
const total = zeroBuckets();
|
|
468
|
+
for (const [, entry] of byDay) addInto(total, entry.totals);
|
|
469
|
+
return {
|
|
470
|
+
days,
|
|
471
|
+
total: {
|
|
472
|
+
...total,
|
|
473
|
+
tokens: totalTokens(total),
|
|
474
|
+
cacheHitRate: cacheHitRate(total),
|
|
475
|
+
cost: days.some((day) => day.cost === null)
|
|
476
|
+
? null
|
|
477
|
+
: roundCost(days.reduce((sum, day) => sum + day.cost, 0))
|
|
478
|
+
},
|
|
479
|
+
updatedAt
|
|
480
|
+
};
|
|
481
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wannanbigpig/dsh-usage-stats",
|
|
3
|
+
"description": "DeepSeek 官方余额、Token 用量、月历热图与离线 tokenizer,内置在 Harness 侧栏",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/wannanbigpig/dsh-usage-stats.git"
|
|
8
|
+
},
|
|
9
|
+
"keywords": [
|
|
10
|
+
"deepseek",
|
|
11
|
+
"deepseek-harness",
|
|
12
|
+
"dsh",
|
|
13
|
+
"dsh-plugin",
|
|
14
|
+
"token-usage",
|
|
15
|
+
"balance",
|
|
16
|
+
"usage-stats"
|
|
17
|
+
],
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/",
|
|
20
|
+
"cordis.patch.yml",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "lib/index.js",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": "./lib/index.js",
|
|
28
|
+
"./client": "./lib/client.js",
|
|
29
|
+
"./usage": "./lib/usage.js",
|
|
30
|
+
"./balance": "./lib/balance.js",
|
|
31
|
+
"./ledger": "./lib/ledger.js",
|
|
32
|
+
"./tokenizer": "./lib/tokenizer.js",
|
|
33
|
+
"./package.json": "./package.json"
|
|
34
|
+
},
|
|
35
|
+
"dsh": {
|
|
36
|
+
"bundle": {
|
|
37
|
+
"patch": "./cordis.patch.yml"
|
|
38
|
+
},
|
|
39
|
+
"client": {
|
|
40
|
+
"platform": "web",
|
|
41
|
+
"inject": [
|
|
42
|
+
"@deepseek-ai/dsh-client-locale",
|
|
43
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-primitives"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"check": "node --check lib/index.js && node --check lib/usage.js && node --check lib/balance.js && node --check lib/tokenizer.js && node --check lib/client.js && node --check scripts/count-tokens.mjs && node --check lib/ledger.js && node --check lib/pricing.js && node --check scripts/rebuild-today-legacy.mjs && node --check scripts/test-usage.mjs && node --check scripts/test-server.mjs && node --check scripts/smoke-client.mjs && node --check scripts/test-tokenizer.mjs",
|
|
50
|
+
"test": "npm run check && node scripts/test-usage.mjs && node scripts/test-tokenizer.mjs && node scripts/test-server.mjs && node scripts/smoke-client.mjs",
|
|
51
|
+
"test:usage": "node scripts/test-usage.mjs",
|
|
52
|
+
"test:tokenizer": "node scripts/test-tokenizer.mjs",
|
|
53
|
+
"test:server": "node scripts/test-server.mjs",
|
|
54
|
+
"test:client": "node scripts/smoke-client.mjs",
|
|
55
|
+
"tokens": "node scripts/count-tokens.mjs",
|
|
56
|
+
"whoami": "npm whoami --registry=https://registry.npmjs.org",
|
|
57
|
+
"publish:public": "npm publish --registry=https://registry.npmjs.org"
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=18.0.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"react": "^18.2.0",
|
|
64
|
+
"react-dom": "^18.2.0"
|
|
65
|
+
},
|
|
66
|
+
"publishConfig": {
|
|
67
|
+
"access": "public"
|
|
68
|
+
},
|
|
69
|
+
"license": "MIT",
|
|
70
|
+
"dependencies": {
|
|
71
|
+
"@huggingface/tokenizers": "^0.1.3"
|
|
72
|
+
}
|
|
73
|
+
}
|