dsh-all-usage 1.2.3 → 1.2.4

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.de.md +316 -0
  3. package/README.en.md +52 -14
  4. package/README.es.md +316 -0
  5. package/README.fr.md +316 -0
  6. package/README.ja.md +316 -0
  7. package/README.md +55 -16
  8. package/README.pt.md +316 -0
  9. package/README.ru.md +316 -0
  10. package/assets/cover-en.png +0 -0
  11. package/assets/cover-zh.png +0 -0
  12. package/assets/readme-architecture-en.svg +95 -95
  13. package/assets/readme-architecture-zh.svg +95 -95
  14. package/assets/readme-sync-en.svg +81 -81
  15. package/assets/readme-sync-zh.svg +81 -81
  16. package/assets/screenshots/en/01-overview.png +0 -0
  17. package/assets/screenshots/en/02-trend-bars.png +0 -0
  18. package/assets/screenshots/en/02-trend.png +0 -0
  19. package/assets/screenshots/en/03-heatmap.png +0 -0
  20. package/assets/screenshots/en/04-model-stats.png +0 -0
  21. package/assets/screenshots/en/05-request-logs.png +0 -0
  22. package/assets/screenshots/en/06-cost-settings.png +0 -0
  23. package/assets/screenshots/en/07-maintenance.png +0 -0
  24. package/assets/screenshots/zh/01-overview.png +0 -0
  25. package/assets/screenshots/zh/02-trend-bars.png +0 -0
  26. package/assets/screenshots/zh/02-trend.png +0 -0
  27. package/assets/screenshots/zh/03-heatmap.png +0 -0
  28. package/assets/screenshots/zh/04-model-stats.png +0 -0
  29. package/assets/screenshots/zh/05-request-logs.png +0 -0
  30. package/assets/screenshots/zh/06-cost-settings.png +0 -0
  31. package/assets/screenshots/zh/07-maintenance.png +0 -0
  32. package/lib/archive.js +399 -0
  33. package/lib/client.js +1 -1
  34. package/lib/http.js +27 -1
  35. package/lib/ledger.js +362 -54
  36. package/package.json +10 -1
  37. package/screenshots.json +5 -3
  38. package/scripts/archive-ledger.mjs +156 -0
  39. package/assets/screenshots/en/04-model-usage.png +0 -0
  40. package/assets/screenshots/zh/04-model-usage.png +0 -0
package/lib/archive.js ADDED
@@ -0,0 +1,399 @@
1
+ // Cold storage for usage ledger records.
2
+ //
3
+ // The per-record trees (<root>/all_usage_ledger_NN/sessions/<id>.json) are the hot
4
+ // copy of the ledger: a write publishes one document, a query reads back only the
5
+ // documents it asks for. A session nobody has touched for months still costs a
6
+ // document and a directory entry, and that is the cost this module buys back.
7
+ //
8
+ // A cold record is folded into <root>/archive/<YYYY-MM>.jsonl.gz, one compact row
9
+ // per line (the exact shape serializeLedgerRecord() writes to the trees), and only
10
+ // then is its document removed. Whole records are stored, never aggregates: the
11
+ // read path can put a row back through the same fold that produced it, so the
12
+ // archive cannot disagree with the tree about a single token.
13
+ //
14
+ // archive/<YYYY-MM>.jsonl.gz the month's records, one JSON document per line
15
+ // archive/<YYYY-MM>.keys.json.gz that month's session ids (the listKeys index)
16
+ // archive/manifest.json {version: 1, months: {...}} - a description only
17
+ //
18
+ // A month is decided by record.updatedAt in UTC, so a record always lands in the
19
+ // same file however the machine's time zone moves.
20
+ //
21
+ // Durability order, per month: record file, key index, manifest, source documents.
22
+ // Every one of the first three is written as <name>.tmp, fsynced, closed and then
23
+ // renamed over its target, so a reader never sees a half-written file. The tree
24
+ // documents are unlinked only once all three are in place; a failure in any layer
25
+ // leaves the record in the tree as well. That is the one state the read path has to
26
+ // tolerate - it prefers the tree, so the record is still folded once, and the next
27
+ // archive run finds a duplicate it does not have to write again.
28
+ import { mkdir as makeDir, open as openFile, readFile as readFileBytes, readdir as readDir, rename as renameFile, stat as statFile, unlink as unlinkFile } from 'node:fs/promises'
29
+ import { join } from 'node:path'
30
+ import { promisify } from 'node:util'
31
+ import { gunzip as gunzipCallback, gzip as gzipCallback } from 'node:zlib'
32
+
33
+ const ARCHIVE_DIR = 'archive'
34
+ const MANIFEST_FILE = 'manifest.json'
35
+ const MANIFEST_VERSION = 1
36
+ const RECORD_SUFFIX = '.jsonl.gz'
37
+ const KEYS_SUFFIX = '.keys.json.gz'
38
+ const MONTH_FILE_PATTERN = /^(\d{4}-\d{2})\.jsonl\.gz$/
39
+ const gzip = promisify(gzipCallback)
40
+ const gunzip = promisify(gunzipCallback)
41
+
42
+ // node:fs/promises cannot be substituted by a test, so both public entry points
43
+ // accept an fs object. Production never passes one; the archive tests hand in a
44
+ // wrapper that counts calls, which is the only way to prove that a ledger without
45
+ // an archive directory does not pay for one.
46
+ const DEFAULT_FS = {
47
+ open: openFile,
48
+ mkdir: makeDir,
49
+ readFile: readFileBytes,
50
+ readdir: readDir,
51
+ rename: renameFile,
52
+ stat: statFile,
53
+ unlink: unlinkFile,
54
+ }
55
+
56
+ function fsOf(options) {
57
+ if (options === null || typeof options !== 'object') return DEFAULT_FS
58
+ if (options.fs === null || typeof options.fs !== 'object') return DEFAULT_FS
59
+ return options.fs
60
+ }
61
+
62
+ // The UTC month a record belongs to. Rows without a usable instant cannot be
63
+ // bucketed (and are already invalid to the ledger) and stay where they are.
64
+ function monthOfRecord(record) {
65
+ if (record === null || typeof record !== 'object') return undefined
66
+ if (!Number.isFinite(record.updatedAt)) return undefined
67
+ const date = new Date(record.updatedAt)
68
+ if (!Number.isFinite(date.getTime())) return undefined
69
+ return date.toISOString().slice(0, 7)
70
+ }
71
+
72
+ function updatedAtOf(record) {
73
+ return record !== null && typeof record === 'object' && Number.isFinite(record.updatedAt) ? record.updatedAt : -Infinity
74
+ }
75
+
76
+ // One fsynced, atomic publish. The staging name carries the pid so two processes
77
+ // that somehow slipped past the maintenance lock cannot truncate each other's file.
78
+ async function writeDurable(fs, path, data) {
79
+ const tmp = path + '.tmp-' + String(process.pid)
80
+ let handle = null
81
+ try {
82
+ handle = await fs.open(tmp, 'w')
83
+ await handle.writeFile(data)
84
+ await handle.sync()
85
+ await handle.close()
86
+ handle = null
87
+ await fs.rename(tmp, path)
88
+ } catch (error) {
89
+ if (handle !== null) await handle.close().catch(() => {})
90
+ await fs.unlink(tmp).catch(() => {})
91
+ throw error
92
+ }
93
+ }
94
+
95
+ // One month file, decompressed and parsed into one row per session id. An
96
+ // unreadable file throws: the writer must never replace a month it cannot read,
97
+ // because that would trade a damaged row for every row in it.
98
+ async function readArchiveMonth(fs, dir, month) {
99
+ const packed = await fs.readFile(join(dir, month + RECORD_SUFFIX)).catch((error) => {
100
+ if (error !== null && typeof error === 'object' && error.code === 'ENOENT') return null
101
+ throw error
102
+ })
103
+ if (packed === null) return new Map()
104
+ const text = (await gunzip(packed)).toString('utf8')
105
+ const rows = new Map()
106
+ for (const line of text.split('\n')) {
107
+ if (line === '') continue
108
+ const row = JSON.parse(line)
109
+ if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
110
+ rows.set(row.sessionId, row)
111
+ }
112
+ return rows
113
+ }
114
+
115
+ async function readManifest(fs, root) {
116
+ const packed = await fs.readFile(join(root, ARCHIVE_DIR, MANIFEST_FILE)).catch(() => null)
117
+ if (packed === null) return { version: MANIFEST_VERSION, months: {} }
118
+ try {
119
+ const parsed = JSON.parse(packed.toString('utf8'))
120
+ if (parsed !== null && typeof parsed === 'object' && parsed.version === MANIFEST_VERSION && parsed.months !== null && typeof parsed.months === 'object') {
121
+ return { version: MANIFEST_VERSION, months: parsed.months }
122
+ }
123
+ } catch (error) {
124
+ // A damaged manifest is a description, not the archive: the months on disk
125
+ // decide what is readable, and this one is simply rewritten from them.
126
+ }
127
+ return { version: MANIFEST_VERSION, months: {} }
128
+ }
129
+
130
+ async function writeManifest(fs, root, manifest) {
131
+ const months = {}
132
+ for (const month of Object.keys(manifest.months).sort()) months[month] = manifest.months[month]
133
+ const body = JSON.stringify({ version: MANIFEST_VERSION, months }, null, 1) + '\n'
134
+ await writeDurable(fs, join(root, ARCHIVE_DIR, MANIFEST_FILE), Buffer.from(body, 'utf8'))
135
+ }
136
+
137
+ /**
138
+ * The read side of the archive, or null when <root>/archive does not exist.
139
+ *
140
+ * The directory listing is the whole feature switch, so a ledger that never
141
+ * archived pays one failed listing per start and reads nothing else. The key index
142
+ * is loaded eagerly because listKeys() is a synchronous question and the
143
+ * .keys.json.gz files are small; the record files are decompressed one month at a
144
+ * time, on the first read that needs them, and stay in memory afterwards.
145
+ *
146
+ * A month that cannot be decompressed or parsed is treated as missing: a warning
147
+ * is logged once, its session ids leave the index, and startup carries on with the
148
+ * months that are readable.
149
+ */
150
+ export async function createArchiveReader(root, options = {}) {
151
+ if (typeof root !== 'string' || root === '') return null
152
+ const fs = fsOf(options)
153
+ const dir = join(root, ARCHIVE_DIR)
154
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => null)
155
+ if (entries === null) return null
156
+ const months = []
157
+ for (const entry of entries) {
158
+ const match = entry.isFile() ? MONTH_FILE_PATTERN.exec(entry.name) : null
159
+ if (match === null || months.includes(match[1])) continue
160
+ months.push(match[1])
161
+ }
162
+ if (months.length === 0) return null
163
+ months.sort()
164
+
165
+ const records = new Map()
166
+ const monthKeys = new Map()
167
+ const keyMonths = new Map()
168
+ const broken = new Set()
169
+ const derived = new Set()
170
+ const warned = new Set()
171
+ const sessionIds = new Set()
172
+
173
+ function rebuildSessionIds() {
174
+ sessionIds.clear()
175
+ for (const [month, keys] of monthKeys) {
176
+ if (broken.has(month)) continue
177
+ for (const key of keys) sessionIds.add(key)
178
+ }
179
+ }
180
+
181
+ async function loadRecords(month) {
182
+ if (records.has(month)) return records.get(month)
183
+ let rows = null
184
+ try {
185
+ const packed = await fs.readFile(join(dir, month + RECORD_SUFFIX))
186
+ const text = (await gunzip(packed)).toString('utf8')
187
+ rows = new Map()
188
+ for (const line of text.split('\n')) {
189
+ if (line === '') continue
190
+ const row = JSON.parse(line)
191
+ if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
192
+ rows.set(row.sessionId, row)
193
+ }
194
+ } catch (error) {
195
+ rows = null
196
+ if (!warned.has(month)) {
197
+ warned.add(month)
198
+ console.warn('[all-usage] usage ledger archive month is unreadable and is treated as missing:', month, error && error.message)
199
+ }
200
+ if (!broken.has(month)) {
201
+ broken.add(month)
202
+ rebuildSessionIds()
203
+ }
204
+ }
205
+ records.set(month, rows)
206
+ return rows
207
+ }
208
+
209
+ function indexMonth(month, keys) {
210
+ monthKeys.set(month, keys)
211
+ for (const key of keys) {
212
+ let owners = keyMonths.get(key)
213
+ if (owners === undefined) { owners = new Set(); keyMonths.set(key, owners) }
214
+ owners.add(month)
215
+ }
216
+ }
217
+
218
+ for (const month of months) {
219
+ let keys = null
220
+ try {
221
+ const packed = await fs.readFile(join(dir, month + KEYS_SUFFIX))
222
+ const parsed = JSON.parse((await gunzip(packed)).toString('utf8'))
223
+ if (Array.isArray(parsed)) keys = parsed.filter((key) => typeof key === 'string' && key !== '')
224
+ } catch (error) {
225
+ keys = null
226
+ }
227
+ if (keys !== null) { indexMonth(month, keys); continue }
228
+ // Without a usable index the month file itself is the only record of what the
229
+ // month holds, and dropping those sessions from listKeys() would hide usage
230
+ // that is still perfectly readable. One decompression buys the whole list.
231
+ const rows = await loadRecords(month)
232
+ if (rows === null) continue
233
+ derived.add(month)
234
+ indexMonth(month, Array.from(rows.keys()))
235
+ }
236
+ rebuildSessionIds()
237
+
238
+ return {
239
+ months: Object.freeze(months.slice()),
240
+ /** Every session id the archive holds, in no particular order. */
241
+ keys() { return new Set(sessionIds) },
242
+ /**
243
+ * One archived row, in the shape the trees store (compact, identity pool and
244
+ * all): the ledger expands it with expandLedgerRecord() like any other row.
245
+ * A session archived in two months is one record; the newer revision wins.
246
+ */
247
+ async readRecord(key) {
248
+ if (typeof key !== 'string' || key === '') return undefined
249
+ const owners = keyMonths.get(key)
250
+ // A key the index never saw can still sit in a month whose index was derived
251
+ // or missing, so those months are still searched. A complete index answers
252
+ // 'missing' without decompressing anything.
253
+ const candidates = owners !== undefined ? months.filter((month) => owners.has(month)) : Array.from(derived)
254
+ let best
255
+ for (const month of candidates) {
256
+ if (broken.has(month)) continue
257
+ const rows = await loadRecords(month)
258
+ if (rows === null) continue
259
+ const row = rows.get(key)
260
+ if (row === undefined) continue
261
+ if (best === undefined || updatedAtOf(row) > updatedAtOf(best)) best = row
262
+ }
263
+ return best
264
+ },
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Fold every tree record older than cutoffMs into its month file and, once that is
270
+ * durable, remove the tree document.
271
+ *
272
+ * The store is the ledger's unit store (or the filesystem stand-in the CLI builds):
273
+ * listKeys() names the records, readRecord() returns the compact row, and
274
+ * deleteRecord(key, expected) removes one tree document only while it still holds
275
+ * the row named by expected. Only updatedAt decides membership, so a record that is
276
+ * still being written before the cutoff and again after it can never be archived
277
+ * twice with different content, and a write that lands during the run keeps its
278
+ * document instead of losing the revision.
279
+ *
280
+ * Returns { months, archived, bytes, compressed, dryRun, errors }. months lists the
281
+ * month files written, archived counts the records this run moved, bytes is their
282
+ * serialized (uncompressed) size, compressed the bytes those month files grew by.
283
+ * A dry run reports the same plan and writes nothing at all - not even the archive
284
+ * directory.
285
+ */
286
+ export async function archiveRecords({ root, store, cutoffMs, dryRun = false, fs: fsOption } = {}) {
287
+ if (typeof root !== 'string' || root === '') throw new TypeError('archiveRecords needs a ledger root')
288
+ if (store === null || typeof store !== 'object') throw new TypeError('archiveRecords needs a ledger store')
289
+ if (typeof store.listKeys !== 'function' || typeof store.readRecord !== 'function' || typeof store.deleteRecord !== 'function') throw new TypeError('archiveRecords needs store.listKeys(), store.readRecord() and store.deleteRecord()')
290
+ if (!Number.isFinite(cutoffMs)) throw new TypeError('archiveRecords needs a finite cutoffMs')
291
+ const fs = fsOf({ fs: fsOption })
292
+ const result = { months: [], archived: 0, bytes: 0, compressed: 0, dryRun: dryRun === true, errors: [] }
293
+ const byMonth = new Map()
294
+ let planned = 0
295
+ let plannedBytes = 0
296
+ for (const key of await store.listKeys()) {
297
+ if (typeof key !== 'string' || key === '' || key.startsWith('__')) continue
298
+ let row
299
+ try {
300
+ row = await store.readRecord(key)
301
+ } catch (error) {
302
+ result.errors.push({ key, message: error && error.message ? error.message : String(error) })
303
+ continue
304
+ }
305
+ if (row === null || typeof row !== 'object' || typeof row.sessionId !== 'string') continue
306
+ if (!(updatedAtOf(row) < cutoffMs)) continue
307
+ const month = monthOfRecord(row)
308
+ if (month === undefined) continue
309
+ let plan = byMonth.get(month)
310
+ if (plan === undefined) { plan = { entries: new Map(), bytes: 0 }; byMonth.set(month, plan) }
311
+ const json = JSON.stringify(row)
312
+ const size = Buffer.byteLength(json)
313
+ plan.entries.set(key, { row, json, bytes: size })
314
+ plan.bytes += size
315
+ planned += 1
316
+ plannedBytes += size
317
+ }
318
+ if (result.dryRun || byMonth.size === 0) {
319
+ result.months = Array.from(byMonth.keys()).sort()
320
+ result.archived = planned
321
+ result.bytes = plannedBytes
322
+ return result
323
+ }
324
+
325
+ const manifest = await readManifest(fs, root)
326
+ for (const month of Array.from(byMonth.keys()).sort()) {
327
+ const plan = byMonth.get(month)
328
+ try {
329
+ const written = await archiveMonth({ root, fs, month, entries: plan.entries, manifest, store })
330
+ result.months.push(month)
331
+ result.archived += plan.entries.size
332
+ result.bytes += plan.bytes
333
+ result.compressed += written
334
+ } catch (error) {
335
+ // The tree still holds every record of this month: the run reports the
336
+ // failure and leaves the archive (if any) exactly as it found it.
337
+ result.errors.push({ month, message: error && error.message ? error.message : String(error) })
338
+ }
339
+ }
340
+ return result
341
+ }
342
+
343
+ // One month under the maintenance lock. Returns the bytes the month file grew by.
344
+ async function archiveMonth({ root, fs, month, entries, manifest, store }) {
345
+ const dir = join(root, ARCHIVE_DIR)
346
+ const recordPath = join(dir, month + RECORD_SUFFIX)
347
+ const keysPath = join(dir, month + KEYS_SUFFIX)
348
+ // Merge into what the month already holds instead of replacing it: a crashed run
349
+ // (or one that lost the race to delete a source) leaves rows this run must keep.
350
+ // The same session id keeps the newer row, so a retry can never roll a record
351
+ // back to an older revision.
352
+ const merged = await readArchiveMonth(fs, dir, month)
353
+ let changed = false
354
+ for (const [key, entry] of entries) {
355
+ const current = merged.get(key)
356
+ if (current !== undefined) {
357
+ if (updatedAtOf(current) > updatedAtOf(entry.row)) continue
358
+ if (JSON.stringify(current) === entry.json) continue
359
+ }
360
+ merged.set(key, entry.row)
361
+ changed = true
362
+ }
363
+ const keys = Array.from(merged.keys()).sort()
364
+ let packed = 0
365
+ if (changed) {
366
+ await fs.mkdir(dir, { recursive: true })
367
+ const lines = keys.map((key) => JSON.stringify(merged.get(key))).join('\n') + '\n'
368
+ const body = await gzip(Buffer.from(lines, 'utf8'))
369
+ await writeDurable(fs, recordPath, body)
370
+ await writeDurable(fs, keysPath, await gzip(Buffer.from(JSON.stringify(keys), 'utf8')))
371
+ packed = body.length
372
+ } else if (!(await fs.stat(keysPath).then(() => true, () => false))) {
373
+ // The rows are already archived; the fast key index behind listKeys() is not.
374
+ await fs.mkdir(dir, { recursive: true })
375
+ await writeDurable(fs, keysPath, await gzip(Buffer.from(JSON.stringify(keys), 'utf8')))
376
+ }
377
+ const size = changed ? packed : await fs.stat(recordPath).then((info) => info.size, () => 0)
378
+ const previous = manifest.months[month]
379
+ if (previous === undefined || previous.records !== keys.length || previous.bytes !== size) {
380
+ manifest.months[month] = { records: keys.length, bytes: size, updatedAt: Date.now() }
381
+ await writeManifest(fs, root, manifest)
382
+ }
383
+ // Record file, key index and manifest are on disk: only now is the tree copy
384
+ // redundant. A failure here leaves both, which the read path dedupes.
385
+ for (const key of entries.keys()) {
386
+ try {
387
+ // Optimistic delete: runtime writes do not hold the maintenance lock, so a
388
+ // session may have moved to a newer revision after this run planned it. The
389
+ // store removes the document only while it still holds the row archived
390
+ // here; a newer one stays in its tree (and wins on read) until the next run
391
+ // files that revision.
392
+ await store.deleteRecord(key, entries.get(key).row)
393
+ } catch (error) {
394
+ // Leaving the document behind is safe (the tree wins on read). It only means
395
+ // the next run sees the record again, so it must not abort the month.
396
+ }
397
+ }
398
+ return packed
399
+ }