dsh-token-use 0.1.1 → 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/lib/pricing.js ADDED
@@ -0,0 +1,380 @@
1
+ /**
2
+ * DeepSeek API pricing for the usage dashboard.
3
+ *
4
+ * One HTTPS fetch per day at a fixed local hour (12:00 by default), an
5
+ * on-disk snapshot cache so restarts and offline runs keep working, and a
6
+ * bundled fallback for the very first run. Prices are per 1M tokens and are
7
+ * published per peak/off-peak window, so every recorded call is priced with
8
+ * the rate in effect at the moment it happened — a later price change never
9
+ * rewrites history.
10
+ *
11
+ * Only DeepSeek models are priced: a model name that does not name DeepSeek
12
+ * (claude, gpt, …) is reported as `excluded` and contributes no amount.
13
+ *
14
+ * @module dsh-token-use/pricing
15
+ */
16
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ export const URL_BY_CURRENCY = {
20
+ CNY: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing',
21
+ USD: 'https://api-docs.deepseek.com/quick_start/pricing',
22
+ }
23
+
24
+ const SYMBOL_BY_CURRENCY = { CNY: '¥', USD: '$' }
25
+ const REFRESH_HOUR = 12
26
+ const TIMEOUT_MS = 15000
27
+ const RETRY_MS = 3600e3
28
+ const MAX_SNAPSHOTS = 30
29
+ const USER_AGENT = 'dsh-token-use (+https://github.com/huangyuheng/dsh-token-use)'
30
+
31
+ /** Official peak window: UTC weekday mornings 01-04 and 06-10 (off-peak is half price). */
32
+ const DEFAULT_PEAK = { days: [1, 2, 3, 4, 5], hoursUtc: [[1, 4], [6, 10]] }
33
+
34
+ /** Built-in snapshot, used only until the first successful fetch. */
35
+ const BUILT_IN = {
36
+ CNY: [
37
+ { id: 'deepseek-flash', label: 'DeepSeek-V4.1-Flash', hit: { off: 0.02, peak: 0.04 }, miss: { off: 1, peak: 2 }, out: { off: 4, peak: 8 } },
38
+ { id: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro-0813', hit: { off: 0.15, peak: 0.3 }, miss: { off: 4.5, peak: 9 }, out: { off: 13.5, peak: 27 } },
39
+ ],
40
+ USD: [
41
+ { id: 'deepseek-flash', label: 'DeepSeek-V4.1-Flash', hit: { off: 0.003, peak: 0.006 }, miss: { off: 0.15, peak: 0.3 }, out: { off: 0.6, peak: 1.2 } },
42
+ { id: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro-0813', hit: { off: 0.022, peak: 0.044 }, miss: { off: 0.66, peak: 1.32 }, out: { off: 1.98, peak: 3.96 } },
43
+ ],
44
+ }
45
+
46
+ function builtIn(currency) {
47
+ const key = BUILT_IN[currency] === undefined ? 'CNY' : currency
48
+ return {
49
+ fetchedAt: 0,
50
+ source: 'built-in snapshot · DeepSeek official pricing 2026-09-14',
51
+ currency: key,
52
+ symbol: SYMBOL_BY_CURRENCY[key],
53
+ peak: DEFAULT_PEAK,
54
+ peakParsed: true,
55
+ builtIn: true,
56
+ models: BUILT_IN[key].map((model) => ({ ...model, hit: { ...model.hit }, miss: { ...model.miss }, out: { ...model.out } })),
57
+ }
58
+ }
59
+
60
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" }
61
+
62
+ /** Plain text of an HTML fragment: tags dropped, entities resolved, space collapsed. */
63
+ function textOf(html) {
64
+ return html
65
+ .replace(/<br\s*\/?>/gi, ' ')
66
+ .replace(/<[^>]*>/g, '')
67
+ .replace(/&(#?\w+);/g, (match, name) => ENTITIES[name] ?? match)
68
+ .replace(/\s+/g, ' ')
69
+ .trim()
70
+ }
71
+
72
+ function numberIn(text) {
73
+ const match = /-?\d+(?:\.\d+)?/.exec(text.replace(/,/g, ''))
74
+ return match === null ? undefined : Number(match[0])
75
+ }
76
+
77
+ /** Hour ranges from a clause such as `01:00 - 04:00 and 06:00 - 10:00`. */
78
+ function hourRanges(clause, shift) {
79
+ const ranges = []
80
+ for (const match of clause.matchAll(/(\d{1,2}):(\d{2})\s*[-–~]\s*(\d{1,2}):(\d{2})/g)) {
81
+ if (match[2] !== '00' || match[4] !== '00') return undefined
82
+ const from = (Number(match[1]) - shift + 24) % 24
83
+ const to = (Number(match[3]) - shift + 24) % 24
84
+ if (from >= to) return undefined
85
+ ranges.push([from, to])
86
+ }
87
+ return ranges.length === 0 ? undefined : ranges
88
+ }
89
+
90
+ /**
91
+ * Peak/off-peak window from the pricing page's footnote. Returns undefined when
92
+ * the wording is not recognised, so the caller keeps the previous window.
93
+ */
94
+ export function parsePeak(pageText) {
95
+ const english = /Peak hours are ([^.]+)/i.exec(pageText)
96
+ if (english !== null) {
97
+ const hoursUtc = hourRanges(english[1], 0)
98
+ const weekdays = /Monday\s+through\s+Friday/i.test(english[1]) ? [1, 2, 3, 4, 5] : undefined
99
+ if (hoursUtc !== undefined && weekdays !== undefined) return { days: weekdays, hoursUtc }
100
+ }
101
+ const chinese = /高峰时段为北京时间([^((。]+)/.exec(pageText)
102
+ if (chinese !== null) {
103
+ const hoursUtc = hourRanges(chinese[1], 8)
104
+ const weekdays = /周一至周五/.test(chinese[1]) ? [1, 2, 3, 4, 5] : undefined
105
+ if (hoursUtc !== undefined && weekdays !== undefined) return { days: weekdays, hoursUtc }
106
+ }
107
+ return undefined
108
+ }
109
+
110
+ /**
111
+ * Parse the official pricing table. Both the Chinese and the English page share
112
+ * one table shape: a model row, a model-version row, then price rows labelled
113
+ * cache-hit / cache-miss / output, each with an off-peak and a peak line.
114
+ * @throws when no price row could be read — the caller keeps its last snapshot.
115
+ */
116
+ export function parsePricing(html, options = {}) {
117
+ const pageText = textOf(html)
118
+ const table = /<table[\s\S]*?<\/table>/i.exec(html)
119
+ if (table === null) throw new Error('no pricing table in the response')
120
+ const rows = table[0].split(/<tr[^>]*>/i).slice(1).map((row) => [...row.matchAll(/<t[dh][^>]*>([\s\S]*?)<\/t[dh]>/gi)].map((cell) => textOf(cell[1])))
121
+ const header = rows.find((cells) => /^(MODEL|模型)$/i.test(cells[0] ?? ''))
122
+ if (header === undefined) throw new Error('no model header row in the pricing table')
123
+ const ids = header.slice(1).map((cell) => cell.replace(/\(\d+\)/g, '').trim()).filter((id) => id.length > 0)
124
+ const versionRow = rows.find((cells) => /^(MODEL VERSION|模型版本)$/i.test(cells[0] ?? ''))
125
+ const labels = versionRow === undefined ? [] : versionRow.slice(1)
126
+ if (ids.length === 0) throw new Error('no model columns in the pricing table')
127
+ const models = ids.map((id, index) => ({ id, label: labels[index] ?? id, hit: {}, miss: {}, out: {} }))
128
+ // The table groups each price with `rowspan`, so only the first row of a
129
+ // group names the field ("cache hit", …); later rows inherit it.
130
+ let field
131
+ for (const cells of rows) {
132
+ const joined = cells.join(' ')
133
+ const tier = /OFF-?\s?PEAK|空闲时段/i.test(joined) ? 'off' : /PEAK|高峰时段/i.test(joined) ? 'peak' : undefined
134
+ if (tier === undefined) continue
135
+ field = /CACHE\s+MISS|缓存未命中/i.test(joined) ? 'miss' : /CACHE\s+HIT|缓存命中/i.test(joined) ? 'hit' : /OUTPUT|输出/i.test(joined) ? 'out' : field
136
+ if (field === undefined) continue
137
+ const values = cells.slice(cells.length - ids.length).map(numberIn)
138
+ if (values.length !== ids.length || values.some((value) => value === undefined)) continue
139
+ values.forEach((value, index) => { models[index][field][tier] = value })
140
+ }
141
+ const complete = models.filter((model) => ['hit', 'miss', 'out'].every((field) => typeof model[field].off === 'number' && typeof model[field].peak === 'number'))
142
+ if (complete.length === 0) throw new Error('no complete price rows in the pricing table')
143
+ const currency = options.currency === 'USD' || pageText.includes('$') ? 'USD' : 'CNY'
144
+ const peak = parsePeak(pageText)
145
+ return {
146
+ fetchedAt: options.fetchedAt ?? Date.now(),
147
+ source: options.source ?? URL_BY_CURRENCY[currency],
148
+ currency,
149
+ symbol: SYMBOL_BY_CURRENCY[currency],
150
+ peak: peak ?? { days: [...DEFAULT_PEAK.days], hoursUtc: DEFAULT_PEAK.hoursUtc.map((range) => [...range]) },
151
+ peakParsed: peak !== undefined,
152
+ models: complete,
153
+ }
154
+ }
155
+
156
+ /** Is `time` inside the published peak window? */
157
+ export function isPeakAt(time, peak) {
158
+ const date = new Date(time)
159
+ if (!peak.days.includes(date.getUTCDay())) return false
160
+ const hour = date.getUTCHours()
161
+ return peak.hoursUtc.some(([from, to]) => hour >= from && hour < to)
162
+ }
163
+
164
+ /** Money for one usage record: cache-miss input, cache-hit input and output, per 1M tokens. */
165
+ export function costOf(usage, entry, peak) {
166
+ const tier = peak ? 'peak' : 'off'
167
+ const miss = Number(usage.inputTokens) || 0
168
+ const hit = Number(usage.cacheReadTokens) || 0
169
+ const out = Number(usage.outputTokens) || 0
170
+ return (miss * entry.miss[tier] + hit * entry.hit[tier] + out * entry.out[tier]) / 1e6
171
+ }
172
+
173
+ /** Distinct, non-version tokens of a model name — the words that identify a family. */
174
+ function familyTokens(name) {
175
+ return String(name)
176
+ .toLowerCase()
177
+ .split(/[^a-z0-9]+/)
178
+ .filter((token) => token.length > 1 && !/^\d/.test(token) && token !== 'deepseek')
179
+ }
180
+
181
+ /**
182
+ * Map one locally configured model name onto an official price entry.
183
+ * A name without "deepseek" in it is `excluded`; a DeepSeek name whose family
184
+ * word matches no entry is `unmatched` — neither contributes an amount.
185
+ */
186
+ export function resolveModel(name, snapshot) {
187
+ if (name === undefined || name === null || String(name).length === 0) return { status: 'excluded', reason: 'no model reported' }
188
+ const base = String(name).toLowerCase().split('/').pop() ?? ''
189
+ if (!base.includes('deepseek')) return { status: 'excluded', reason: 'not a DeepSeek model' }
190
+ const tokens = familyTokens(base)
191
+ let best
192
+ let bestScore = 0
193
+ for (const model of snapshot.models) {
194
+ const entryTokens = familyTokens(`${model.id} ${model.label ?? ''}`)
195
+ const score = entryTokens.filter((token) => tokens.includes(token)).length
196
+ if (score > bestScore) {
197
+ bestScore = score
198
+ best = model
199
+ }
200
+ }
201
+ if (best === undefined) return { status: 'unmatched', reason: 'no official price entry matches this model name' }
202
+ return { status: 'priced', id: best.id, label: best.label, hit: best.hit, miss: best.miss, out: best.out }
203
+ }
204
+
205
+ /** Snapshot history, oldest first: drop unusable entries and cap the tail. */
206
+ function normalizeSnapshots(snapshots) {
207
+ const list = Array.isArray(snapshots) ? snapshots : []
208
+ return list
209
+ .filter((snapshot) => Array.isArray(snapshot?.models) && snapshot.models.length > 0)
210
+ .map((snapshot) => (typeof snapshot.peak?.days?.[0] === 'number' ? snapshot : { ...snapshot, peak: builtIn(snapshot.currency).peak }))
211
+ .sort((a, b) => a.fetchedAt - b.fetchedAt)
212
+ .slice(-MAX_SNAPSHOTS)
213
+ }
214
+
215
+ /**
216
+ * Price book: the snapshot history, its disk cache, and the daily refresh.
217
+ * `pricingAt(time)` makes a day/month view quote the rates of that moment.
218
+ */
219
+ export class PricingStore {
220
+ /**
221
+ * @param dshHome - Harness home; the snapshot cache lives under it.
222
+ * @param options - `enabled`, `currency`, `refreshHour`, `url`, `timeoutMs`.
223
+ * @param imported - snapshot list to use instead of the cache file (worker threads).
224
+ */
225
+ constructor(dshHome, options = {}, imported = undefined) {
226
+ this.dir = join(dshHome, 'dsh-token-use')
227
+ this.path = join(this.dir, 'pricing.json')
228
+ this.enabled = options.enabled !== false
229
+ this.currency = options.currency === 'USD' ? 'USD' : 'CNY'
230
+ this.refreshHour = Number.isInteger(options.refreshHour) && options.refreshHour >= 0 && options.refreshHour <= 23 ? options.refreshHour : REFRESH_HOUR
231
+ this.url = typeof options.url === 'string' && options.url.length > 0 ? options.url : URL_BY_CURRENCY[this.currency]
232
+ this.timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : TIMEOUT_MS
233
+ this.snapshots = []
234
+ this.retryAt = 0
235
+ this.meta = { error: null, lastAttemptAt: 0, nextRefreshAt: 0 }
236
+ this.timer = null
237
+ this.entries = new WeakMap()
238
+ if (imported === undefined) this.load()
239
+ else this.snapshots = normalizeSnapshots(imported)
240
+ }
241
+
242
+ /** Price book for a worker thread: the host's snapshots, no cache, no timers. */
243
+ static hydrate(data) {
244
+ const currency = data?.currency === 'USD' ? 'USD' : 'CNY'
245
+ return new PricingStore(process.cwd(), { enabled: false, currency }, Array.isArray(data?.snapshots) ? data.snapshots : [])
246
+ }
247
+
248
+ /** Cached snapshots from disk (synchronous: the boot scan prices from them). */
249
+ load() {
250
+ let stored
251
+ try {
252
+ stored = JSON.parse(readFileSync(this.path, 'utf8'))
253
+ } catch {
254
+ stored = undefined
255
+ }
256
+ this.snapshots = normalizeSnapshots(stored?.snapshots)
257
+ if (stored?.error !== undefined) this.meta.error = stored.error ?? null
258
+ }
259
+
260
+ /** Rate in effect at `time`: the newest snapshot taken before it. */
261
+ pricingAt(time) {
262
+ const at = Number.isFinite(time) ? time : Date.now()
263
+ let chosen
264
+ for (const snapshot of this.snapshots) {
265
+ if (snapshot.fetchedAt <= at) chosen = snapshot
266
+ else break
267
+ }
268
+ return chosen ?? this.snapshots[0] ?? builtIn(this.currency)
269
+ }
270
+
271
+ /** Resolved price for one model name, memoized per snapshot. */
272
+ priceFor(name, time) {
273
+ const snapshot = this.pricingAt(time)
274
+ let cache = this.entries.get(snapshot)
275
+ if (cache === undefined) {
276
+ cache = new Map()
277
+ this.entries.set(snapshot, cache)
278
+ }
279
+ let resolved = cache.get(name)
280
+ if (resolved === undefined) {
281
+ resolved = resolveModel(name, snapshot)
282
+ cache.set(name, resolved)
283
+ }
284
+ return resolved
285
+ }
286
+
287
+ /** Money for one usage record, or 0 when the model is not a priced DeepSeek one. */
288
+ costOf(usage, name, time) {
289
+ const resolved = this.priceFor(name, time)
290
+ if (resolved.status !== 'priced') return 0
291
+ return costOf(usage, resolved, isPeakAt(Number.isFinite(time) ? time : Date.now(), this.pricingAt(time).peak))
292
+ }
293
+
294
+ nextRefreshAt(now = Date.now()) {
295
+ const next = new Date(now)
296
+ next.setHours(this.refreshHour, 0, 0, 0)
297
+ if (next.getTime() <= now) next.setDate(next.getDate() + 1)
298
+ return next.getTime()
299
+ }
300
+
301
+ persist() {
302
+ try {
303
+ mkdirSync(this.dir, { recursive: true })
304
+ const payload = JSON.stringify({ version: 1, snapshots: this.snapshots.slice(-MAX_SNAPSHOTS), error: this.meta.error })
305
+ const temporary = `${this.path}.tmp`
306
+ writeFileSync(temporary, payload)
307
+ renameSync(temporary, this.path)
308
+ } catch (error) {
309
+ this.meta.error = `could not cache prices: ${String(error?.message ?? error)}`
310
+ }
311
+ }
312
+
313
+ /** Fetch and store today's prices. Never throws; failures keep the last snapshot. */
314
+ async refresh(now = Date.now()) {
315
+ if (!this.enabled) return false
316
+ this.meta.lastAttemptAt = now
317
+ try {
318
+ const response = await fetch(this.url, {
319
+ redirect: 'follow',
320
+ headers: { 'user-agent': USER_AGENT, accept: 'text/html,application/xhtml+xml' },
321
+ signal: AbortSignal.timeout(this.timeoutMs),
322
+ })
323
+ if (!response.ok) throw new Error(`http ${response.status}`)
324
+ const snapshot = parsePricing(await response.text(), { currency: this.currency, source: this.url, fetchedAt: Date.now() })
325
+ this.snapshots = [...this.snapshots.filter((stored) => stored.fetchedAt !== snapshot.fetchedAt), snapshot].sort((a, b) => a.fetchedAt - b.fetchedAt).slice(-MAX_SNAPSHOTS)
326
+ this.retryAt = 0
327
+ this.meta.error = null
328
+ this.persist()
329
+ return true
330
+ } catch (error) {
331
+ this.meta.error = String(error?.message ?? error)
332
+ this.retryAt = now + RETRY_MS
333
+ this.persist()
334
+ return false
335
+ }
336
+ }
337
+
338
+ /** Refresh now when today's slot has not been fetched yet, then arm the daily timer. */
339
+ start(now = Date.now()) {
340
+ if (!this.enabled) return
341
+ const slot = this.nextRefreshAt(now) - 24 * 3600e3
342
+ const current = this.snapshots[this.snapshots.length - 1]
343
+ if (current === undefined || current.fetchedAt < slot) this.refresh(now).catch(() => {})
344
+ this.schedule(now)
345
+ }
346
+
347
+ schedule(now = Date.now()) {
348
+ const at = this.retryAt > now ? this.retryAt : this.nextRefreshAt(now)
349
+ this.meta.nextRefreshAt = at
350
+ if (this.timer !== null) clearTimeout(this.timer)
351
+ this.timer = setTimeout(() => {
352
+ this.refresh().finally(() => this.schedule())
353
+ }, Math.max(1000, at - now))
354
+ this.timer.unref?.()
355
+ }
356
+
357
+ describe(now = Date.now()) {
358
+ const current = this.snapshots[this.snapshots.length - 1] ?? builtIn(this.currency)
359
+ return {
360
+ enabled: this.enabled,
361
+ currency: current.currency,
362
+ symbol: current.symbol,
363
+ source: current.source,
364
+ fetchedAt: current.fetchedAt,
365
+ builtIn: current.builtIn === true,
366
+ peakParsed: current.peakParsed !== false,
367
+ peak: current.peak,
368
+ error: this.meta.error,
369
+ nextRefreshAt: this.meta.nextRefreshAt > 0 ? this.meta.nextRefreshAt : this.nextRefreshAt(now),
370
+ refreshHour: this.refreshHour,
371
+ snapshots: this.snapshots.length,
372
+ models: current.models.map((model) => ({ id: model.id, label: model.label, hit: model.hit, miss: model.miss, out: model.out })),
373
+ }
374
+ }
375
+
376
+ stop() {
377
+ if (this.timer !== null) clearTimeout(this.timer)
378
+ this.timer = null
379
+ }
380
+ }
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { parentPort, workerData } from 'node:worker_threads'
8
8
  import { UsageTracker } from './index.js'
9
+ import { PricingStore } from './pricing.js'
9
10
 
10
- const tracker = new UsageTracker(workerData.dshHome)
11
+ const tracker = new UsageTracker(workerData.dshHome, { pricing: PricingStore.hydrate(workerData.pricing) })
11
12
  await tracker.scanSessions({ worker: false })
12
13
  parentPort.postMessage(tracker.serialize())
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-token-use",
3
- "version": "0.1.1",
4
- "description": "Real-time token usage dashboard for DeepSeek Harness — live totals (input/output/cache/reasoning), filters by model, day, month and project, and a smooth trend chart. · 实时 Token 用量仪表盘:总量/输入/输出/缓存命中按模型、按天、按月、按项目随选,附趋势曲线。",
3
+ "version": "0.2.0",
4
+ "description": "Real-time token usage and cost dashboard for DeepSeek Harness — live totals (input/output/cache/reasoning), estimated spend per model/day/project from DeepSeek's official prices (refreshed daily, peak/off-peak aware), model/day/month/project filters and a smooth trend chart. · 实时 Token 用量与消费金额仪表盘:按模型/按天/按月/按项目查看用量与估算金额(取 DeepSeek 官网定价,每日自动更新,区分高峰/空闲),附趋势曲线。",
5
5
  "keywords": [
6
6
  "dsh",
7
7
  "dsh-plugin",