dsh-token-use 0.1.0 → 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/README.en.md +41 -11
- package/README.md +41 -11
- package/client/client.js +349 -44
- package/cordis.patch.yml +7 -0
- package/lib/index.js +121 -30
- package/lib/pricing.js +380 -0
- package/lib/scan-worker.js +2 -1
- package/package.json +2 -2
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
|
+
}
|
package/lib/scan-worker.js
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "Real-time token usage dashboard for DeepSeek Harness — live totals (input/output/cache/reasoning),
|
|
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",
|