@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/lib/ledger.js ADDED
@@ -0,0 +1,253 @@
1
+ /**
2
+ * dsh-usage-stats — call-level ledger.
3
+ *
4
+ * Records one entry per llm/stream model call attributed by COMPLETION time
5
+ * (the moment the usage chunk is reported; `completedAt`, falling back to
6
+ * `occurredAt` for legacy entries), so hourly buckets and peak/off-peak cost
7
+ * attribution match the provider's billing basis (usage report time) instead
8
+ * of the request start time. A request that starts at 17:59 and reports usage
9
+ * at 18:01 is billed in the 18:00 idle hour, exactly like the official usage
10
+ * page.
11
+ *
12
+ * The ledger folds through the SAME usage engine as session events by
13
+ * projecting each entry to a synthetic `assistant/message` event whose
14
+ * `time` is the attribution time — reusing replace-last-sample semantics,
15
+ * provider/model attribution, hourly buckets, and renderUsage unchanged.
16
+ *
17
+ * Pure module (no cordis imports) so it can be unit-tested offline.
18
+ *
19
+ * @module dsh-usage-stats/ledger
20
+ */
21
+
22
+ import { costOf, dayKey, defaultPricing, foldUsage, hourKey, renderUsage, roundCost } from "./usage.js";
23
+
24
+ /** Normalize one provider usage into bucket fields (missing → 0). */
25
+ function normalizeUsage(usage) {
26
+ return {
27
+ inputTokens: usage?.inputTokens ?? 0,
28
+ outputTokens: usage?.outputTokens ?? 0,
29
+ cacheReadTokens: usage?.cacheReadTokens ?? 0,
30
+ cacheWriteTokens: usage?.cacheWriteTokens ?? 0
31
+ };
32
+ }
33
+
34
+ /** Structural equality of two normalized usage records. */
35
+ function usageEquals(a, b) {
36
+ return a.inputTokens === b.inputTokens
37
+ && a.outputTokens === b.outputTokens
38
+ && a.cacheReadTokens === b.cacheReadTokens
39
+ && a.cacheWriteTokens === b.cacheWriteTokens;
40
+ }
41
+
42
+ /**
43
+ * Append one call-level ledger entry (mutating). Entries with the same stable
44
+ * call ID are deduplicated in O(1) via a per-ledger id index (a WeakMap keyed
45
+ * by the array itself); legacy entries without IDs retain the adjacent
46
+ * content-based fallback for compatibility. Returns the ledger.
47
+ * @param ledger - the ledger array (mutated in place).
48
+ * @param entry - { occurredAt, provider, model, usage }.
49
+ * @returns the ledger.
50
+ */
51
+ export function appendLedger(ledger, entry) {
52
+ const normalized = {
53
+ ...(typeof entry?.id === "string" && entry.id !== "" ? { id: entry.id } : {}),
54
+ occurredAt: Number(entry?.occurredAt) || 0,
55
+ ...(Number(entry?.completedAt) > 0 ? { completedAt: Number(entry.completedAt) } : {}),
56
+ provider: typeof entry?.provider === "string" && entry.provider !== "" ? entry.provider : "unknown",
57
+ model: typeof entry?.model === "string" && entry.model !== "" ? entry.model : "unknown",
58
+ usage: normalizeUsage(entry?.usage),
59
+ ...(Object.hasOwn(entry ?? {}, "costCny") ? { costCny: entry.costCny === null ? null : Number(entry.costCny) } : {}),
60
+ ...(typeof entry?.pricingVersion === "string" && entry.pricingVersion !== "" ? { pricingVersion: entry.pricingVersion } : {})
61
+ };
62
+ const last = ledger[ledger.length - 1];
63
+ const ids = idIndexOf(ledger);
64
+ if (normalized.id !== void 0 && ids.has(normalized.id)) return ledger;
65
+ if (normalized.id === void 0 && last !== void 0 && last.id === void 0
66
+ && last.occurredAt === normalized.occurredAt
67
+ && last.provider === normalized.provider
68
+ && last.model === normalized.model
69
+ && usageEquals(last.usage, normalized.usage)) {
70
+ return ledger;
71
+ }
72
+ ledger.push(normalized);
73
+ if (normalized.id !== void 0) ids.add(normalized.id);
74
+ return ledger;
75
+ }
76
+
77
+ /**
78
+ * Per-ledger Set of known stable call IDs, memoized in a WeakMap keyed by the
79
+ * ledger array itself (no leak: the entry dies with the array). A miss — e.g.
80
+ * after loadCache filters `parseLedger(...)` into a brand-new array — scans
81
+ * the array once to rebuild the index.
82
+ */
83
+ const ledgerIdIndexes = new WeakMap();
84
+ function idIndexOf(ledger) {
85
+ let ids = ledgerIdIndexes.get(ledger);
86
+ if (ids === void 0) {
87
+ ids = new Set();
88
+ for (const item of ledger) {
89
+ if (typeof item?.id === "string" && item.id !== "") ids.add(item.id);
90
+ }
91
+ ledgerIdIndexes.set(ledger, ids);
92
+ }
93
+ return ids;
94
+ }
95
+
96
+ /**
97
+ * Trim the ledger to its newest `keepCount` entries (oldest dropped) and
98
+ * return the removed entries, oldest first, so the caller can fold them into
99
+ * the legacy snapshot. Rebuilds the id-dedup index afterwards: without that,
100
+ * ids of removed entries would linger in the Set and wrongly dedup a later
101
+ * re-record of the same id. Keeps the WeakMap key (the array) stable.
102
+ * @param ledger - the ledger array (mutated in place).
103
+ * @param keepCount - how many newest entries to keep.
104
+ * @returns the removed entries ([] when nothing was trimmed).
105
+ */
106
+ export function compactLedger(ledger, keepCount) {
107
+ const keep = Math.max(0, Math.floor(Number(keepCount) || 0));
108
+ const overflow = ledger.length - keep;
109
+ if (overflow <= 0) return [];
110
+ const removed = ledger.splice(0, overflow);
111
+ const ids = new Set();
112
+ for (const item of ledger) {
113
+ if (typeof item?.id === "string" && item.id !== "") ids.add(item.id);
114
+ }
115
+ ledgerIdIndexes.set(ledger, ids);
116
+ return removed;
117
+ }
118
+
119
+ /** Stable fingerprint for the exact price configuration used by a call. */
120
+ export function pricingVersionOf(pricing = defaultPricing()) {
121
+ const models = Object.entries(pricing?.pricing ?? {})
122
+ .sort(([left], [right]) => left.localeCompare(right))
123
+ .map(([model, row]) => [model, Number(row?.inputMiss) || 0, Number(row?.inputHit) || 0, Number(row?.output) || 0]);
124
+ const canonical = JSON.stringify({
125
+ currency: pricing?.currency ?? "CNY",
126
+ peakHours: pricing?.peakHours ?? [],
127
+ peakMultiplier: Number(pricing?.peakMultiplier) || 1,
128
+ models
129
+ });
130
+ let hash = 2166136261;
131
+ for (let index = 0; index < canonical.length; index += 1) {
132
+ hash ^= canonical.charCodeAt(index);
133
+ hash = Math.imul(hash, 16777619);
134
+ }
135
+ return `${typeof pricing?.id === "string" && pricing.id !== "" ? pricing.id : "pricing"}-${(hash >>> 0).toString(16).padStart(8, "0")}`;
136
+ }
137
+
138
+ /** Freeze one call's price result at occurrence time. */
139
+ export function freezeLedgerEntry(entry, pricing = defaultPricing()) {
140
+ const occurredAt = Number(entry?.occurredAt) || 0;
141
+ const provider = typeof entry?.provider === "string" && entry.provider !== "" ? entry.provider : "unknown";
142
+ const model = typeof entry?.model === "string" && entry.model !== "" ? entry.model : "unknown";
143
+ const usage = normalizeUsage(entry?.usage);
144
+ const attributionAt = Number(entry?.completedAt) > 0 ? Number(entry.completedAt) : occurredAt;
145
+ const cost = costOf(`${provider}/${model}`, usage, hourKey(attributionAt), pricing);
146
+ return {
147
+ ...(typeof entry?.id === "string" && entry.id !== "" ? { id: entry.id } : {}),
148
+ occurredAt,
149
+ ...(Number(entry?.completedAt) > 0 ? { completedAt: Number(entry.completedAt) } : {}),
150
+ provider,
151
+ model,
152
+ usage,
153
+ pricingVersion: pricingVersionOf(pricing),
154
+ costCny: cost === null ? null : roundCost(cost)
155
+ };
156
+ }
157
+
158
+ function addCost(index, key, value) {
159
+ let state = index.get(key);
160
+ if (state === void 0) {
161
+ state = { sum: 0, unpriced: false };
162
+ index.set(key, state);
163
+ }
164
+ if (value === null || !Number.isFinite(Number(value))) state.unpriced = true;
165
+ else state.sum += Number(value);
166
+ }
167
+
168
+ function costValue(index, key) {
169
+ const state = index.get(key);
170
+ if (state === void 0 || state.unpriced) return null;
171
+ return roundCost(state.sum);
172
+ }
173
+
174
+ /** Replace render-time estimates with the prices frozen on ledger entries. */
175
+ function applyFrozenCosts(rendered, ledger, pricing) {
176
+ const costs = new Map();
177
+ for (const entry of ledger) {
178
+ const attributionAt = Number(entry.completedAt) > 0 ? entry.completedAt : entry.occurredAt;
179
+ const date = dayKey(attributionAt);
180
+ const hour = hourKey(attributionAt);
181
+ const model = `${entry.provider}/${entry.model}`;
182
+ const frozen = Object.hasOwn(entry, "costCny")
183
+ ? entry.costCny
184
+ : costOf(model, normalizeUsage(entry.usage), hour, pricing);
185
+ addCost(costs, `d:${date}`, frozen);
186
+ addCost(costs, `m:${date}:${model}`, frozen);
187
+ addCost(costs, `h:${date}:${hour}`, frozen);
188
+ addCost(costs, `hm:${date}:${hour}:${model}`, frozen);
189
+ }
190
+ for (const day of rendered.days) {
191
+ day.cost = costValue(costs, `d:${day.date}`);
192
+ for (const model of day.models) model.cost = costValue(costs, `m:${day.date}:${model.model}`);
193
+ for (const hour of day.hours) {
194
+ hour.cost = costValue(costs, `h:${day.date}:${hour.hour}`);
195
+ for (const model of hour.models) model.cost = costValue(costs, `hm:${day.date}:${hour.hour}:${model.model}`);
196
+ }
197
+ }
198
+ rendered.total.cost = rendered.days.some((day) => day.cost === null)
199
+ ? null
200
+ : roundCost(rendered.days.reduce((sum, day) => sum + day.cost, 0));
201
+ return rendered;
202
+ }
203
+
204
+ /**
205
+ * Project the ledger into synthetic `assistant/message` events whose
206
+ * `time` is the attribution time (completion time per the provider billing
207
+ * basis; `completedAt` when recorded, else `occurredAt`). Steps are unique
208
+ * so the replace-last-sample semantics never collide entries.
209
+ * @param ledger - the call-level ledger.
210
+ * @returns synthetic session events in ledger order.
211
+ */
212
+ export function ledgerToEvents(ledger) {
213
+ return ledger.map((entry, index) => ({
214
+ type: "assistant/message",
215
+ // 官方账单按请求完成时间(usage 上报时间)归小时并判峰谷;
216
+ // completedAt 即 usage 捕获时刻,旧条目回退到 occurredAt。
217
+ time: entry.completedAt ?? entry.occurredAt,
218
+ data: {
219
+ turn: 0,
220
+ step: index + 1,
221
+ message: {
222
+ role: "assistant",
223
+ content: [],
224
+ source: { kind: "model", provider: entry.provider, model: entry.model }
225
+ },
226
+ usage: entry.usage
227
+ }
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * Fold the ledger into the per-day/hour/model map, attributed by completion
233
+ * time (`completedAt`, else `occurredAt`) on the Beijing calendar.
234
+ * @param ledger - the call-level ledger.
235
+ * @returns Map<YYYY-MM-DD, { totals, models, hours }>.
236
+ */
237
+ export function foldLedger(ledger) {
238
+ return foldUsage(ledgerToEvents(ledger));
239
+ }
240
+
241
+ /**
242
+ * Render the ledger with the same wire shape as `renderUsage` (days/total/
243
+ * hours/models + estimated cost), so the existing client consumes it without
244
+ * changes. Peak/off-peak cost follows the COMPLETION hour of each call
245
+ * (completedAt, else occurredAt).
246
+ * @param ledger - the call-level ledger.
247
+ * @param updatedAt - computation timestamp.
248
+ * @param pricing - merged pricing configuration (see defaultPricing).
249
+ * @returns the usage wire shape.
250
+ */
251
+ export function renderLedger(ledger, updatedAt, pricing = defaultPricing()) {
252
+ return applyFrozenCosts(renderUsage(foldLedger(ledger), updatedAt, pricing), ledger, pricing);
253
+ }
package/lib/pricing.js ADDED
@@ -0,0 +1,85 @@
1
+ /** Versioned DeepSeek pricing helpers. The legacy `pricing`/`peakMultiplier`
2
+ * fields remain supported so existing plugin configuration and cache entries
3
+ * continue to load without a destructive migration. */
4
+
5
+ export const OFFICIAL_PRICING_SOURCE = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing/";
6
+
7
+ export function defaultPricingVersion(checkedAt = "2026-08-18T00:00:00.000Z") {
8
+ return {
9
+ id: "deepseek-cn-official-2026-08",
10
+ name: "DeepSeek 中国区官方价格",
11
+ currency: "CNY",
12
+ timezone: "Asia/Shanghai",
13
+ sourceUrl: OFFICIAL_PRICING_SOURCE,
14
+ checkedAt,
15
+ effectiveFrom: checkedAt,
16
+ mode: "official",
17
+ windows: [
18
+ { id: "peak-am", start: "09:00", end: "12:00", tier: "peak" },
19
+ { id: "peak-pm", start: "14:00", end: "18:00", tier: "peak" }
20
+ ],
21
+ models: {
22
+ "deepseek-v4-flash": {
23
+ offPeak: { inputHit: 0.05, inputMiss: 1.5, output: 4.5 },
24
+ peak: { inputHit: 0.10, inputMiss: 3, output: 9 }
25
+ },
26
+ "deepseek-v4-pro": {
27
+ offPeak: { inputHit: 0.15, inputMiss: 4.5, output: 13.5 },
28
+ peak: { inputHit: 0.30, inputMiss: 9, output: 27 }
29
+ }
30
+ }
31
+ };
32
+ }
33
+
34
+ export function normalizePricing(raw = {}) {
35
+ const base = defaultPricingVersion();
36
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return base;
37
+ const models = raw.models && typeof raw.models === "object" && !Array.isArray(raw.models) ? raw.models : {};
38
+ const legacy = raw.pricing && typeof raw.pricing === "object" && !Array.isArray(raw.pricing) ? raw.pricing : {};
39
+ for (const [model, row] of Object.entries(models)) {
40
+ if (row === null || typeof row !== "object") continue;
41
+ const off = row.offPeak ?? row.offpeak ?? row;
42
+ const peak = row.peak ?? off;
43
+ base.models[model] = {
44
+ offPeak: { inputHit: finite(off.inputHit, 0), inputMiss: finite(off.inputMiss, 0), output: finite(off.output, 0) },
45
+ peak: { inputHit: finite(peak.inputHit, finite(off.inputHit, 0)), inputMiss: finite(peak.inputMiss, finite(off.inputMiss, 0)), output: finite(peak.output, finite(off.output, 0)) }
46
+ };
47
+ }
48
+ // Apply legacy overrides last. validateConfig builds a merged object that
49
+ // contains both default versioned `models` and user-provided legacy
50
+ // `pricing`; the explicit user values must win over those defaults.
51
+ for (const [model, row] of Object.entries(legacy)) {
52
+ if (row === null || typeof row !== "object") continue;
53
+ const offPeak = {
54
+ inputHit: finite(row.inputHit, 0), inputMiss: finite(row.inputMiss, 0), output: finite(row.output, 0)
55
+ };
56
+ base.models[model] = { offPeak, peak: {
57
+ inputHit: finite(row.peak?.inputHit, offPeak.inputHit * (Number(raw.peakMultiplier) || 1)),
58
+ inputMiss: finite(row.peak?.inputMiss, offPeak.inputMiss * (Number(raw.peakMultiplier) || 1)),
59
+ output: finite(row.peak?.output, offPeak.output * (Number(raw.peakMultiplier) || 1))
60
+ } };
61
+ }
62
+ if (typeof raw.id === "string" && raw.id.trim()) base.id = raw.id.trim();
63
+ if (typeof raw.name === "string" && raw.name.trim()) base.name = raw.name.trim();
64
+ if (typeof raw.sourceUrl === "string" && raw.sourceUrl.trim()) base.sourceUrl = raw.sourceUrl.trim();
65
+ if (typeof raw.checkedAt === "string") base.checkedAt = raw.checkedAt;
66
+ if (typeof raw.effectiveFrom === "string") base.effectiveFrom = raw.effectiveFrom;
67
+ if (raw.mode === "custom" || raw.mode === "official") base.mode = raw.mode;
68
+ if (typeof raw.timezone === "string" && raw.timezone.trim()) base.timezone = raw.timezone.trim();
69
+ if (Array.isArray(raw.windows)) base.windows = raw.windows.filter((w) => w && typeof w.start === "string" && typeof w.end === "string").map((w, i) => ({ id: typeof w.id === "string" ? w.id : `window-${i + 1}`, start: w.start, end: w.end, tier: w.tier === "offPeak" ? "offPeak" : "peak" }));
70
+ if (typeof raw.currency === "string" && raw.currency.trim()) base.currency = raw.currency.trim();
71
+ // Legacy wire consumers still read these fields.
72
+ base.pricing = Object.fromEntries(Object.entries(base.models).map(([model, row]) => [model, row.offPeak]));
73
+ base.peakHours = Array.isArray(raw.peakHours) ? raw.peakHours : [[9, 12], [14, 18]];
74
+ base.peakMultiplier = Number(raw.peakMultiplier) || 2;
75
+ return base;
76
+ }
77
+
78
+ export function migratePricingConfig(raw = {}) {
79
+ return normalizePricing(raw);
80
+ }
81
+
82
+ function finite(value, fallback) {
83
+ const n = Number(value);
84
+ return Number.isFinite(n) ? n : fallback;
85
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Offline DeepSeek tokenizer support.
3
+ *
4
+ * This module loads the official Hugging Face `tokenizer.json` and
5
+ * `tokenizer_config.json` supplied by the user. It is intentionally separate
6
+ * from billing aggregation: local encoding can estimate visible text, while
7
+ * provider-reported `usage` remains the source of truth for actual requests.
8
+ */
9
+ import { readFile } from "node:fs/promises";
10
+ import { resolve } from "node:path";
11
+ import { Tokenizer } from "@huggingface/tokenizers";
12
+
13
+ async function readTokenizerJson(path, fileName) {
14
+ const file = resolve(path, fileName);
15
+ let source;
16
+ try {
17
+ source = await readFile(file, "utf8");
18
+ } catch (error) {
19
+ throw new Error(`cannot read ${fileName} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
20
+ }
21
+ try {
22
+ return JSON.parse(source);
23
+ } catch (error) {
24
+ throw new Error(`invalid ${fileName} at ${file}: ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+ }
27
+
28
+ /** Load a tokenizer directory exported by DeepSeek/Hugging Face. */
29
+ export async function loadDeepSeekTokenizer(tokenizerDir) {
30
+ if (typeof tokenizerDir !== "string" || tokenizerDir.trim() === "") {
31
+ throw new Error("tokenizerDir must point to a directory containing tokenizer.json and tokenizer_config.json");
32
+ }
33
+ const dir = resolve(tokenizerDir);
34
+ // Read in a stable order so a missing directory always reports the primary
35
+ // tokenizer file first instead of whichever parallel read rejects first.
36
+ const tokenizerJson = await readTokenizerJson(dir, "tokenizer.json");
37
+ const tokenizerConfig = await readTokenizerJson(dir, "tokenizer_config.json");
38
+ return new Tokenizer(tokenizerJson, tokenizerConfig);
39
+ }
40
+
41
+ /**
42
+ * Encode visible text with the official tokenizer files. Special tokens
43
+ * (BOS/EOS) added by the tokenizer's post-processor are excluded by default
44
+ * so only the visible text is measured; pass `includeSpecialTokens: true`
45
+ * to include them.
46
+ */
47
+ export async function countTextTokens(text, options = {}) {
48
+ if (typeof text !== "string") throw new Error("text must be a string");
49
+ const tokenizer = options.tokenizer ?? await loadDeepSeekTokenizer(options.tokenizerDir);
50
+ const encoded = tokenizer.encode(text, { add_special_tokens: options.includeSpecialTokens === true });
51
+ return {
52
+ count: encoded.ids.length,
53
+ ...(options.includeIds === true ? { ids: [...encoded.ids] } : {})
54
+ };
55
+ }