dsh-token-total 1.1.0 → 1.1.3
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.md +7 -4
- package/lib/client.js +423 -524
- package/lib/index.js +209 -6
- package/lib/types/client/UsageCards.d.ts +28 -0
- package/lib/types/client/context-window-core.d.ts +9 -0
- package/lib/types/client/session-usage-locales.d.ts +17 -0
- package/lib/types/context-window.d.ts +5 -0
- package/lib/types/usage-buckets.d.ts +14 -0
- package/lib/types/usage-composition.d.ts +12 -0
- package/lib/types/wire.d.ts +30 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.tsx +6 -6
- package/src/client/StatsLineEnhanced.tsx +59 -29
- package/src/client/UsageCards.module.css +74 -0
- package/src/client/UsageCards.tsx +244 -0
- package/src/client/context-window-core.ts +39 -0
- package/src/client/session-usage-locales.ts +27 -3
- package/src/client/stats-line-core.ts +14 -5
- package/src/context-window.ts +15 -5
- package/src/session-detail.ts +3 -2
- package/src/usage-buckets.ts +27 -0
- package/src/usage-composition.ts +203 -0
- package/src/wire.ts +37 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spend composition: what the session's requests actually paid for, split by
|
|
3
|
+
* what filled each prompt.
|
|
4
|
+
*
|
|
5
|
+
* The context-window snapshot answers "what is in the window NOW". This module
|
|
6
|
+
* answers the other half of the picture: every provider request replays the
|
|
7
|
+
* whole message list, so a session that made 300 calls paid for the system
|
|
8
|
+
* prompt 300 times. The number the Token stats card shows per bucket is
|
|
9
|
+
* therefore the sum of that bucket's size AT EVERY REQUEST — not its current
|
|
10
|
+
* size, which would always favour whatever arrived last.
|
|
11
|
+
*
|
|
12
|
+
* The naive way to get that is to rebuild the replayed list per request, which
|
|
13
|
+
* is O(requests × events) and far too slow for a view that polls every 1.5 s
|
|
14
|
+
* over a long session. Instead this walks the log ONCE and keeps a live block
|
|
15
|
+
* map with per-bucket subtotals: a request snapshots those subtotals (8 adds)
|
|
16
|
+
* plus the tool catalogue total, and shadowed sequences subtract their block
|
|
17
|
+
* the moment compaction prunes them. That is O(events + requests × 8).
|
|
18
|
+
*
|
|
19
|
+
* Token sizes are ESTIMATES from the same character-count estimator as
|
|
20
|
+
* `context-window.ts` (the provider never reports a per-block number), so the
|
|
21
|
+
* card labels them approximate. The billed total, cache split and request
|
|
22
|
+
* count come straight from `usage` records and are exact.
|
|
23
|
+
*/
|
|
24
|
+
import { messageTokens, toolTokens } from './context-window.ts'
|
|
25
|
+
import { USAGE_BUCKET_ORDER } from './usage-buckets.ts'
|
|
26
|
+
import type { UsageSessionEvent } from './context-types.ts'
|
|
27
|
+
import type { SessionUsageBucket, SessionUsageComposition, SessionUsageTotals } from './wire.ts'
|
|
28
|
+
|
|
29
|
+
/** One replayed message block (or a folded compaction summary). */
|
|
30
|
+
interface LiveBlock {
|
|
31
|
+
bucket: SessionUsageBucket
|
|
32
|
+
tokens: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Shadowed sequences a compaction event removed from the replay list. */
|
|
36
|
+
function shadowedSeqs(data: any): number[] {
|
|
37
|
+
const seqs: number[] = []
|
|
38
|
+
for (const seq of Array.isArray(data?.shadowedSeqs) ? data.shadowedSeqs : []) seqs.push(Number(seq))
|
|
39
|
+
const range = data?.shadowedRange
|
|
40
|
+
if (typeof range?.start === 'number' && typeof range?.end === 'number') {
|
|
41
|
+
for (let seq = range.start; seq <= range.end; seq++) seqs.push(seq)
|
|
42
|
+
}
|
|
43
|
+
return seqs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Accumulate one session's own events into the eight legend buckets.
|
|
48
|
+
*
|
|
49
|
+
* Returns null for an empty log or a log whose requests never reported usage
|
|
50
|
+
* (an older host writing no usage records) so the card can hide itself rather
|
|
51
|
+
* than draw an empty ring.
|
|
52
|
+
*/
|
|
53
|
+
export function buildSessionUsageComposition(
|
|
54
|
+
events: readonly UsageSessionEvent[],
|
|
55
|
+
): SessionUsageComposition | null {
|
|
56
|
+
if (events.length === 0) return null
|
|
57
|
+
const ordered = [...events].sort((a, b) => a.seq - b.seq)
|
|
58
|
+
|
|
59
|
+
/** Blocks still replayed by the next request, keyed by their event sequence. */
|
|
60
|
+
const live = new Map<number, LiveBlock>()
|
|
61
|
+
/** Current size of the replay list per bucket — what one request would send. */
|
|
62
|
+
const liveByBucket = new Map<SessionUsageBucket, number>()
|
|
63
|
+
/** What every request so far sent, summed per bucket. */
|
|
64
|
+
const acc = new Map<SessionUsageBucket, number>()
|
|
65
|
+
const totals: SessionUsageTotals = {
|
|
66
|
+
uncachedInputTokens: 0,
|
|
67
|
+
outputTokens: 0,
|
|
68
|
+
cacheReadTokens: 0,
|
|
69
|
+
cacheWriteTokens: 0,
|
|
70
|
+
}
|
|
71
|
+
let billed = 0
|
|
72
|
+
let requests = 0
|
|
73
|
+
/** Prompt tokens the newest request/header's tool catalogue costs. */
|
|
74
|
+
let catalogue = 0
|
|
75
|
+
|
|
76
|
+
const addBlock = (seq: number, bucket: SessionUsageBucket, tokens: number): void => {
|
|
77
|
+
if (tokens <= 0) return
|
|
78
|
+
live.set(seq, { bucket, tokens })
|
|
79
|
+
liveByBucket.set(bucket, (liveByBucket.get(bucket) ?? 0) + tokens)
|
|
80
|
+
}
|
|
81
|
+
const dropBlock = (seq: number): void => {
|
|
82
|
+
const block = live.get(seq)
|
|
83
|
+
if (block === undefined) return
|
|
84
|
+
live.delete(seq)
|
|
85
|
+
liveByBucket.set(block.bucket, (liveByBucket.get(block.bucket) ?? 0) - block.tokens)
|
|
86
|
+
}
|
|
87
|
+
/** Charge one request for everything it replayed. */
|
|
88
|
+
const snapshot = (): void => {
|
|
89
|
+
for (const [bucket, tokens] of liveByBucket) {
|
|
90
|
+
if (tokens > 0) acc.set(bucket, (acc.get(bucket) ?? 0) + tokens)
|
|
91
|
+
}
|
|
92
|
+
if (catalogue > 0) acc.set('toolDef', (acc.get('toolDef') ?? 0) + catalogue)
|
|
93
|
+
}
|
|
94
|
+
/** Fold the provider's exact report into the billed split. */
|
|
95
|
+
const account = (usage: any): void => {
|
|
96
|
+
const input = Number(usage?.inputTokens) || 0
|
|
97
|
+
const cacheRead = Number(usage?.cacheReadTokens) || 0
|
|
98
|
+
const cacheWrite = Number(usage?.cacheWriteTokens) || 0
|
|
99
|
+
const output = Number(usage?.outputTokens) || 0
|
|
100
|
+
totals.uncachedInputTokens += input
|
|
101
|
+
totals.cacheReadTokens += cacheRead
|
|
102
|
+
totals.cacheWriteTokens += cacheWrite
|
|
103
|
+
totals.outputTokens += output
|
|
104
|
+
billed += input + cacheRead + cacheWrite + output
|
|
105
|
+
requests++
|
|
106
|
+
// Output never belongs to the replayed prompt, so it is not a live block;
|
|
107
|
+
// it is charged straight to its own bucket.
|
|
108
|
+
if (output > 0) acc.set('output', (acc.get('output') ?? 0) + output)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const event of ordered) {
|
|
112
|
+
const data: any = event.data ?? {}
|
|
113
|
+
if (event.type === 'compaction/prune' || event.type === 'compaction/summary') {
|
|
114
|
+
// The summary call itself carried the PRE-prune prompt, so it is charged
|
|
115
|
+
// before the shadow set is applied — and before its own block lands.
|
|
116
|
+
if (event.type === 'compaction/summary' && data.usage !== undefined && data.usage !== null) {
|
|
117
|
+
snapshot()
|
|
118
|
+
account(data.usage)
|
|
119
|
+
}
|
|
120
|
+
for (const seq of shadowedSeqs(data)) dropBlock(seq)
|
|
121
|
+
if (event.type === 'compaction/summary') {
|
|
122
|
+
addBlock(event.seq, 'injected', messageTokens({ content: data.summary }))
|
|
123
|
+
}
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (event.type === 'request/header') {
|
|
127
|
+
const header = data.header ?? {}
|
|
128
|
+
if (Array.isArray(header.tools)) {
|
|
129
|
+
catalogue = 0
|
|
130
|
+
for (const tool of header.tools) catalogue += toolTokens(tool)
|
|
131
|
+
}
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
if (event.type === 'assistant/message') {
|
|
135
|
+
if (data.usage !== undefined && data.usage !== null) {
|
|
136
|
+
snapshot()
|
|
137
|
+
account(data.usage)
|
|
138
|
+
}
|
|
139
|
+
addBlock(event.seq, 'assistant', messageTokens(data.message ?? { content: data.content }))
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
if (event.type === 'system/message') {
|
|
143
|
+
const kind = String(data.message?.source?.kind ?? data.source?.kind ?? 'system-prompt')
|
|
144
|
+
const bucket: SessionUsageBucket = kind === 'system-prompt' ? 'systemPrompt' : 'injected'
|
|
145
|
+
addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }))
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
if (event.type === 'user/message') {
|
|
149
|
+
const kind = String(data.source?.kind ?? data.message?.source?.kind ?? 'user')
|
|
150
|
+
const bucket: SessionUsageBucket = kind === 'user'
|
|
151
|
+
? 'user'
|
|
152
|
+
: kind === 'skill-catalog' ? 'skill' : 'injected'
|
|
153
|
+
addBlock(event.seq, bucket, messageTokens(data.message ?? { content: data.content }))
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
if (event.type === 'tool/result') {
|
|
157
|
+
// A file read is still prompt payload; the reference card has no separate
|
|
158
|
+
// "files" row, so it belongs with the rest of the tool results.
|
|
159
|
+
addBlock(event.seq, 'toolResult', messageTokens(data.message ?? {}))
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (requests === 0) return null
|
|
165
|
+
return {
|
|
166
|
+
buckets: USAGE_BUCKET_ORDER.map((key) => ({ key, tokens: Math.max(0, Math.round(acc.get(key) ?? 0)) })),
|
|
167
|
+
billedTokens: billed,
|
|
168
|
+
totals,
|
|
169
|
+
requests,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Add one tree's compositions into the root's, matching its other aggregates. */
|
|
174
|
+
export function mergeUsageCompositions(
|
|
175
|
+
parts: ReadonlyArray<SessionUsageComposition | null | undefined>,
|
|
176
|
+
): SessionUsageComposition | null {
|
|
177
|
+
const present = parts.filter((part): part is SessionUsageComposition => part !== null && part !== undefined)
|
|
178
|
+
if (present.length === 0) return null
|
|
179
|
+
const acc = new Map<SessionUsageBucket, number>()
|
|
180
|
+
const totals: SessionUsageTotals = {
|
|
181
|
+
uncachedInputTokens: 0,
|
|
182
|
+
outputTokens: 0,
|
|
183
|
+
cacheReadTokens: 0,
|
|
184
|
+
cacheWriteTokens: 0,
|
|
185
|
+
}
|
|
186
|
+
let billed = 0
|
|
187
|
+
let requests = 0
|
|
188
|
+
for (const part of present) {
|
|
189
|
+
for (const bucket of part.buckets) acc.set(bucket.key, (acc.get(bucket.key) ?? 0) + bucket.tokens)
|
|
190
|
+
totals.uncachedInputTokens += part.totals.uncachedInputTokens
|
|
191
|
+
totals.cacheReadTokens += part.totals.cacheReadTokens
|
|
192
|
+
totals.cacheWriteTokens += part.totals.cacheWriteTokens
|
|
193
|
+
totals.outputTokens += part.totals.outputTokens
|
|
194
|
+
billed += part.billedTokens
|
|
195
|
+
requests += part.requests
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
buckets: USAGE_BUCKET_ORDER.map((key) => ({ key, tokens: acc.get(key) ?? 0 })),
|
|
199
|
+
billedTokens: billed,
|
|
200
|
+
totals,
|
|
201
|
+
requests,
|
|
202
|
+
}
|
|
203
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -198,6 +198,43 @@ export interface SessionUsageDetail {
|
|
|
198
198
|
/** The root session's live context composition, rendered above the cards.
|
|
199
199
|
* Null when the log has no readable events (or on an older host). */
|
|
200
200
|
context?: SessionContextWindow | null
|
|
201
|
+
/** What the session's requests spent, split by what filled the prompt.
|
|
202
|
+
* Null when the log has no readable usage (or on an older host). */
|
|
203
|
+
composition?: SessionUsageComposition | null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The eight buckets the Token stats card names, in legend order: the left
|
|
207
|
+
* column first (prompt composition), then the right column. */
|
|
208
|
+
export type SessionUsageBucket =
|
|
209
|
+
| 'systemPrompt'
|
|
210
|
+
| 'user'
|
|
211
|
+
| 'skill'
|
|
212
|
+
| 'toolResult'
|
|
213
|
+
| 'toolDef'
|
|
214
|
+
| 'injected'
|
|
215
|
+
| 'assistant'
|
|
216
|
+
| 'output'
|
|
217
|
+
|
|
218
|
+
/** One session tree's spend split by prompt content.
|
|
219
|
+
*
|
|
220
|
+
* Every provider request replays the whole message list, so a bucket's number
|
|
221
|
+
* is what that bucket contributed ACROSS ALL REQUESTS, not its size in the
|
|
222
|
+
* window today. The replay figures are ESTIMATES (character counts, the same
|
|
223
|
+
* estimator the context-window snapshot uses) because the provider only bills
|
|
224
|
+
* a whole prompt; the billed total and its cache split are exact and drive
|
|
225
|
+
* the headline and the cache-hit badge. */
|
|
226
|
+
export interface SessionUsageComposition {
|
|
227
|
+
/** Estimated replay tokens per bucket, in legend order (kept at 0 when a
|
|
228
|
+
* bucket never appeared so the card can keep a stable order). */
|
|
229
|
+
buckets: Array<{ key: SessionUsageBucket; tokens: number }>
|
|
230
|
+
/** Exact provider-billed total across the same requests (prompt including
|
|
231
|
+
* cached tokens, plus output). */
|
|
232
|
+
billedTokens: number
|
|
233
|
+
/** The same spend split into the four billing buckets, for the cache badge
|
|
234
|
+
* and any future detail row. */
|
|
235
|
+
totals: SessionUsageTotals
|
|
236
|
+
/** How many provider answers the accumulation covers (compaction included). */
|
|
237
|
+
requests: number
|
|
201
238
|
}
|
|
202
239
|
|
|
203
240
|
/** The six buckets the context-window panel paints, in legend order. They are
|