dsh-usage-badge 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 +89 -0
- package/assets/usage-dialog.png +0 -0
- package/cordis.patch.yml +7 -0
- package/docs/design-notes.md +293 -0
- package/lib/client.js +1007 -0
- package/lib/fold.js +512 -0
- package/lib/holidays-cn.json +41 -0
- package/lib/holidays.js +141 -0
- package/lib/index.js +543 -0
- package/lib/official-pricing.js +316 -0
- package/lib/pricing.js +485 -0
- package/package.json +63 -0
package/lib/fold.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — session-log folding and its incremental cache.
|
|
3
|
+
*
|
|
4
|
+
* DSH persists one append-only log per session under `$DSH_HOME/sessions`. This
|
|
5
|
+
* module folds every log it finds into per-day / per-(provider, model) token
|
|
6
|
+
* buckets holding the raw per-request usage records, which is what lets the
|
|
7
|
+
* pricing layer apply a different multiplier to each request instead of pricing
|
|
8
|
+
* a day at one flat rate.
|
|
9
|
+
*
|
|
10
|
+
* Ported from the desktop shell's `usage-sidecar.mjs` fold, with the same
|
|
11
|
+
* discovery rules (generation selection per session directory) and the same
|
|
12
|
+
* size/mtime cursor that keeps a refresh proportional to what actually changed.
|
|
13
|
+
* The cache file is this plugin's own (`usage-badge-cache.json`) rather than the
|
|
14
|
+
* shell's `usage-cache.json`: both read the same logs, but sharing one cache file
|
|
15
|
+
* would mean two writers racing over it.
|
|
16
|
+
*
|
|
17
|
+
* @module dsh-usage-badge/fold
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mkdirSync, readFileSync, readdirSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
21
|
+
import { homedir } from 'node:os'
|
|
22
|
+
import { join, resolve } from 'node:path'
|
|
23
|
+
import { zstdDecompressSync } from 'node:zlib'
|
|
24
|
+
|
|
25
|
+
import { dayKey, initHourly, localHour } from './pricing.js'
|
|
26
|
+
|
|
27
|
+
/** Bucket key and display name used when a log records no provider/model. */
|
|
28
|
+
export const UNKNOWN = 'unknown'
|
|
29
|
+
|
|
30
|
+
/** Matches `session.jsonl`, `session.jsonl.zstd`, `session.v2.jsonl(.zstd)`, … */
|
|
31
|
+
const SESSION_LOG_RE = /^session(?:\.v(\d+))?\.jsonl(?:\.zstd)?$/
|
|
32
|
+
|
|
33
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
34
|
+
|
|
35
|
+
/** This plugin's cache format version. Unknown versions are ignored, not migrated. */
|
|
36
|
+
const CACHE_VERSION = 1
|
|
37
|
+
|
|
38
|
+
/** Files folded between event-loop yields, so a cold scan never stalls the host. */
|
|
39
|
+
const YIELD_EVERY = 25
|
|
40
|
+
|
|
41
|
+
/** Expand `~`, `~/`, `~\` against the OS home. */
|
|
42
|
+
function expandHome(path) {
|
|
43
|
+
if (path === '~') return homedir()
|
|
44
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(homedir(), path.slice(2))
|
|
45
|
+
return path
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Resolve the DeepSeek Harness home.
|
|
50
|
+
*
|
|
51
|
+
* This mirrors `resolveDshHome()` from `@deepseek-ai/dsh-home-paths`, inlined so
|
|
52
|
+
* the plugin needs no peer dependency: an explicit path, then a non-blank
|
|
53
|
+
* `$DSH_HOME`, then `~/.dsh`. A whitespace-only `$DSH_HOME` counts as unset, so a
|
|
54
|
+
* blank override can never resolve the home to the current directory.
|
|
55
|
+
*/
|
|
56
|
+
export function resolveDshHome(configured, env = process.env) {
|
|
57
|
+
const fromEnv = env?.DSH_HOME
|
|
58
|
+
const chosen =
|
|
59
|
+
configured ?? (typeof fromEnv === 'string' && fromEnv.trim().length > 0 ? fromEnv : join(homedir(), '.dsh'))
|
|
60
|
+
return resolve(expandHome(chosen))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Decode a log body that may hold one or more concatenated zstd frames.
|
|
65
|
+
*
|
|
66
|
+
* DSH appends frames as a session grows, so a `.zstd` log is a frame sequence
|
|
67
|
+
* rather than one stream; `zstdDecompressSync` handles a single frame, so the
|
|
68
|
+
* buffer is split on the frame magic first. A body with no frame magic is
|
|
69
|
+
* returned as UTF-8, which keeps plain `.jsonl` and any partially written file
|
|
70
|
+
* readable instead of throwing.
|
|
71
|
+
*/
|
|
72
|
+
export function decodeLogBody(buf) {
|
|
73
|
+
const starts = []
|
|
74
|
+
let i = 0
|
|
75
|
+
while ((i = buf.indexOf(ZSTD_MAGIC, i)) !== -1) {
|
|
76
|
+
starts.push(i)
|
|
77
|
+
i += 1
|
|
78
|
+
}
|
|
79
|
+
if (starts.length === 0) return buf.toString('utf8')
|
|
80
|
+
if (typeof zstdDecompressSync !== 'function') {
|
|
81
|
+
throw new Error('this Node build has no zlib.zstdDecompressSync; a zstd session log cannot be read')
|
|
82
|
+
}
|
|
83
|
+
let out = ''
|
|
84
|
+
for (let k = 0; k < starts.length; k++) {
|
|
85
|
+
const end = k + 1 < starts.length ? starts[k + 1] : buf.length
|
|
86
|
+
out += zstdDecompressSync(buf.subarray(starts[k], end)).toString('utf8')
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function logParent(file) {
|
|
92
|
+
const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
|
|
93
|
+
return index >= 0 ? file.slice(0, index) : ''
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Rank a log file within its session directory: generation first, then `.zstd` over plain. */
|
|
97
|
+
function logRank(file) {
|
|
98
|
+
const index = Math.max(file.lastIndexOf('\\'), file.lastIndexOf('/'))
|
|
99
|
+
const name = index >= 0 ? file.slice(index + 1) : file
|
|
100
|
+
const match = SESSION_LOG_RE.exec(name)
|
|
101
|
+
if (!match) return -1
|
|
102
|
+
return (Number(match[1]) || 0) * 2 + (name.endsWith('.zstd') ? 1 : 0)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every log file under `dir`, keeping only the newest generation per session
|
|
107
|
+
* directory.
|
|
108
|
+
*
|
|
109
|
+
* A migrated session can leave older artifacts beside its current log; those are
|
|
110
|
+
* generations of one session, not sessions to add up. Picking the highest
|
|
111
|
+
* generation per directory is also what keeps a future `v3`/`v4` log correct
|
|
112
|
+
* without a code change.
|
|
113
|
+
*/
|
|
114
|
+
export function listLogFiles(dir, root = dir, out = []) {
|
|
115
|
+
let entries
|
|
116
|
+
try {
|
|
117
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
118
|
+
} catch {
|
|
119
|
+
return out
|
|
120
|
+
}
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
const full = join(dir, entry.name)
|
|
123
|
+
if (entry.isDirectory()) listLogFiles(full, root, out)
|
|
124
|
+
else if (SESSION_LOG_RE.test(entry.name)) out.push(full)
|
|
125
|
+
}
|
|
126
|
+
if (dir === root) {
|
|
127
|
+
const best = new Map()
|
|
128
|
+
for (const file of out) {
|
|
129
|
+
const parent = logParent(file)
|
|
130
|
+
const current = best.get(parent)
|
|
131
|
+
if (!current || logRank(file) > logRank(current)) best.set(parent, file)
|
|
132
|
+
}
|
|
133
|
+
return out.filter((file) => best.get(logParent(file)) === file)
|
|
134
|
+
}
|
|
135
|
+
return out
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The session id a log path belongs to (its containing directory name). */
|
|
139
|
+
export function sessionIdFromLogPath(file) {
|
|
140
|
+
const parts = file.split(/[\\/]/)
|
|
141
|
+
return parts.length >= 2 ? parts[parts.length - 2] : file
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function emptyBucket(provider, model) {
|
|
145
|
+
return {
|
|
146
|
+
provider,
|
|
147
|
+
model,
|
|
148
|
+
input: 0,
|
|
149
|
+
cacheRead: 0,
|
|
150
|
+
cacheWrite: 0,
|
|
151
|
+
output: 0,
|
|
152
|
+
requests: 0,
|
|
153
|
+
hourly: initHourly(),
|
|
154
|
+
usageRecords: [],
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add `source` into `target`.
|
|
160
|
+
*
|
|
161
|
+
* A missing per-request list marks a legacy aggregate row: mixing one into a
|
|
162
|
+
* bucket that has a list would silently drop the aggregate's share from the
|
|
163
|
+
* context-tier price, so the list is dropped to `null` for the whole bucket
|
|
164
|
+
* instead, and that bucket falls back to flat pricing. Appending is looped rather
|
|
165
|
+
* than spread because one day bucket can hold hundreds of thousands of requests,
|
|
166
|
+
* which would overflow the argument list long before it exhausted memory.
|
|
167
|
+
*/
|
|
168
|
+
function addBucket(target, source) {
|
|
169
|
+
target.input += Number(source?.input) || 0
|
|
170
|
+
target.cacheRead += Number(source?.cacheRead) || 0
|
|
171
|
+
target.cacheWrite += Number(source?.cacheWrite) || 0
|
|
172
|
+
target.output += Number(source?.output) || 0
|
|
173
|
+
target.requests += Number(source?.requests) || 0
|
|
174
|
+
if (!Array.isArray(target.hourly)) target.hourly = initHourly()
|
|
175
|
+
if (Array.isArray(source?.hourly)) {
|
|
176
|
+
for (let hour = 0; hour < 24; hour++) {
|
|
177
|
+
const from = source.hourly[hour]
|
|
178
|
+
const to = target.hourly[hour]
|
|
179
|
+
if (!from || !to) continue
|
|
180
|
+
to.input += Number(from.input) || 0
|
|
181
|
+
to.cacheRead += Number(from.cacheRead) || 0
|
|
182
|
+
to.cacheWrite += Number(from.cacheWrite) || 0
|
|
183
|
+
to.output += Number(from.output) || 0
|
|
184
|
+
to.requests += Number(from.requests) || 0
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!Array.isArray(target.usageRecords) || !Array.isArray(source?.usageRecords)) {
|
|
188
|
+
target.usageRecords = null
|
|
189
|
+
} else {
|
|
190
|
+
for (const usage of source.usageRecords) {
|
|
191
|
+
if (usage && typeof usage === 'object') target.usageRecords.push(usage)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Fold one session log into per-day, per-route buckets.
|
|
198
|
+
*
|
|
199
|
+
* Route attribution follows the log's own control events: `request/header` (and
|
|
200
|
+
* `request/context`) carry the provider and model, and each `assistant/message`
|
|
201
|
+
* carries the provider-reported `usage` for one completed request. Requests are
|
|
202
|
+
* stamped with the hour they were *sent* where the log allows it, because a
|
|
203
|
+
* streaming call can cross a peak/valley boundary and filing it by completion
|
|
204
|
+
* would price it in the wrong band.
|
|
205
|
+
*
|
|
206
|
+
* @returns {object|null} the session record, or null when the file is unreadable.
|
|
207
|
+
*/
|
|
208
|
+
export function foldSession(file, previous) {
|
|
209
|
+
const sessionId = sessionIdFromLogPath(file)
|
|
210
|
+
let stat
|
|
211
|
+
try {
|
|
212
|
+
stat = statSync(file)
|
|
213
|
+
} catch {
|
|
214
|
+
return null
|
|
215
|
+
}
|
|
216
|
+
let body
|
|
217
|
+
try {
|
|
218
|
+
body = readFileSync(file)
|
|
219
|
+
} catch {
|
|
220
|
+
return null
|
|
221
|
+
}
|
|
222
|
+
let text
|
|
223
|
+
try {
|
|
224
|
+
text = decodeLogBody(body)
|
|
225
|
+
} catch {
|
|
226
|
+
// An unreadable body keeps whatever this session contributed before, so a
|
|
227
|
+
// transient decompression failure cannot erase history.
|
|
228
|
+
return previous ?? null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const sessionDays = new Map()
|
|
232
|
+
let provider = null
|
|
233
|
+
let model = null
|
|
234
|
+
let maxSeq = -1
|
|
235
|
+
|
|
236
|
+
for (const line of text.split('\n')) {
|
|
237
|
+
if (!line) continue
|
|
238
|
+
let ev
|
|
239
|
+
try {
|
|
240
|
+
ev = JSON.parse(line)
|
|
241
|
+
} catch {
|
|
242
|
+
continue // a torn tail line is expected on a log being appended
|
|
243
|
+
}
|
|
244
|
+
const seq = typeof ev?.seq === 'number' ? ev.seq : -1
|
|
245
|
+
if (seq > maxSeq) maxSeq = seq
|
|
246
|
+
const data = ev?.data ?? {}
|
|
247
|
+
if (ev?.type === 'request/header') {
|
|
248
|
+
const config = data.header?.config ?? {}
|
|
249
|
+
provider = config.provider ?? data.provider ?? null
|
|
250
|
+
model = config.model ?? data.model ?? null
|
|
251
|
+
} else if (ev?.type === 'request/context') {
|
|
252
|
+
provider = data.provider ?? provider
|
|
253
|
+
model = data.model ?? model
|
|
254
|
+
} else if (ev?.type === 'assistant/message') {
|
|
255
|
+
const usage = data.usage
|
|
256
|
+
if (!usage || typeof usage !== 'object') continue
|
|
257
|
+
const pm = provider ?? UNKNOWN
|
|
258
|
+
const mm = model ?? UNKNOWN
|
|
259
|
+
const date = dayKey(ev.time ?? Date.now())
|
|
260
|
+
let dayObj = sessionDays.get(date)
|
|
261
|
+
if (!dayObj) {
|
|
262
|
+
dayObj = new Map()
|
|
263
|
+
sessionDays.set(date, dayObj)
|
|
264
|
+
}
|
|
265
|
+
const key = `${pm}|${mm}`
|
|
266
|
+
const bucket = dayObj.get(key) ?? emptyBucket(pm, mm)
|
|
267
|
+
const record = {
|
|
268
|
+
input: Number(usage.inputTokens) || 0,
|
|
269
|
+
cacheRead: Number(usage.cacheReadTokens) || 0,
|
|
270
|
+
cacheWrite: Number(usage.cacheWriteTokens) || 0,
|
|
271
|
+
output: Number(usage.outputTokens) || 0,
|
|
272
|
+
}
|
|
273
|
+
const hour = localHour(ev.time ?? Date.now())
|
|
274
|
+
bucket.input += record.input
|
|
275
|
+
bucket.cacheRead += record.cacheRead
|
|
276
|
+
bucket.cacheWrite += record.cacheWrite
|
|
277
|
+
bucket.output += record.output
|
|
278
|
+
bucket.requests += 1
|
|
279
|
+
if (Array.isArray(bucket.usageRecords)) bucket.usageRecords.push({ ...record, hour })
|
|
280
|
+
const hourBucket = bucket.hourly[hour]
|
|
281
|
+
hourBucket.input += record.input
|
|
282
|
+
hourBucket.cacheRead += record.cacheRead
|
|
283
|
+
hourBucket.cacheWrite += record.cacheWrite
|
|
284
|
+
hourBucket.output += record.output
|
|
285
|
+
hourBucket.requests += 1
|
|
286
|
+
dayObj.set(key, bucket)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
sessionId,
|
|
292
|
+
path: file,
|
|
293
|
+
fileSize: stat.size,
|
|
294
|
+
fileMtimeMs: stat.mtimeMs || 0,
|
|
295
|
+
seq: maxSeq,
|
|
296
|
+
provider,
|
|
297
|
+
model,
|
|
298
|
+
days: sessionDays,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function serializeSession(record) {
|
|
303
|
+
const days = {}
|
|
304
|
+
for (const [date, dayObj] of record.days ?? []) {
|
|
305
|
+
days[date] = {}
|
|
306
|
+
for (const [key, bucket] of dayObj) days[date][key] = bucket
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
path: record.path,
|
|
310
|
+
fileSize: record.fileSize,
|
|
311
|
+
fileMtimeMs: record.fileMtimeMs,
|
|
312
|
+
seq: record.seq,
|
|
313
|
+
provider: record.provider,
|
|
314
|
+
model: record.model,
|
|
315
|
+
days,
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function deserializeSession(sessionId, record) {
|
|
320
|
+
if (!record || typeof record !== 'object' || !record.days || typeof record.days !== 'object') return null
|
|
321
|
+
const days = new Map()
|
|
322
|
+
for (const [date, sourceDay] of Object.entries(record.days)) {
|
|
323
|
+
if (!sourceDay || typeof sourceDay !== 'object') continue
|
|
324
|
+
const dayObj = new Map()
|
|
325
|
+
for (const [key, source] of Object.entries(sourceDay)) {
|
|
326
|
+
if (!source || typeof source !== 'object') continue
|
|
327
|
+
const bucket = emptyBucket(String(source.provider ?? UNKNOWN), String(source.model ?? UNKNOWN))
|
|
328
|
+
addBucket(bucket, source)
|
|
329
|
+
dayObj.set(key, bucket)
|
|
330
|
+
}
|
|
331
|
+
days.set(date, dayObj)
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
sessionId,
|
|
335
|
+
path: String(record.path ?? ''),
|
|
336
|
+
fileSize: Number(record.fileSize) || 0,
|
|
337
|
+
fileMtimeMs: Number(record.fileMtimeMs) || 0,
|
|
338
|
+
seq: Number(record.seq) || -1,
|
|
339
|
+
provider: record.provider ?? null,
|
|
340
|
+
model: record.model ?? null,
|
|
341
|
+
days,
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* An incremental view over every session log.
|
|
347
|
+
*
|
|
348
|
+
* `refresh()` re-reads only logs whose size, mtime or path changed, so a steady
|
|
349
|
+
* state costs one `stat` per session. The merged `days` map is rebuilt from the
|
|
350
|
+
* per-session records on every refresh; that is deliberate, because rebuilding is
|
|
351
|
+
* what makes a deleted log keep its cached contribution instead of erasing
|
|
352
|
+
* history that the log no longer exists to prove.
|
|
353
|
+
*/
|
|
354
|
+
export function createAggregator({ sessionsRoot, cachePath, log = () => {} }) {
|
|
355
|
+
const records = new Map()
|
|
356
|
+
const cursors = new Map()
|
|
357
|
+
let days = new Map()
|
|
358
|
+
let saveTimer = null
|
|
359
|
+
let lastRefresh = null
|
|
360
|
+
|
|
361
|
+
function mergeDays() {
|
|
362
|
+
const merged = new Map()
|
|
363
|
+
for (const record of records.values()) {
|
|
364
|
+
for (const [date, sourceDay] of record.days ?? []) {
|
|
365
|
+
let dayObj = merged.get(date)
|
|
366
|
+
if (!dayObj) {
|
|
367
|
+
dayObj = new Map()
|
|
368
|
+
merged.set(date, dayObj)
|
|
369
|
+
}
|
|
370
|
+
for (const [key, source] of sourceDay) {
|
|
371
|
+
const target = dayObj.get(key) ?? emptyBucket(source.provider, source.model)
|
|
372
|
+
addBucket(target, source)
|
|
373
|
+
dayObj.set(key, target)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
days = merged
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function loadCache() {
|
|
381
|
+
let raw
|
|
382
|
+
try {
|
|
383
|
+
raw = JSON.parse(readFileSync(cachePath, 'utf8'))
|
|
384
|
+
} catch {
|
|
385
|
+
return false
|
|
386
|
+
}
|
|
387
|
+
if (!raw || raw.version !== CACHE_VERSION || !raw.sessions || typeof raw.sessions !== 'object') return false
|
|
388
|
+
for (const [sessionId, source] of Object.entries(raw.sessions)) {
|
|
389
|
+
const record = deserializeSession(sessionId, source)
|
|
390
|
+
if (!record) continue
|
|
391
|
+
records.set(sessionId, record)
|
|
392
|
+
cursors.set(sessionId, {
|
|
393
|
+
seq: record.seq,
|
|
394
|
+
provider: record.provider,
|
|
395
|
+
model: record.model,
|
|
396
|
+
fileSize: record.fileSize,
|
|
397
|
+
fileMtimeMs: record.fileMtimeMs,
|
|
398
|
+
path: record.path,
|
|
399
|
+
})
|
|
400
|
+
}
|
|
401
|
+
mergeDays()
|
|
402
|
+
log(`cache loaded: ${records.size} session(s)`)
|
|
403
|
+
return true
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function saveCache() {
|
|
407
|
+
const sessions = {}
|
|
408
|
+
for (const [sessionId, record] of records) sessions[sessionId] = serializeSession(record)
|
|
409
|
+
// The temporary name carries pid, time and entropy so a restarted host can
|
|
410
|
+
// never collide with a stale temp file another process still holds open.
|
|
411
|
+
const temp = `${cachePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
|
412
|
+
try {
|
|
413
|
+
mkdirSync(join(cachePath, '..'), { recursive: true })
|
|
414
|
+
writeFileSync(temp, JSON.stringify({ version: CACHE_VERSION, sessions }))
|
|
415
|
+
try {
|
|
416
|
+
renameSync(temp, cachePath)
|
|
417
|
+
} catch {
|
|
418
|
+
// Windows cannot rename over an existing file; the complete temp file is
|
|
419
|
+
// already on disk, so removing the old one first is safe here.
|
|
420
|
+
try {
|
|
421
|
+
unlinkSync(cachePath)
|
|
422
|
+
} catch {
|
|
423
|
+
// absent is fine
|
|
424
|
+
}
|
|
425
|
+
renameSync(temp, cachePath)
|
|
426
|
+
}
|
|
427
|
+
} catch (error) {
|
|
428
|
+
try {
|
|
429
|
+
unlinkSync(temp)
|
|
430
|
+
} catch {
|
|
431
|
+
// best-effort cleanup only
|
|
432
|
+
}
|
|
433
|
+
log(`cache save failed: ${error?.message ?? error}`)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function scheduleCacheSave() {
|
|
438
|
+
if (saveTimer) return
|
|
439
|
+
saveTimer = setTimeout(() => {
|
|
440
|
+
saveTimer = null
|
|
441
|
+
saveCache()
|
|
442
|
+
}, 2000)
|
|
443
|
+
saveTimer.unref?.()
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Fold every changed log.
|
|
448
|
+
*
|
|
449
|
+
* @returns {Promise<{files:number, folded:number, ms:number}>} what this pass did.
|
|
450
|
+
*/
|
|
451
|
+
async function refresh() {
|
|
452
|
+
const started = Date.now()
|
|
453
|
+
const files = listLogFiles(sessionsRoot)
|
|
454
|
+
const seen = new Set()
|
|
455
|
+
let folded = 0
|
|
456
|
+
let sinceYield = 0
|
|
457
|
+
for (const file of files) {
|
|
458
|
+
const sessionId = sessionIdFromLogPath(file)
|
|
459
|
+
seen.add(sessionId)
|
|
460
|
+
const cursor = cursors.get(sessionId)
|
|
461
|
+
let stat
|
|
462
|
+
try {
|
|
463
|
+
stat = statSync(file)
|
|
464
|
+
} catch {
|
|
465
|
+
continue
|
|
466
|
+
}
|
|
467
|
+
if (cursor && cursor.fileSize === stat.size && cursor.fileMtimeMs === (stat.mtimeMs || 0) && cursor.path === file) {
|
|
468
|
+
continue
|
|
469
|
+
}
|
|
470
|
+
const record = foldSession(file, records.get(sessionId))
|
|
471
|
+
if (record) {
|
|
472
|
+
records.set(sessionId, record)
|
|
473
|
+
cursors.set(sessionId, {
|
|
474
|
+
seq: record.seq,
|
|
475
|
+
provider: record.provider,
|
|
476
|
+
model: record.model,
|
|
477
|
+
fileSize: record.fileSize,
|
|
478
|
+
fileMtimeMs: record.fileMtimeMs,
|
|
479
|
+
path: record.path,
|
|
480
|
+
})
|
|
481
|
+
folded++
|
|
482
|
+
}
|
|
483
|
+
if (++sinceYield >= YIELD_EVERY) {
|
|
484
|
+
sinceYield = 0
|
|
485
|
+
await new Promise((r) => setImmediate(r))
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
// A session whose log disappeared keeps its record (and therefore its
|
|
489
|
+
// history) but stops being refreshed.
|
|
490
|
+
if (folded > 0) {
|
|
491
|
+
mergeDays()
|
|
492
|
+
scheduleCacheSave()
|
|
493
|
+
}
|
|
494
|
+
lastRefresh = { files: files.length, folded, ms: Date.now() - started, at: Date.now() }
|
|
495
|
+
return lastRefresh
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return {
|
|
499
|
+
loadCache,
|
|
500
|
+
refresh,
|
|
501
|
+
saveCache,
|
|
502
|
+
get days() {
|
|
503
|
+
return days
|
|
504
|
+
},
|
|
505
|
+
get sessionCount() {
|
|
506
|
+
return records.size
|
|
507
|
+
},
|
|
508
|
+
get lastRefresh() {
|
|
509
|
+
return lastRefresh
|
|
510
|
+
},
|
|
511
|
+
}
|
|
512
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"note": "中国法定节假日与调休上班日。数据来自国务院办公厅的年度放假安排通知;DeepSeek 的高峰时段定义依赖该日历:「北京时间周一至周五(不含中国法定节假日)9:00-12:00、14:00-18:00 为高峰时段;其余时段,包括周末及中国法定节假日全天均为空闲时段」。注意:政策把「周末」整体划为空闲时段,所以 blocks 里的放假日才影响计价,workdays 里的调休上班日只是存档(调休后的周日仍是周末,仍按空闲价)。新增年份时按同样格式补充 years.<year> 即可,blocks 用日期区间表示,加载时展开。",
|
|
4
|
+
"source": {
|
|
5
|
+
"2025": {
|
|
6
|
+
"document": "国办发明电〔2024〕7号",
|
|
7
|
+
"published": "2024-11-12",
|
|
8
|
+
"url": "https://www.gov.cn/gongbao/2024/issue_11726/202411/content_6989767.html"
|
|
9
|
+
},
|
|
10
|
+
"2026": {
|
|
11
|
+
"document": "国办发明电〔2025〕7号",
|
|
12
|
+
"published": "2025-11-04",
|
|
13
|
+
"url": "https://www.gov.cn/zhengce/zhengceku/202511/content_7047091.htm"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"years": {
|
|
17
|
+
"2025": {
|
|
18
|
+
"blocks": [
|
|
19
|
+
{ "name": "元旦", "from": "2025-01-01", "to": "2025-01-01" },
|
|
20
|
+
{ "name": "春节", "from": "2025-01-28", "to": "2025-02-04" },
|
|
21
|
+
{ "name": "清明节", "from": "2025-04-04", "to": "2025-04-06" },
|
|
22
|
+
{ "name": "劳动节", "from": "2025-05-01", "to": "2025-05-05" },
|
|
23
|
+
{ "name": "端午节", "from": "2025-05-31", "to": "2025-06-02" },
|
|
24
|
+
{ "name": "国庆节、中秋节", "from": "2025-10-01", "to": "2025-10-08" }
|
|
25
|
+
],
|
|
26
|
+
"workdays": ["2025-01-26", "2025-02-08", "2025-04-27", "2025-09-28", "2025-10-11"]
|
|
27
|
+
},
|
|
28
|
+
"2026": {
|
|
29
|
+
"blocks": [
|
|
30
|
+
{ "name": "元旦", "from": "2026-01-01", "to": "2026-01-03" },
|
|
31
|
+
{ "name": "春节", "from": "2026-02-15", "to": "2026-02-23" },
|
|
32
|
+
{ "name": "清明节", "from": "2026-04-04", "to": "2026-04-06" },
|
|
33
|
+
{ "name": "劳动节", "from": "2026-05-01", "to": "2026-05-05" },
|
|
34
|
+
{ "name": "端午节", "from": "2026-06-19", "to": "2026-06-21" },
|
|
35
|
+
{ "name": "中秋节", "from": "2026-09-25", "to": "2026-09-27" },
|
|
36
|
+
{ "name": "国庆节", "from": "2026-10-01", "to": "2026-10-07" }
|
|
37
|
+
],
|
|
38
|
+
"workdays": ["2026-01-04", "2026-02-14", "2026-02-28", "2026-05-09", "2026-09-20", "2026-10-10"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
package/lib/holidays.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-usage-badge — the legal-holiday calendar behind the peak/valley rule.
|
|
3
|
+
*
|
|
4
|
+
* DeepSeek's published policy is explicit about holidays:
|
|
5
|
+
*
|
|
6
|
+
* > 空闲时段价格为高峰时段价格的一半。北京时间周一至周五(**不含中国法定节假日**)
|
|
7
|
+
* > 9:00 - 12:00、14:00 - 18:00 为高峰时段;其余时段,包括周末及**中国法定节假日全天均为空闲时段**。
|
|
8
|
+
*
|
|
9
|
+
* So a weekday holiday must be billed entirely off-peak, and a 调休 weekend that
|
|
10
|
+
* the State Council turns into a working day must be billed as a working day
|
|
11
|
+
* again. Both directions need the real calendar, which is why this module ships
|
|
12
|
+
* one rather than inferring from the weekday alone.
|
|
13
|
+
*
|
|
14
|
+
* The bundled table is per-year data transcribed from the State Council notices
|
|
15
|
+
* (see `holidays-cn.json`, which records document numbers and URLs). A year the
|
|
16
|
+
* table does not cover is reported rather than guessed, and `config.holidays`
|
|
17
|
+
* can always add or override dates by hand.
|
|
18
|
+
*
|
|
19
|
+
* @module dsh-usage-badge/holidays
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'node:fs'
|
|
23
|
+
|
|
24
|
+
import { dayKey } from './pricing.js'
|
|
25
|
+
|
|
26
|
+
const TABLE_URL = new URL('./holidays-cn.json', import.meta.url)
|
|
27
|
+
|
|
28
|
+
let bundledCache = null
|
|
29
|
+
|
|
30
|
+
/** Expand an inclusive `YYYY-MM-DD` range into its dates, walking in UTC to dodge DST. */
|
|
31
|
+
function expandRange(from, to) {
|
|
32
|
+
const out = []
|
|
33
|
+
const start = Date.parse(`${from}T00:00:00Z`)
|
|
34
|
+
const end = Date.parse(`${to}T00:00:00Z`)
|
|
35
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return out
|
|
36
|
+
for (let ms = start; ms <= end; ms += 86400000) out.push(new Date(ms).toISOString().slice(0, 10))
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Load and expand the bundled calendar.
|
|
42
|
+
*
|
|
43
|
+
* @returns {{holidays: Map<string,string>, workdays: Set<string>, years: Set<string>, source: object}|null}
|
|
44
|
+
* `holidays` maps a date to its festival name; `null` when the bundled file
|
|
45
|
+
* cannot be read, which is treated as "no bundled calendar" rather than a crash.
|
|
46
|
+
*/
|
|
47
|
+
export function loadBundledTable() {
|
|
48
|
+
if (bundledCache !== null) return bundledCache
|
|
49
|
+
try {
|
|
50
|
+
const raw = JSON.parse(readFileSync(TABLE_URL, 'utf8'))
|
|
51
|
+
const holidays = new Map()
|
|
52
|
+
const workdays = new Set()
|
|
53
|
+
const years = new Set()
|
|
54
|
+
for (const [year, data] of Object.entries(raw.years ?? {})) {
|
|
55
|
+
years.add(year)
|
|
56
|
+
for (const block of data.blocks ?? []) {
|
|
57
|
+
for (const date of expandRange(block.from, block.to)) holidays.set(date, block.name ?? '')
|
|
58
|
+
}
|
|
59
|
+
for (const date of data.workdays ?? []) workdays.add(date)
|
|
60
|
+
}
|
|
61
|
+
bundledCache = { holidays, workdays, years, source: raw.source ?? {}, note: raw.note ?? '' }
|
|
62
|
+
} catch {
|
|
63
|
+
bundledCache = { holidays: new Map(), workdays: new Set(), years: new Set(), source: {}, note: '' }
|
|
64
|
+
}
|
|
65
|
+
return bundledCache
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the day-classification rules a pricing document asks for.
|
|
70
|
+
*
|
|
71
|
+
* The document opts in through a top-level `holidays` object, kept separate from
|
|
72
|
+
* `timeOfUse` because it answers a different question: `timeOfUse` says *when*
|
|
73
|
+
* peak windows are, `holidays` says *which days* they can apply to at all. It also
|
|
74
|
+
* means one holiday calendar governs every per-model `timeOfUse` row.
|
|
75
|
+
*
|
|
76
|
+
* | `holidays.source` | meaning |
|
|
77
|
+
* |---|---|
|
|
78
|
+
* | absent / `"none"` | no holiday handling — the historical behavior |
|
|
79
|
+
* | `"cn"` | the bundled China calendar, plus any `extra` dates |
|
|
80
|
+
* | `"custom"` | only `extra` dates, no bundled calendar |
|
|
81
|
+
*
|
|
82
|
+
* Only *holidays* affect pricing. The bundled table also records the 调休 working
|
|
83
|
+
* days, and they are deliberately not resolved into a rule here: the published
|
|
84
|
+
* policy makes every weekend off-peak in full, and a 调休 day is still a 周末, so
|
|
85
|
+
* treating one as a working day would charge peak rates on a day the page calls
|
|
86
|
+
* off-peak. The dates stay in the data file as provenance.
|
|
87
|
+
*
|
|
88
|
+
* @param {object|undefined} config - the pricing document's `holidays` field.
|
|
89
|
+
* @returns {{enabled:boolean, holidays:Map<string,string>, source:object, years:Set<string>, missingYears:string[]}}
|
|
90
|
+
*/
|
|
91
|
+
export function resolveHolidayRules(config) {
|
|
92
|
+
const source = config?.source === 'cn' || config?.source === 'custom' ? config.source : 'none'
|
|
93
|
+
const bundled = source === 'cn' ? loadBundledTable() : { holidays: new Map(), workdays: new Set(), years: new Set(), source: {} }
|
|
94
|
+
const enabled = source !== 'none'
|
|
95
|
+
|
|
96
|
+
const holidays = new Map(enabled && source === 'cn' ? bundled.holidays : [])
|
|
97
|
+
for (const date of config?.extra ?? []) if (typeof date === 'string' && date) holidays.set(date.trim(), '自定义')
|
|
98
|
+
|
|
99
|
+
// A year with no bundled block is worth surfacing: its holidays would otherwise
|
|
100
|
+
// silently be treated as ordinary weekdays.
|
|
101
|
+
const missingYears = []
|
|
102
|
+
if (enabled && source === 'cn') {
|
|
103
|
+
const thisYear = new Date().getFullYear()
|
|
104
|
+
for (const year of [thisYear, thisYear + 1]) {
|
|
105
|
+
if (!bundled.years.has(String(year))) missingYears.push(String(year))
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { enabled, holidays, source: bundled.source, years: bundled.years, missingYears, source_kind: source }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Classify one date under the resolved rules.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} date - local `YYYY-MM-DD` day key.
|
|
116
|
+
* @param {object} rules - result of {@link resolveHolidayRules}.
|
|
117
|
+
* @returns {{class:'holiday'|'normal', name:string|null}}
|
|
118
|
+
*/
|
|
119
|
+
export function classifyDate(date, rules) {
|
|
120
|
+
if (!rules?.enabled) return { class: 'normal', name: null }
|
|
121
|
+
const name = rules.holidays?.get?.(date)
|
|
122
|
+
if (name !== undefined) return { class: 'holiday', name: name || null }
|
|
123
|
+
return { class: 'normal', name: null }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The holiday-related facts the browser half displays: whether the calendar is on,
|
|
128
|
+
* which years it covers, and how the current day is classified.
|
|
129
|
+
*/
|
|
130
|
+
export function describeCalendar(rules, now = Date.now()) {
|
|
131
|
+
const date = dayKey(now)
|
|
132
|
+
const today = classifyDate(date, rules)
|
|
133
|
+
return {
|
|
134
|
+
enabled: Boolean(rules?.enabled),
|
|
135
|
+
source: rules?.source_kind ?? 'none',
|
|
136
|
+
years: [...(rules?.years ?? [])].sort(),
|
|
137
|
+
missingYears: rules?.missingYears ?? [],
|
|
138
|
+
today: { date, class: today.class, name: today.name },
|
|
139
|
+
counts: { holidays: rules?.holidays?.size ?? 0 },
|
|
140
|
+
}
|
|
141
|
+
}
|