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.
@@ -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