dsh-token-total 1.0.0 → 1.0.1

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.
@@ -25,6 +25,7 @@
25
25
 
26
26
  import type {
27
27
  UsageSessionEvent,
28
+ UsageSessionHandle,
28
29
  UsageSessionHeader,
29
30
  UsageSessionPersistence,
30
31
  UsageSessionSnapshot,
@@ -68,6 +69,10 @@ function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSession
68
69
  return typeof row?.id === 'string' && row.id !== '' ? row : null
69
70
  }
70
71
 
72
+ function inheritedCount(cut: unknown): number {
73
+ return typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
74
+ }
75
+
71
76
  /** Read one stored session's events through whichever persistence seam the
72
77
  * host line exposes (alpha open/read/close wins; rc.1 inspect is the tail). */
73
78
  async function readEvents(
@@ -78,13 +83,12 @@ async function readEvents(
78
83
  if (typeof persistence.open === 'function') {
79
84
  const handle = await persistence.open(id, 'read', signal === undefined ? undefined : { signal })
80
85
  try {
81
- const cut = handle.inheritedEventCount
82
- const inheritedCut = typeof cut === 'number' && Number.isSafeInteger(cut) && cut >= 0 ? cut : 0
86
+ const inheritedCut = inheritedCount(handle.inheritedEventCount)
83
87
  const collected: UsageSessionEvent[] = []
84
88
  let offset = 0
85
89
  const PAGE = 500
86
90
  for (;;) {
87
- if (signal?.aborted) break
91
+ signal?.throwIfAborted()
88
92
  const slice = await handle.read(offset, PAGE, signal === undefined ? undefined : { signal })
89
93
  const page = ((slice && slice.events) ? slice.events : []) as readonly UsageSessionEvent[]
90
94
  if (page.length === 0) break
@@ -98,7 +102,7 @@ async function readEvents(
98
102
  }
99
103
  if (typeof persistence.inspect === 'function') {
100
104
  const inspection = await persistence.inspect(id, signal)
101
- return { events: inspection.events, inheritedCut: inspection.inheritedEventCount ?? 0 }
105
+ return { events: inspection.events, inheritedCut: inheritedCount(inspection.inheritedEventCount) }
102
106
  }
103
107
  throw new Error('no readable persistence seam (need open or inspect)')
104
108
  }
@@ -108,49 +112,59 @@ async function sumOneSession(
108
112
  persistence: UsageSessionPersistence,
109
113
  id: string,
110
114
  signal?: AbortSignal,
111
- ): Promise<SessionUsageTotals> {
112
- const { events, inheritedCut } = await readEvents(persistence, id, signal)
115
+ live?: UsageSessionHandle,
116
+ ): Promise<{ totals: SessionUsageTotals; compaction: SessionUsageTotals }> {
117
+ signal?.throwIfAborted()
118
+ const { events, inheritedCut } = typeof live?.snapshotEvents === 'function'
119
+ && typeof live.inheritedEventCount === 'number'
120
+ ? { events: live.snapshotEvents(), inheritedCut: inheritedCount(live.inheritedEventCount) }
121
+ : await readEvents(persistence, id, signal)
113
122
  const fold = new UsageFold()
114
123
  const totals: SessionUsageTotals = { ...ZERO }
124
+ const compaction: SessionUsageTotals = { ...ZERO }
115
125
  for (const ev of events) {
116
126
  if (inheritedCut > 0 && typeof ev.seq === 'number' && ev.seq < inheritedCut) continue
117
127
  const sample = fold.fold(ev)
118
128
  if (sample === null || sample.turn) continue
119
- totals.uncachedInputTokens += sample.inputTokens
120
- totals.outputTokens += sample.outputTokens
121
- totals.cacheReadTokens += sample.cacheReadTokens
122
- totals.cacheWriteTokens += sample.cacheWriteTokens
129
+ const buckets: SessionUsageTotals = {
130
+ uncachedInputTokens: sample.inputTokens,
131
+ outputTokens: sample.outputTokens,
132
+ cacheReadTokens: sample.cacheReadTokens,
133
+ cacheWriteTokens: sample.cacheWriteTokens,
134
+ }
135
+ add(totals, buckets)
136
+ if (ev.type === 'compaction/summary') add(compaction, buckets)
123
137
  }
124
- return totals
138
+ return { totals, compaction }
125
139
  }
126
140
 
127
141
  export class SessionUsageService {
128
- /** Cached trees, each remembering every session id it was built from, so a
129
- * live event on ANY of them (a subagent's own event changes its ancestors'
130
- * totals too) can invalidate exactly the affected trees. */
131
- private cache = new Map<string, { at: number; value: SessionUsageTree; members: Set<string> }>()
132
-
133
- /** Drop every cached tree that counted `sessionId` (its own tree, or any
134
- * ancestor's tree that folded it in as a subagent). Called by the collector
135
- * on each live session event. */
136
- invalidate(sessionId: string): void {
137
- for (const [rootId, entry] of this.cache) {
138
- if (entry.members.has(sessionId)) this.cache.delete(rootId)
139
- }
142
+ /** Short-lived tree reads; any live event can invalidate tree membership. */
143
+ private cache = new Map<string, { at: number; value: SessionUsageTree }>()
144
+ /** Any event can introduce a new descendant while an async read is running. */
145
+ private generation = 0
146
+
147
+ /** Invalidate conservatively: an unseen session may be a newly-created child
148
+ * of ANY cached root, so membership-only invalidation is not safe. */
149
+ invalidate(_sessionId: string): void {
150
+ this.generation++
151
+ this.cache.clear()
140
152
  }
141
153
 
142
154
  /** Resolve the whole session tree rooted at `rootId` and sum its usage.
143
155
  * `self` is the root's own usage, `subagents` every descendant's, `total`
144
- * the sum. Unknown roots answer zeroed totals rather than throwing: the pill
145
- * must never break the composer. */
156
+ * the sum. Unreadable logs reject rather than publishing partial totals;
157
+ * callers can retain their last good display while surfacing the error. */
146
158
  async tree(
147
159
  persistence: UsageSessionPersistence,
148
160
  sessions: UsageSessionStore | undefined,
149
161
  rootId: string,
150
162
  signal?: AbortSignal,
151
163
  ): Promise<SessionUsageTree> {
164
+ signal?.throwIfAborted()
152
165
  const hit = this.cache.get(rootId)
153
166
  if (hit !== undefined && Date.now() - hit.at < CACHE_TTL_MS) return hit.value
167
+ const generation = this.generation
154
168
 
155
169
  const headers = await this.listHeaders(persistence, signal)
156
170
  const children = new Map<string, string[]>()
@@ -163,10 +177,10 @@ export class SessionUsageService {
163
177
  }
164
178
  // A brand-new live subagent may not be materialized in persistence yet;
165
179
  // the live store still knows its parent, so fold those in too.
166
- for (const handle of sessions?.list() ?? []) {
167
- const parent = (handle as { parentSession?: string }).parentSession
180
+ const live = new Map((sessions?.list() ?? []).map((handle) => [handle.id, handle]))
181
+ for (const handle of live.values()) {
182
+ const parent = handle.header?.parentSession ?? handle.parentSession
168
183
  if (typeof parent !== 'string' || parent === '' || parent === handle.id) continue
169
- if (headers.some((h) => h.id === handle.id)) continue
170
184
  const bucket = children.get(parent)
171
185
  if (bucket === undefined) children.set(parent, [handle.id])
172
186
  else if (!bucket.includes(handle.id)) bucket.push(handle.id)
@@ -185,23 +199,21 @@ export class SessionUsageService {
185
199
  }
186
200
  }
187
201
 
188
- const self = await sumOneSession(persistence, rootId, signal).catch(() => ({ ...ZERO }))
202
+ const root = await sumOneSession(persistence, rootId, signal, live.get(rootId))
203
+ const self = root.totals
189
204
  const subagents: SessionUsageTotals = { ...ZERO }
190
205
  for (const id of descendants) {
191
- const one = await sumOneSession(persistence, id, signal).catch(() => ({ ...ZERO }))
192
- add(subagents, one)
206
+ const one = await sumOneSession(persistence, id, signal, live.get(id))
207
+ add(subagents, one.totals)
193
208
  }
194
209
  const total: SessionUsageTotals = { ...self }
195
210
  add(total, subagents)
196
- const value: SessionUsageTree = { self, subagents, total, subagentSessions: descendants.length }
211
+ const value: SessionUsageTree = { self, selfCompaction: root.compaction, subagents, total, subagentSessions: descendants.length }
197
212
 
198
- if (signal?.aborted !== true) {
213
+ signal?.throwIfAborted()
214
+ if (generation === this.generation) {
199
215
  if (this.cache.size >= CACHE_MAX) this.cache.clear()
200
- this.cache.set(rootId, {
201
- at: Date.now(),
202
- value,
203
- members: new Set([rootId, ...descendants]),
204
- })
216
+ this.cache.set(rootId, { at: Date.now(), value })
205
217
  }
206
218
  return value
207
219
  }
package/src/wire.ts CHANGED
@@ -32,6 +32,10 @@ export interface ModelTokenUsage {
32
32
  provider: string
33
33
  /** All source providers; optional for compatibility with older responses. */
34
34
  providers?: string[]
35
+ /** Per-provider token split of this one model, largest first — the ranked
36
+ * row's hover detail ("which providers served this model"). Optional so a
37
+ * pre-cross-breakdown host still renders. */
38
+ providerTokens?: Array<{ provider: string; tokens: number }>
35
39
  tokens: number
36
40
  percent: number // 0..100
37
41
  /** Cached input tokens this model served (rate numerator). */
@@ -47,6 +51,10 @@ export interface ProviderTokenUsage {
47
51
  percent: number
48
52
  cacheHit: number
49
53
  cacheMiss: number
54
+ /** Per-model token split this provider served, largest first — the ranked
55
+ * row's hover detail ("which models this provider served"). Optional so a
56
+ * pre-cross-breakdown host still renders. */
57
+ modelTokens?: Array<{ model: string; tokens: number }>
50
58
  }
51
59
 
52
60
  /** The full aggregate the panel renders for one time range. */
@@ -76,6 +84,11 @@ export interface TokenTotalRange {
76
84
  daily: DailyTokenUsage[]
77
85
  models: ModelTokenUsage[]
78
86
  providers: ProviderTokenUsage[]
87
+ /** Provider route id -> the display name the model selector shows. Filled by
88
+ * the host route from the llm service metadata; a provider missing here (a
89
+ * deleted route, an older host) renders as its raw id. Optional so a host
90
+ * that cannot resolve names still serves a valid payload. */
91
+ providerNames?: Record<string, string>
79
92
  }
80
93
 
81
94
  /** The usage statistics panel aggregate request. */
@@ -98,8 +111,11 @@ export interface SessionUsageTotals {
98
111
  * The composer stats row renders this so a delegating session shows its whole
99
112
  * spend (the native `tokenUsage` projection only covers the main agent). */
100
113
  export interface SessionUsageTree {
101
- /** The root session's own (main agent) usage. */
114
+ /** The root session's own usage, including logged compaction calls. */
102
115
  self: SessionUsageTotals
116
+ /** Root-only compaction spend already included in `self`, but absent from
117
+ * the native `tokenUsage` projection. Optional for older host responses. */
118
+ selfCompaction?: SessionUsageTotals
103
119
  /** Sum over every descendant (subagent) session. */
104
120
  subagents: SessionUsageTotals
105
121
  /** self + subagents. */