@chenmiao8563/dsh-token-ledger 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/CHANGELOG.md +60 -1
- package/README.md +38 -1
- package/README.zh.md +31 -1
- package/docs/VERIFICATION.md +152 -7
- package/lib/cli.js +12 -9
- package/lib/client.js +678 -0
- package/lib/index.js +42 -6
- package/lib/ledger.js +63 -12
- package/lib/overview.js +228 -0
- package/lib/route.js +141 -0
- package/package.json +16 -1
package/lib/index.js
CHANGED
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
* @module dsh-token-ledger
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
-
import { UsageLedger, inheritedCut } from './ledger.js'
|
|
25
|
+
import { UsageLedger, inheritedCut, isForkSession } from './ledger.js'
|
|
26
|
+
import { createOverviewRoute, OVERVIEW_PATH } from './route.js'
|
|
26
27
|
import { ledgerPaths, loadLedger, saveLedger, writeFileAtomic } from './store.js'
|
|
27
28
|
import { join } from 'node:path'
|
|
28
29
|
|
|
@@ -131,12 +132,22 @@ export function apply(ctx, config = {}) {
|
|
|
131
132
|
const events = stored?.events
|
|
132
133
|
if (!Array.isArray(events)) continue
|
|
133
134
|
const meta = stored?.meta ?? header
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
// A fork's prefix belongs to its parent, which is counted separately.
|
|
136
|
+
// The boundary is located by the `session/end-seed` marker rather than
|
|
137
|
+
// by a raw count, because a count can be expressed in a different
|
|
138
|
+
// coordinate space than the array we were handed. See inheritedCut.
|
|
139
|
+
const inheritedEventCount = inheritedCut({
|
|
140
|
+
header: meta,
|
|
136
141
|
events,
|
|
137
|
-
|
|
138
|
-
inheritedEventCount: inheritedCut(meta, stored?.inheritedEventCount ?? meta?.seedLength),
|
|
142
|
+
inheritedEventCount: stored?.inheritedEventCount ?? meta?.seedLength,
|
|
139
143
|
})
|
|
144
|
+
if (isForkSession(meta) && inheritedEventCount === 0) {
|
|
145
|
+
log.warn(
|
|
146
|
+
'[token-ledger] forked session %s has no usable inheritance boundary; folding it whole, so its totals may include the parent prefix',
|
|
147
|
+
id,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
ledger.adoptHistory({ sessionId: id, events, inheritedEventCount })
|
|
140
151
|
sessions += 1
|
|
141
152
|
} catch (error) {
|
|
142
153
|
errors += 1
|
|
@@ -230,12 +241,37 @@ export function apply(ctx, config = {}) {
|
|
|
230
241
|
commandCtx.commands.register({
|
|
231
242
|
name: 'tokens',
|
|
232
243
|
description: 'Show the cumulative token ledger (per day and per model)',
|
|
233
|
-
input
|
|
244
|
+
// `input` must be an object carrying a non-empty `hint` string; a bare
|
|
245
|
+
// string is rejected by the command registry with a TypeError.
|
|
246
|
+
input: { hint: 'summary | export | json | path' },
|
|
234
247
|
handler: runCommand,
|
|
235
248
|
})
|
|
236
249
|
})
|
|
237
250
|
}
|
|
238
251
|
|
|
252
|
+
// The browser half reads this route, which is a read-only view of the live
|
|
253
|
+
// ledger. Without a web server the plugin is still fully usable through
|
|
254
|
+
// /tokens and the CLI, so the service is optional rather than injected.
|
|
255
|
+
//
|
|
256
|
+
// Registration is logged because the settings page can only report that it
|
|
257
|
+
// could not read the route. The log is what distinguishes "this profile has no
|
|
258
|
+
// web server" from "the route failed to register"; from the browser the two
|
|
259
|
+
// look identical.
|
|
260
|
+
if (typeof ctx.inject === 'function') {
|
|
261
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
262
|
+
ctx.effect(() => {
|
|
263
|
+
const dispose = webCtx.webServer.register(
|
|
264
|
+
createOverviewRoute({
|
|
265
|
+
ledger,
|
|
266
|
+
options: { onError: (error) => log.warn('[token-ledger] overview route failed: %o', error) },
|
|
267
|
+
}),
|
|
268
|
+
)
|
|
269
|
+
log.info('[token-ledger] settings page route ready at %s', OVERVIEW_PATH)
|
|
270
|
+
return dispose
|
|
271
|
+
}, 'token-ledger: overview route')
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
|
|
239
275
|
if (typeof ctx.effect === 'function') {
|
|
240
276
|
ctx.effect(
|
|
241
277
|
() => () => {
|
package/lib/ledger.js
CHANGED
|
@@ -36,8 +36,17 @@
|
|
|
36
36
|
* @module dsh-token-ledger/ledger
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Bumped whenever the folded state shape **or the counting semantics** change.
|
|
41
|
+
*
|
|
42
|
+
* A stored ledger is a cache of a fold, and its per-session cursors claim those
|
|
43
|
+
* sessions are fully consumed. State written by a version that counted
|
|
44
|
+
* differently would therefore keep its wrong totals forever: the fixed version
|
|
45
|
+
* would skip every session as already consumed. Version 2 exists precisely
|
|
46
|
+
* because 0.1.0's boundary rule mis-counted forks, so its files must be
|
|
47
|
+
* discarded and rebuilt rather than trusted.
|
|
48
|
+
*/
|
|
49
|
+
export const LEDGER_VERSION = 2
|
|
41
50
|
|
|
42
51
|
/** The four disjoint provider usage buckets, all defaulting to zero. */
|
|
43
52
|
const BUCKET_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens']
|
|
@@ -156,15 +165,24 @@ export function dateKeyOf(timeMs, now = new Date()) {
|
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
/**
|
|
159
|
-
*
|
|
168
|
+
* Whether a stored session header describes a fork.
|
|
169
|
+
*
|
|
170
|
+
* @param {object} [header] - a `SessionHeader`-shaped object.
|
|
171
|
+
* @returns {boolean} true when the session has a parent.
|
|
172
|
+
*/
|
|
173
|
+
export function isForkSession(header) {
|
|
174
|
+
return header?.parentSession !== undefined && header?.parentSession !== null
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decide how much of a session's event list belongs to another session.
|
|
160
179
|
*
|
|
161
180
|
* A session log can carry a prefix of already-recorded history. Two very
|
|
162
181
|
* different situations produce one, and only one of them must be cut:
|
|
163
182
|
*
|
|
164
183
|
* - **Fork** (`header.parentSession` is set): the prefix is the *parent
|
|
165
184
|
* session's* history, and the parent is counted separately. Folding the
|
|
166
|
-
* prefix here would count those tokens twice, so it is cut
|
|
167
|
-
* `inheritedEventCount`.
|
|
185
|
+
* prefix here would count those tokens twice, so it is cut.
|
|
168
186
|
* - **Resume** (no parent): the prefix is *this session's own* earlier history,
|
|
169
187
|
* stored once, with no other session to double count it against. Cutting it
|
|
170
188
|
* would lose tokens, so nothing is cut.
|
|
@@ -175,14 +193,42 @@ export function dateKeyOf(timeMs, now = new Date()) {
|
|
|
175
193
|
* while for the non-forked logs carrying the same marker the prefix did not
|
|
176
194
|
* reappear later in the file.
|
|
177
195
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
196
|
+
* ## Why the boundary is located by the marker
|
|
197
|
+
*
|
|
198
|
+
* The obvious implementation — skip `inheritedEventCount` leading entries — is
|
|
199
|
+
* wrong, and shipping it under-counted a real 139-session home by hundreds of
|
|
200
|
+
* millions of tokens. A stored session reaches this fold in one of two
|
|
201
|
+
* coordinate spaces:
|
|
202
|
+
*
|
|
203
|
+
* - the **logical** event list, where positions match `seq` and
|
|
204
|
+
* `inheritedEventCount` is directly meaningful;
|
|
205
|
+
* - the **compact row** form the log is stored in, where several logical events
|
|
206
|
+
* share one record, so the declared count overshoots the array length and
|
|
207
|
+
* applying it skipped the entire session.
|
|
208
|
+
*
|
|
209
|
+
* `session/end-seed` is present in both forms and marks the same boundary, so
|
|
210
|
+
* it is authoritative. The declared count is only a fallback, and only when it
|
|
211
|
+
* is a plausible index into the list we were actually handed: a count that
|
|
212
|
+
* reaches past the end proves it is not in this list's space and is refused
|
|
213
|
+
* rather than trusted. Callers are expected to warn when a fork yields a cut of
|
|
214
|
+
* zero, because that over-counts rather than silently losing data.
|
|
215
|
+
*
|
|
216
|
+
* @param {object} input - what is known about the session's stored content.
|
|
217
|
+
* @param {object} [input.header] - stored header/meta carrying `parentSession`.
|
|
218
|
+
* @param {object[]} [input.events] - the stored event list about to be folded.
|
|
219
|
+
* @param {number} [input.inheritedEventCount] - the cut storage declares.
|
|
180
220
|
* @returns {number} the number of leading events to skip.
|
|
181
221
|
*/
|
|
182
|
-
export function inheritedCut(header, inheritedEventCount) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
222
|
+
export function inheritedCut({ header, events, inheritedEventCount } = {}) {
|
|
223
|
+
if (!isForkSession(header)) return 0
|
|
224
|
+
const list = Array.isArray(events) ? events : []
|
|
225
|
+
const marker = list.findIndex((event) => event?.type === 'session/end-seed')
|
|
226
|
+
if (marker >= 0) return marker + 1
|
|
227
|
+
const declared =
|
|
228
|
+
typeof inheritedEventCount === 'number' && Number.isFinite(inheritedEventCount) && inheritedEventCount > 0
|
|
229
|
+
? Math.floor(inheritedEventCount)
|
|
230
|
+
: 0
|
|
231
|
+
return declared > 0 && declared < list.length ? declared : 0
|
|
186
232
|
}
|
|
187
233
|
|
|
188
234
|
/**
|
|
@@ -514,13 +560,18 @@ export class UsageLedger {
|
|
|
514
560
|
* Merge a snapshot back in. Returns false for missing or incompatible state,
|
|
515
561
|
* which callers treat as "start fresh" rather than as an error.
|
|
516
562
|
*
|
|
563
|
+
* The postcondition of a `false` return is an empty ledger, never a partially
|
|
564
|
+
* loaded one: the state is cleared before the snapshot is judged, so a
|
|
565
|
+
* rejected file cannot leave stale totals or cursors behind for the next
|
|
566
|
+
* caller to mistake for real data.
|
|
567
|
+
*
|
|
517
568
|
* @param {unknown} snapshot - a previously produced snapshot.
|
|
518
569
|
* @returns {boolean} whether the snapshot was usable.
|
|
519
570
|
*/
|
|
520
571
|
restore(snapshot) {
|
|
572
|
+
this.reset()
|
|
521
573
|
if (snapshot === null || typeof snapshot !== 'object') return false
|
|
522
574
|
if (snapshot.version !== LEDGER_VERSION) return false
|
|
523
|
-
this.reset()
|
|
524
575
|
|
|
525
576
|
const absorb = (raw) => countersFromUsage(raw)
|
|
526
577
|
const totals = absorb(snapshot.totals)
|
package/lib/overview.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The overview payload the browser half renders.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from the plugin so it is a pure function of a ledger snapshot
|
|
5
|
+
* and a clock, and therefore testable without a host, a browser or a socket.
|
|
6
|
+
* The host route is then a thin wrapper: take the live snapshot, call this,
|
|
7
|
+
* serialize.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the payload is shaped like this
|
|
10
|
+
*
|
|
11
|
+
* The page the client draws has three parts — range totals, today, and a
|
|
12
|
+
* calendar. Each is answered here rather than in the browser so the client
|
|
13
|
+
* stays dumb and two clients cannot disagree:
|
|
14
|
+
*
|
|
15
|
+
* - `ranges` carries the totals for the three selectable ranges, plus the
|
|
16
|
+
* derived cache-hit rate, so switching a tab is a re-render and not a
|
|
17
|
+
* re-fetch.
|
|
18
|
+
* - `today` carries the current local day's numbers, which is what "live"
|
|
19
|
+
* means for a ledger: a value that moves as steps complete.
|
|
20
|
+
* - `daily` is a **contiguous** day series including zero-token days, because
|
|
21
|
+
* a calendar grid needs every cell, and gap-filling in one place is better
|
|
22
|
+
* than in every view.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-token-ledger/overview
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** How many days of daily series to publish, bounding the payload. */
|
|
28
|
+
export const SERIES_DAYS = 366
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a local calendar day as `YYYY-MM-DD`.
|
|
32
|
+
*
|
|
33
|
+
* @param {Date} date - the day.
|
|
34
|
+
* @returns {string} the key.
|
|
35
|
+
*/
|
|
36
|
+
export function dayKey(date) {
|
|
37
|
+
const pad = (value) => (value < 10 ? `0${value}` : String(value))
|
|
38
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a `YYYY-MM-DD` key back into a local midnight `Date`.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} key - the day key.
|
|
45
|
+
* @returns {Date} local midnight of that day.
|
|
46
|
+
*/
|
|
47
|
+
export function parseDayKey(key) {
|
|
48
|
+
const [year, month, day] = String(key).split('-').map((part) => Number.parseInt(part, 10))
|
|
49
|
+
return new Date(year, (month ?? 1) - 1, day ?? 1)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The local date a range starts on.
|
|
54
|
+
*
|
|
55
|
+
* `week` is the trailing seven days including today, because "7 天" reads as a
|
|
56
|
+
* rolling window; `month` and `year` are calendar periods, because "本月" and
|
|
57
|
+
* "本年" read as the period you are in.
|
|
58
|
+
*
|
|
59
|
+
* @param {'week'|'month'|'year'} kind - the range.
|
|
60
|
+
* @param {Date} now - the current time.
|
|
61
|
+
* @returns {Date} local midnight of the first day in range.
|
|
62
|
+
*/
|
|
63
|
+
export function rangeStart(kind, now) {
|
|
64
|
+
if (kind === 'week') {
|
|
65
|
+
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
66
|
+
start.setDate(start.getDate() - 6)
|
|
67
|
+
return start
|
|
68
|
+
}
|
|
69
|
+
if (kind === 'month') return new Date(now.getFullYear(), now.getMonth(), 1)
|
|
70
|
+
if (kind === 'year') return new Date(now.getFullYear(), 0, 1)
|
|
71
|
+
throw new Error(`unknown range "${kind}" (expected week, month, or year)`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* An empty counter set in the shape the ledger emits.
|
|
76
|
+
*
|
|
77
|
+
* @returns {{ inputTokens: number, outputTokens: number, cacheReadTokens: number, cacheWriteTokens: number, totalTokens: number, reasoningTokens: number }} zeroed counters.
|
|
78
|
+
*/
|
|
79
|
+
function emptyTotals() {
|
|
80
|
+
return {
|
|
81
|
+
inputTokens: 0,
|
|
82
|
+
outputTokens: 0,
|
|
83
|
+
cacheReadTokens: 0,
|
|
84
|
+
cacheWriteTokens: 0,
|
|
85
|
+
totalTokens: 0,
|
|
86
|
+
reasoningTokens: 0,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Add one day row into an accumulator.
|
|
92
|
+
*
|
|
93
|
+
* @param {ReturnType<typeof emptyTotals>} target - mutated accumulator.
|
|
94
|
+
* @param {object} row - a ledger daily row.
|
|
95
|
+
* @returns {void}
|
|
96
|
+
*/
|
|
97
|
+
function accumulate(target, row) {
|
|
98
|
+
for (const key of ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'totalTokens', 'reasoningTokens']) {
|
|
99
|
+
const value = row?.[key]
|
|
100
|
+
if (typeof value === 'number' && Number.isFinite(value)) target[key] += value
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The share of input tokens served from the prompt cache.
|
|
106
|
+
*
|
|
107
|
+
* Defined over input only: a cache read is an input token that did not have to
|
|
108
|
+
* be re-sent, so `cacheRead / (cacheRead + uncachedInput)` is the fraction of
|
|
109
|
+
* input the cache absorbed. Returns `null` when there was no input at all,
|
|
110
|
+
* which the client renders as a dash rather than as a misleading 0%.
|
|
111
|
+
*
|
|
112
|
+
* @param {{ inputTokens: number, cacheReadTokens: number }} totals - the counters.
|
|
113
|
+
* @returns {number|null} a fraction between 0 and 1, or null when undefined.
|
|
114
|
+
*/
|
|
115
|
+
export function cacheHitRate(totals) {
|
|
116
|
+
const denominator = (totals.cacheReadTokens ?? 0) + (totals.inputTokens ?? 0)
|
|
117
|
+
if (denominator <= 0) return null
|
|
118
|
+
return totals.cacheReadTokens / denominator
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fold a set of day rows into one range summary.
|
|
123
|
+
*
|
|
124
|
+
* @param {object[]} rows - day rows inside the range.
|
|
125
|
+
* @returns {{ totals: ReturnType<typeof emptyTotals>, calls: number, activeDays: number, cacheHitRate: number|null }} the summary.
|
|
126
|
+
*/
|
|
127
|
+
export function summarize(rows) {
|
|
128
|
+
const totals = emptyTotals()
|
|
129
|
+
let calls = 0
|
|
130
|
+
let activeDays = 0
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
accumulate(totals, row)
|
|
133
|
+
if (typeof row?.calls === 'number' && Number.isFinite(row.calls)) calls += row.calls
|
|
134
|
+
if ((row?.totalTokens ?? 0) > 0) activeDays += 1
|
|
135
|
+
}
|
|
136
|
+
return { totals, calls, activeDays, cacheHitRate: cacheHitRate(totals) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A contiguous day series ending today, gaps filled with zeroes.
|
|
141
|
+
*
|
|
142
|
+
* @param {Map<string, object>} byDay - ledger daily rows keyed by day.
|
|
143
|
+
* @param {Date} now - the current time.
|
|
144
|
+
* @param {number} [limit] - maximum number of days emitted.
|
|
145
|
+
* @returns {object[]} ascending day rows, one per calendar day.
|
|
146
|
+
*/
|
|
147
|
+
export function buildSeries(byDay, now, limit = SERIES_DAYS) {
|
|
148
|
+
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
|
149
|
+
const keys = [...byDay.keys()].sort()
|
|
150
|
+
const earliest = keys.length > 0 ? parseDayKey(keys[0]) : today
|
|
151
|
+
const span = Math.min(limit, Math.max(1, Math.round((today - earliest) / 86400000) + 1))
|
|
152
|
+
|
|
153
|
+
const series = []
|
|
154
|
+
for (let offset = span - 1; offset >= 0; offset -= 1) {
|
|
155
|
+
const date = new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset)
|
|
156
|
+
const key = dayKey(date)
|
|
157
|
+
const row = byDay.get(key)
|
|
158
|
+
series.push({
|
|
159
|
+
date: key,
|
|
160
|
+
calls: typeof row?.calls === 'number' ? row.calls : 0,
|
|
161
|
+
inputTokens: row?.inputTokens ?? 0,
|
|
162
|
+
outputTokens: row?.outputTokens ?? 0,
|
|
163
|
+
cacheReadTokens: row?.cacheReadTokens ?? 0,
|
|
164
|
+
cacheWriteTokens: row?.cacheWriteTokens ?? 0,
|
|
165
|
+
totalTokens: row?.totalTokens ?? 0,
|
|
166
|
+
reasoningTokens: row?.reasoningTokens ?? 0,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
return series
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Build the whole overview payload.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} snapshot - a `UsageLedger#snapshot()` value.
|
|
176
|
+
* @param {{ now?: Date }} [options] - clock override for tests.
|
|
177
|
+
* @returns {object} the JSON payload the client renders. Every field is a
|
|
178
|
+
* primitive, a plain object or an array, so it serializes losslessly.
|
|
179
|
+
*/
|
|
180
|
+
export function buildOverview(snapshot, { now = new Date() } = {}) {
|
|
181
|
+
const dailyRows = Array.isArray(snapshot?.daily) ? snapshot.daily : []
|
|
182
|
+
const byDay = new Map()
|
|
183
|
+
for (const row of dailyRows) {
|
|
184
|
+
if (typeof row?.date === 'string') byDay.set(row.date, row)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const series = buildSeries(byDay, now)
|
|
188
|
+
const todayKey = dayKey(now)
|
|
189
|
+
|
|
190
|
+
const ranges = {}
|
|
191
|
+
for (const kind of ['week', 'month', 'year']) {
|
|
192
|
+
const startKey = dayKey(rangeStart(kind, now))
|
|
193
|
+
const inRange = [...byDay.entries()]
|
|
194
|
+
.filter(([key]) => key >= startKey && key <= todayKey)
|
|
195
|
+
.map(([, row]) => row)
|
|
196
|
+
ranges[kind] = { kind, from: startKey, to: todayKey, ...summarize(inRange) }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const todayRow = byDay.get(todayKey)
|
|
200
|
+
const todaySummary = summarize(todayRow === undefined ? [] : [todayRow])
|
|
201
|
+
|
|
202
|
+
const models = (Array.isArray(snapshot?.models) ? snapshot.models : [])
|
|
203
|
+
.slice(0, 12)
|
|
204
|
+
.map((model) => ({
|
|
205
|
+
model: String(model?.model ?? 'unknown'),
|
|
206
|
+
calls: typeof model?.calls === 'number' ? model.calls : 0,
|
|
207
|
+
totalTokens: model?.totalTokens ?? 0,
|
|
208
|
+
cacheReadTokens: model?.cacheReadTokens ?? 0,
|
|
209
|
+
inputTokens: model?.inputTokens ?? 0,
|
|
210
|
+
cacheHitRate: cacheHitRate({ cacheReadTokens: model?.cacheReadTokens ?? 0, inputTokens: model?.inputTokens ?? 0 }),
|
|
211
|
+
}))
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
plugin: 'token-ledger',
|
|
215
|
+
generatedAt: now.getTime(),
|
|
216
|
+
ledgerUpdatedAt: typeof snapshot?.updatedAt === 'number' ? snapshot.updatedAt : null,
|
|
217
|
+
totals: {
|
|
218
|
+
...emptyTotals(),
|
|
219
|
+
...(snapshot?.totals ?? {}),
|
|
220
|
+
cacheHitRate: cacheHitRate(snapshot?.totals ?? {}),
|
|
221
|
+
},
|
|
222
|
+
ranges,
|
|
223
|
+
today: { date: todayKey, lastEventAt: typeof snapshot?.updatedAt === 'number' ? snapshot.updatedAt : null, ...todaySummary },
|
|
224
|
+
series,
|
|
225
|
+
models,
|
|
226
|
+
sessionCount: Array.isArray(snapshot?.sessions) ? snapshot.sessions.length : 0,
|
|
227
|
+
}
|
|
228
|
+
}
|
package/lib/route.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host route that feeds the browser half.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a route instead of the settings namespace
|
|
5
|
+
*
|
|
6
|
+
* The obvious channel for plugin data is a settings namespace, and the reference
|
|
7
|
+
* UI plugins use one. This plugin deliberately does not, for two reasons:
|
|
8
|
+
*
|
|
9
|
+
* 1. A settings namespace needs a schema, and the schema library is a real
|
|
10
|
+
* dependency. This package's whole installability claim is that it has none.
|
|
11
|
+
* 2. Settings are persisted. Publishing token aggregates through them would
|
|
12
|
+
* rewrite `settings.yaml` on every debounce and grow it with data that is
|
|
13
|
+
* derived and reproducible, which is exactly the mistake that left a 185 kB
|
|
14
|
+
* settings backup on the machine this was developed against.
|
|
15
|
+
*
|
|
16
|
+
* The ledger file stays the store of record; this route is a read-only view of
|
|
17
|
+
* the live in-memory ledger.
|
|
18
|
+
*
|
|
19
|
+
* ## The guard
|
|
20
|
+
*
|
|
21
|
+
* The route exposes local usage counts, which are not secrets but are also
|
|
22
|
+
* nobody else's business. The web server binds loopback by default, but an
|
|
23
|
+
* operator may bind `0.0.0.0`, so the handler checks the peer itself: an
|
|
24
|
+
* address is accepted only when it is loopback, and an `Origin` header is
|
|
25
|
+
* accepted only when it is loopback or absent.
|
|
26
|
+
*
|
|
27
|
+
* Absent is allowed because the desktop build loads the front end over
|
|
28
|
+
* `file://` and carries `fetch` over an IPC bridge, where a socket address may
|
|
29
|
+
* be unavailable. Refusing that case would break the desktop app to protect
|
|
30
|
+
* against a request that never left the machine.
|
|
31
|
+
*
|
|
32
|
+
* @module dsh-token-ledger/route
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { buildOverview } from './overview.js'
|
|
36
|
+
|
|
37
|
+
/** The path the browser half fetches. */
|
|
38
|
+
export const OVERVIEW_PATH = '/api/token-ledger/summary'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Whether a peer address is loopback.
|
|
42
|
+
*
|
|
43
|
+
* @param {string|undefined} address - `req.socket.remoteAddress`.
|
|
44
|
+
* @returns {boolean} true for IPv4 or IPv6 loopback.
|
|
45
|
+
*/
|
|
46
|
+
export function isLoopbackAddress(address) {
|
|
47
|
+
if (typeof address !== 'string') return false
|
|
48
|
+
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether an `Origin` header belongs to a local page.
|
|
53
|
+
*
|
|
54
|
+
* @param {string|undefined} origin - the header value.
|
|
55
|
+
* @returns {boolean} true when absent, `null`, `file://`, or a loopback host.
|
|
56
|
+
*/
|
|
57
|
+
export function isLocalOrigin(origin) {
|
|
58
|
+
if (origin === undefined || origin === '' || origin === 'null') return true
|
|
59
|
+
if (origin.startsWith('file://')) return true
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(origin)
|
|
62
|
+
// WHATWG URL keeps the brackets on an IPv6 host: `new URL('http://[::1]/').hostname`
|
|
63
|
+
// is `'[::1]'`, so they have to come off before comparing.
|
|
64
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '')
|
|
65
|
+
return hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost'
|
|
66
|
+
} catch {
|
|
67
|
+
return false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Decide whether a request may read the overview.
|
|
73
|
+
*
|
|
74
|
+
* @param {object} req - a node `IncomingMessage`-shaped object.
|
|
75
|
+
* @returns {boolean} whether to serve it.
|
|
76
|
+
*/
|
|
77
|
+
export function isAllowedPeer(req) {
|
|
78
|
+
const address = req?.socket?.remoteAddress
|
|
79
|
+
// An unrecognized address means the request did not arrive over a socket we
|
|
80
|
+
// can judge (the desktop IPC bridge), not that it came from a stranger.
|
|
81
|
+
if (typeof address === 'string' && address !== '' && !isLoopbackAddress(address)) return false
|
|
82
|
+
return isLocalOrigin(req?.headers?.origin)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Write a JSON response.
|
|
87
|
+
*
|
|
88
|
+
* @param {object} res - a node `ServerResponse`-shaped object.
|
|
89
|
+
* @param {number} status - the HTTP status.
|
|
90
|
+
* @param {unknown} body - the JSON value.
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
93
|
+
function sendJson(res, status, body) {
|
|
94
|
+
const text = JSON.stringify(body)
|
|
95
|
+
res.writeHead(status, {
|
|
96
|
+
'content-type': 'application/json; charset=utf-8',
|
|
97
|
+
'content-length': Buffer.byteLength(text),
|
|
98
|
+
// The numbers move as steps complete, so a cached copy is always wrong.
|
|
99
|
+
'cache-control': 'no-store',
|
|
100
|
+
})
|
|
101
|
+
res.end(text)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Build the route definition to hand to `ctx.webServer.register`.
|
|
106
|
+
*
|
|
107
|
+
* The handler never throws: a fault becomes a 500 with a JSON body, because the
|
|
108
|
+
* web server logs a warning and destroys the socket when a handler throws, and
|
|
109
|
+
* a client that cannot reach the plugin should be able to say why.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} input - the route's dependencies.
|
|
112
|
+
* @param {{ snapshot: () => object }} input.ledger - the live ledger.
|
|
113
|
+
* @param {{ now?: () => Date, onError?: (error: unknown) => void }} [input.options] - clock and diagnostics.
|
|
114
|
+
* @returns {{ kind: 'exact', path: string, handler: (req: object, res: object) => void }} the route.
|
|
115
|
+
*/
|
|
116
|
+
export function createOverviewRoute({ ledger, options = {} }) {
|
|
117
|
+
const now = options.now ?? (() => new Date())
|
|
118
|
+
const onError = options.onError ?? (() => {})
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
kind: 'exact',
|
|
122
|
+
path: OVERVIEW_PATH,
|
|
123
|
+
handler: (req, res) => {
|
|
124
|
+
if (!isAllowedPeer(req)) {
|
|
125
|
+
sendJson(res, 403, { error: 'forbidden' })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
const method = req?.method ?? 'GET'
|
|
129
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
130
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
sendJson(res, 200, buildOverview(ledger.snapshot(), { now: now() }))
|
|
135
|
+
} catch (error) {
|
|
136
|
+
onError(error)
|
|
137
|
+
sendJson(res, 500, { error: 'overview unavailable' })
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
}
|
|
141
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chenmiao8563/dsh-token-ledger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Transparent, auditable token accounting for DeepSeek Harness: a restart-safe ledger over the durable session log, plus a CLI that recomputes it from raw logs and diffs the result.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"./ledger": "./lib/ledger.js",
|
|
27
27
|
"./session-log": "./lib/session-log.js",
|
|
28
28
|
"./store": "./lib/store.js",
|
|
29
|
+
"./overview": "./lib/overview.js",
|
|
30
|
+
"./route": "./lib/route.js",
|
|
31
|
+
"./client": "./lib/client.js",
|
|
29
32
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
30
33
|
"./package.json": "./package.json"
|
|
31
34
|
},
|
|
@@ -52,11 +55,23 @@
|
|
|
52
55
|
"verify": "node scripts/verify-package.mjs",
|
|
53
56
|
"prepublishOnly": "npm run verify && npm test"
|
|
54
57
|
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
|
|
60
|
+
"react": "18.3.1",
|
|
61
|
+
"react-dom": "18.3.1"
|
|
62
|
+
},
|
|
55
63
|
"dsh": {
|
|
56
64
|
"pluginType": "feature",
|
|
57
65
|
"bundle": {
|
|
58
66
|
"patch": "./cordis.patch.yml"
|
|
59
67
|
},
|
|
68
|
+
"client": {
|
|
69
|
+
"platform": "web",
|
|
70
|
+
"inject": [
|
|
71
|
+
"@deepseek-ai/dsh-client-locale",
|
|
72
|
+
"@deepseek-ai/dsh-client-ui-settings-general"
|
|
73
|
+
]
|
|
74
|
+
},
|
|
60
75
|
"compatibility": {
|
|
61
76
|
"dsh": ">=0.1.2-alpha.1"
|
|
62
77
|
}
|