@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/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
|
+
}
|
package/lib/tokenizer.js
ADDED
|
@@ -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
|
+
}
|