dsh-token-total 1.0.0 → 1.0.2
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 +5 -4
- package/lib/client.js +566 -159
- package/lib/index.js +355 -54
- package/lib/types/client/ContextUsageEntry.d.ts +22 -0
- package/lib/types/client/context-meter-core.d.ts +26 -0
- package/lib/types/client/context-takeover.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-core.d.ts +9 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +27 -12
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/query.d.ts +8 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +30 -1
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +20 -0
- package/src/client/ContextUsageEntry.tsx +89 -0
- package/src/client/StatsLineEnhanced.tsx +17 -35
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +204 -56
- package/src/client/context-meter-core.ts +54 -0
- package/src/client/context-takeover.ts +35 -0
- package/src/client/index.tsx +21 -3
- package/src/client/locales.ts +4 -0
- package/src/client/session-usage-core.ts +54 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +138 -67
- package/src/context-types.ts +17 -2
- package/src/index.ts +29 -0
- package/src/model-names.ts +34 -0
- package/src/query.ts +84 -13
- package/src/routes.ts +20 -2
- package/src/session-usage.ts +50 -38
- package/src/store.ts +136 -0
- package/src/wire.ts +24 -1
package/src/context-types.ts
CHANGED
|
@@ -150,6 +150,16 @@ export interface UsageSessionPersistence {
|
|
|
150
150
|
export interface UsageLlmService {
|
|
151
151
|
resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{ name: string }>
|
|
152
152
|
listModels?(provider: string): Promise<readonly { id: string; name: string }[]>
|
|
153
|
+
/** The provider catalog: route id -> the display name the model selector
|
|
154
|
+
* shows. Synchronous on every host line this plugin supports (the llm
|
|
155
|
+
* runtime's own `listProviders`, mirrored by the dsh-llm service). Optional:
|
|
156
|
+
* a host without it leaves every ranked provider row labelled by its raw
|
|
157
|
+
* historical route id. */
|
|
158
|
+
listProviders?(): readonly { id: string; name: string }[]
|
|
159
|
+
/** The configured-provider view (the models page's own list), whose
|
|
160
|
+
* `displayName` is the same label. Read as a fallback for a route that
|
|
161
|
+
* `listProviders` omits. */
|
|
162
|
+
listConfigurableProviders?(): readonly { provider: string; displayName: string }[]
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
/** The route a session's log records for its model calls (mirror of
|
|
@@ -168,8 +178,13 @@ export interface UsageSessionHandle {
|
|
|
168
178
|
id: string
|
|
169
179
|
cwd?: string
|
|
170
180
|
createdAt?: number
|
|
171
|
-
/**
|
|
172
|
-
|
|
181
|
+
/** Modern hosts retain creation metadata on Session.header, not as getters. */
|
|
182
|
+
header?: UsageSessionHeader
|
|
183
|
+
/** Exact fork prefix, distinct from the resume/construction seed length. */
|
|
184
|
+
inheritedEventCount?: number
|
|
185
|
+
/** Immutable current live log; avoids the persistence backend's batching lag. */
|
|
186
|
+
snapshotEvents?(): readonly UsageSessionEvent[]
|
|
187
|
+
/** Legacy direct parent; modern hosts expose header.parentSession instead. */
|
|
173
188
|
parentSession?: string
|
|
174
189
|
/** 0 for a root session; the delegation depth for a subagent. */
|
|
175
190
|
delegationDepth?: number
|
package/src/index.ts
CHANGED
|
@@ -212,6 +212,34 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
212
212
|
await collector.backfillHours(ctx.sessionPersistence, ctx.sessions, controller.signal)
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
+
// The failure counter arrived after BOTH token cursors were populated, so
|
|
216
|
+
// an existing store needs one more replay to recover historic failures.
|
|
217
|
+
// Same shape as the hour upgrade: only after the hour pass settled cleanly
|
|
218
|
+
// (a fresh install's passes already folded failures and set the flag, so
|
|
219
|
+
// this returns immediately), and with a live boundary for open sessions.
|
|
220
|
+
const ensureFailureHistory = async (): Promise<void> => {
|
|
221
|
+
await store.readyPromise()
|
|
222
|
+
if (await store.failuresReady()) return
|
|
223
|
+
if (!(await store.hasAnyDayRows())) return
|
|
224
|
+
if (collector.status.error !== undefined) return
|
|
225
|
+
const previous = await store.failuresLiveSequences()
|
|
226
|
+
const boundaries = new Map<string, number>()
|
|
227
|
+
for (const handle of ctx.sessions.list()) {
|
|
228
|
+
const seq = handle.seq
|
|
229
|
+
if (typeof seq === 'number' && Number.isFinite(seq) && seq >= 0) {
|
|
230
|
+
boundaries.set(handle.id, seq)
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
const old = previous.get(handle.id)
|
|
234
|
+
boundaries.set(handle.id, typeof old === 'number' ? old : -1)
|
|
235
|
+
}
|
|
236
|
+
await store.markFailuresLiveSequences(boundaries)
|
|
237
|
+
scanController?.abort()
|
|
238
|
+
const controller = new AbortController()
|
|
239
|
+
scanController = controller
|
|
240
|
+
await collector.backfillFailures(ctx.sessionPersistence, ctx.sessions, controller.signal)
|
|
241
|
+
}
|
|
242
|
+
|
|
215
243
|
ctx.effect(() => {
|
|
216
244
|
collector.start()
|
|
217
245
|
// Keep the per-session tree cache honest: every committed event of any
|
|
@@ -226,6 +254,7 @@ export function apply(ctx: Context, config: TokenTotalConfig = {}): void {
|
|
|
226
254
|
void (async () => {
|
|
227
255
|
await rescan()
|
|
228
256
|
await ensureHourHistory()
|
|
257
|
+
await ensureFailureHistory()
|
|
229
258
|
})()
|
|
230
259
|
return () => {
|
|
231
260
|
scanController?.abort()
|
package/src/model-names.ts
CHANGED
|
@@ -26,6 +26,40 @@ async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: n
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Resolve provider route ids to the labels the model selector shows. Both
|
|
30
|
+
* catalogs are presentational and synchronous on the host runtime, so this is
|
|
31
|
+
* a plain in-process read rather than a per-request lookup: a missing service,
|
|
32
|
+
* a throwing adapter, or an unexpected promise all resolve to an empty map and
|
|
33
|
+
* every surface falls back to the raw historical route id. */
|
|
34
|
+
export function resolveProviderNames(llm?: UsageLlmService): Record<string, string> {
|
|
35
|
+
const names: Record<string, string> = Object.create(null)
|
|
36
|
+
if (!llm) return names
|
|
37
|
+
try {
|
|
38
|
+
for (const provider of catalog(llm.listProviders?.())) {
|
|
39
|
+
const name = provider.name?.trim()
|
|
40
|
+
if (provider.id && name) names[provider.id] = name
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Metadata is auxiliary: a broken catalog must never fail the range query.
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
for (const entry of catalog(llm.listConfigurableProviders?.())) {
|
|
47
|
+
const name = entry.displayName?.trim()
|
|
48
|
+
if (entry.provider && name && names[entry.provider] === undefined) names[entry.provider] = name
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Same contract as above.
|
|
52
|
+
}
|
|
53
|
+
return names
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Both catalogs are declared synchronous, but a host that publishes them over
|
|
57
|
+
* a remote face would hand back a promise; treat anything that is not an array
|
|
58
|
+
* as "no metadata" instead of throwing mid-query. */
|
|
59
|
+
function catalog<T>(value: readonly T[] | undefined): readonly T[] {
|
|
60
|
+
return Array.isArray(value) ? value : []
|
|
61
|
+
}
|
|
62
|
+
|
|
29
63
|
/** Resolve each distinct route once per range query. Unknown/deleted providers
|
|
30
64
|
* fall back to their bare historical model names in aggregateSamples(). */
|
|
31
65
|
export async function resolveModelNames(
|
package/src/query.ts
CHANGED
|
@@ -51,6 +51,14 @@ export interface UsageSample {
|
|
|
51
51
|
cacheWriteTokens: number
|
|
52
52
|
turn?: boolean // true for a completed-turn marker
|
|
53
53
|
request?: boolean // true when this sample also counts one provider call
|
|
54
|
+
/** True when this sample also counts one FAILED provider call. The host
|
|
55
|
+
* appends `assistant/attempt` (instead of `assistant/message`) when a
|
|
56
|
+
* call's stream ends with an error or is cancelled before any content is
|
|
57
|
+
* committed, so that event is the exact boundary of a failed call. The
|
|
58
|
+
* marker is ORTHOGONAL to `request` and to the buckets: a failed call was
|
|
59
|
+
* still opened by its own `step/start` (so it is already one request) and
|
|
60
|
+
* a stream that died mid-flight may still report usage. */
|
|
61
|
+
failed?: boolean
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
export interface RangeFilter {
|
|
@@ -105,6 +113,7 @@ export function aggregateSamples(
|
|
|
105
113
|
outputTokens: 0,
|
|
106
114
|
cacheTokens: 0,
|
|
107
115
|
requests: 0,
|
|
116
|
+
failures: 0,
|
|
108
117
|
turns: 0,
|
|
109
118
|
cacheHit: 0,
|
|
110
119
|
cacheMiss: 0,
|
|
@@ -123,6 +132,12 @@ export function aggregateSamples(
|
|
|
123
132
|
// tracked alongside the token volume.
|
|
124
133
|
const modelCache = new Map<string, { hit: number; miss: number }>()
|
|
125
134
|
const providerCache = new Map<string, { hit: number; miss: number }>()
|
|
135
|
+
// Cross-dimension splits: the two ranked lists render as side-by-side cards
|
|
136
|
+
// whose rows explain themselves on hover (which providers served this model,
|
|
137
|
+
// which models this provider served), so both directions are tracked here
|
|
138
|
+
// in the same pass rather than by re-walking the samples per row.
|
|
139
|
+
const modelProviderTotals = new Map<string, Map<string, number>>()
|
|
140
|
+
const providerModelTotals = new Map<string, Map<string, number>>()
|
|
126
141
|
const active = new Set<string>()
|
|
127
142
|
// Per-bucket accumulation (the reasonix dayTotals): the stacked trend chart
|
|
128
143
|
// reads the bucket's byModel / byProvider, so every token-bearing sample
|
|
@@ -131,6 +146,7 @@ export function aggregateSamples(
|
|
|
131
146
|
const bucketByProvider = new Map<string, Map<string, number>>()
|
|
132
147
|
const bucketTotals = new Map<string, number>()
|
|
133
148
|
const bucketRequests = new Map<string, number>()
|
|
149
|
+
const bucketFailures = new Map<string, number>()
|
|
134
150
|
const bucketTurns = new Map<string, number>()
|
|
135
151
|
const bucketCacheHit = new Map<string, number>()
|
|
136
152
|
const bucketCacheMiss = new Map<string, number>()
|
|
@@ -147,18 +163,22 @@ export function aggregateSamples(
|
|
|
147
163
|
// provider billed for this call — uncached input, output, cache reads
|
|
148
164
|
// and writes. The hit/miss rate denominators below stay input-side only.
|
|
149
165
|
const total = sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens
|
|
166
|
+
// UsageFold emits signed bucket corrections. Even a net-zero correction
|
|
167
|
+
// can move prompt tokens between cache hit and miss, so only all-zero
|
|
168
|
+
// buckets are a marker with no token accounting.
|
|
169
|
+
const hasTokenDelta = sample.inputTokens !== 0 || sample.outputTokens !== 0
|
|
170
|
+
|| sample.cacheReadTokens !== 0 || sample.cacheWriteTokens !== 0
|
|
171
|
+
if (sample.failed) {
|
|
172
|
+
out.failures++
|
|
173
|
+
bucketFailures.set(bucket, (bucketFailures.get(bucket) ?? 0) + 1)
|
|
174
|
+
}
|
|
150
175
|
if (sample.request) {
|
|
151
|
-
// One provider call, whether or not it produced tokens — reasonix counts
|
|
152
|
-
// failed calls too. A marker with no buckets is a request and nothing
|
|
153
|
-
// else: it must not create a model row, mark its day active, or open an
|
|
154
|
-
// empty daily bucket. A marker that DOES carry buckets
|
|
155
|
-
// (`compaction/summary` states the call and its usage in one sample) is
|
|
156
|
-
// counted here and again through the token accounting below — the two
|
|
157
|
-
// count different things, so neither doubles the other.
|
|
158
176
|
out.requests++
|
|
159
177
|
bucketRequests.set(bucket, (bucketRequests.get(bucket) ?? 0) + 1)
|
|
160
|
-
if (total <= 0) continue
|
|
161
178
|
}
|
|
179
|
+
// Pure markers must not create a model row or an active day. A marker
|
|
180
|
+
// carrying a correction still updates every billing/cache bucket below.
|
|
181
|
+
if (!hasTokenDelta) continue
|
|
162
182
|
out.tokens += total
|
|
163
183
|
out.inputTokens += sample.inputTokens
|
|
164
184
|
out.outputTokens += sample.outputTokens
|
|
@@ -178,6 +198,18 @@ export function aggregateSamples(
|
|
|
178
198
|
modelProviders.set(model, sources)
|
|
179
199
|
modelTotals.set(model, (modelTotals.get(model) ?? 0) + total)
|
|
180
200
|
providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + total)
|
|
201
|
+
let modelSplitMap = modelProviderTotals.get(model)
|
|
202
|
+
if (!modelSplitMap) {
|
|
203
|
+
modelSplitMap = new Map()
|
|
204
|
+
modelProviderTotals.set(model, modelSplitMap)
|
|
205
|
+
}
|
|
206
|
+
modelSplitMap.set(provider, (modelSplitMap.get(provider) ?? 0) + total)
|
|
207
|
+
let providerSplitMap = providerModelTotals.get(provider)
|
|
208
|
+
if (!providerSplitMap) {
|
|
209
|
+
providerSplitMap = new Map()
|
|
210
|
+
providerModelTotals.set(provider, providerSplitMap)
|
|
211
|
+
}
|
|
212
|
+
providerSplitMap.set(model, (providerSplitMap.get(model) ?? 0) + total)
|
|
181
213
|
const mc = modelCache.get(model) ?? { hit: 0, miss: 0 }
|
|
182
214
|
mc.hit += sample.cacheReadTokens
|
|
183
215
|
mc.miss += missSide
|
|
@@ -222,14 +254,15 @@ export function aggregateSamples(
|
|
|
222
254
|
byModel: byModelObj,
|
|
223
255
|
byProvider: byProviderObj,
|
|
224
256
|
requests: bucketRequests.get(bucket) ?? 0,
|
|
257
|
+
failures: bucketFailures.get(bucket) ?? 0,
|
|
225
258
|
turns: bucketTurns.get(bucket) ?? 0,
|
|
226
259
|
cacheHit: bucketCacheHit.get(bucket) ?? 0,
|
|
227
260
|
cacheMiss: bucketCacheMiss.get(bucket) ?? 0,
|
|
228
261
|
})
|
|
229
262
|
}
|
|
230
263
|
|
|
231
|
-
out.models = modelsSorted(modelTotals, modelCache, modelProviders)
|
|
232
|
-
out.providers = providersSorted(providerTotals, providerCache)
|
|
264
|
+
out.models = modelsSorted(modelTotals, modelCache, modelProviders, modelProviderTotals)
|
|
265
|
+
out.providers = providersSorted(providerTotals, providerCache, providerModelTotals)
|
|
233
266
|
out.topModel = out.models[0]?.model ?? ''
|
|
234
267
|
out.topProvider = out.providers[0]?.provider ?? ''
|
|
235
268
|
if (out.tokens > 0) {
|
|
@@ -243,23 +276,61 @@ function modelsSorted(
|
|
|
243
276
|
totals: Map<string, number>,
|
|
244
277
|
cache: Map<string, { hit: number; miss: number }>,
|
|
245
278
|
sources: Map<string, Set<string>>,
|
|
279
|
+
splits: Map<string, Map<string, number>>,
|
|
246
280
|
): ModelTokenUsage[] {
|
|
247
281
|
const out: ModelTokenUsage[] = []
|
|
248
282
|
for (const [model, tokens] of totals) {
|
|
249
283
|
const c = cache.get(model)
|
|
250
284
|
const providers = [...(sources.get(model) ?? [])].sort()
|
|
251
|
-
out.push({
|
|
285
|
+
out.push({
|
|
286
|
+
model,
|
|
287
|
+
provider: providers.length === 1 ? providers[0]! : '',
|
|
288
|
+
providers,
|
|
289
|
+
providerTokens: providerSplit(splits.get(model)),
|
|
290
|
+
tokens,
|
|
291
|
+
percent: 0,
|
|
292
|
+
cacheHit: c?.hit ?? 0,
|
|
293
|
+
cacheMiss: c?.miss ?? 0,
|
|
294
|
+
})
|
|
252
295
|
}
|
|
253
296
|
out.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
254
297
|
return out
|
|
255
298
|
}
|
|
256
299
|
|
|
257
|
-
function providersSorted(
|
|
300
|
+
function providersSorted(
|
|
301
|
+
totals: Map<string, number>,
|
|
302
|
+
cache: Map<string, { hit: number; miss: number }>,
|
|
303
|
+
splits: Map<string, Map<string, number>>,
|
|
304
|
+
): ProviderTokenUsage[] {
|
|
258
305
|
const out: ProviderTokenUsage[] = []
|
|
259
306
|
for (const [provider, tokens] of totals) {
|
|
260
307
|
const c = cache.get(provider)
|
|
261
|
-
out.push({
|
|
308
|
+
out.push({
|
|
309
|
+
provider,
|
|
310
|
+
tokens,
|
|
311
|
+
percent: 0,
|
|
312
|
+
cacheHit: c?.hit ?? 0,
|
|
313
|
+
cacheMiss: c?.miss ?? 0,
|
|
314
|
+
modelTokens: modelSplit(splits.get(provider)),
|
|
315
|
+
})
|
|
262
316
|
}
|
|
263
317
|
out.sort((a, b) => b.tokens - a.tokens)
|
|
264
318
|
return out
|
|
265
319
|
}
|
|
320
|
+
|
|
321
|
+
/** One model's per-provider split, largest first (ties by id for a stable
|
|
322
|
+
* order across refreshes). Empty when the response carries no cross data. */
|
|
323
|
+
function providerSplit(split: Map<string, number> | undefined): Array<{ provider: string; tokens: number }> {
|
|
324
|
+
if (split === undefined) return []
|
|
325
|
+
return [...split]
|
|
326
|
+
.map(([provider, tokens]) => ({ provider, tokens }))
|
|
327
|
+
.sort((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** One provider's per-model split, largest first (ties by name). */
|
|
331
|
+
function modelSplit(split: Map<string, number> | undefined): Array<{ model: string; tokens: number }> {
|
|
332
|
+
if (split === undefined) return []
|
|
333
|
+
return [...split]
|
|
334
|
+
.map(([model, tokens]) => ({ model, tokens }))
|
|
335
|
+
.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
336
|
+
}
|
package/src/routes.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import type { Context, UsageLlmService } from './context-types.ts'
|
|
15
|
-
import { resolveModelNames } from './model-names.ts'
|
|
15
|
+
import { resolveModelNames, resolveProviderNames } from './model-names.ts'
|
|
16
16
|
import type { UsageHttpRequest, UsageHttpResponse, UsageWebRoute } from './context-types.ts'
|
|
17
17
|
import type { UsageStore, UsageDayRow, UsageHourRow } from './store.ts'
|
|
18
18
|
import type { UsageCollector } from './collector.ts'
|
|
@@ -162,12 +162,30 @@ export async function aggregateRange(
|
|
|
162
162
|
for (let i = 0; i < row.requests; i++) {
|
|
163
163
|
yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, request: true }
|
|
164
164
|
}
|
|
165
|
+
// Failed calls ride the row as a count too. They are folded as their own
|
|
166
|
+
// marker (never as an extra request): the call's request is already
|
|
167
|
+
// counted above, and a zero-token failure must not open a model row.
|
|
168
|
+
for (let i = 0; i < (row.failures ?? 0); i++) {
|
|
169
|
+
yield { day, hour, model: row.model, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, failed: true }
|
|
170
|
+
}
|
|
165
171
|
for (let i = 0; i < row.turns; i++) {
|
|
166
172
|
yield { day, hour, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turn: true }
|
|
167
173
|
}
|
|
168
174
|
}
|
|
169
175
|
})()
|
|
170
|
-
|
|
176
|
+
const stats = aggregateSamples(samples, { from, to }, granularity)
|
|
177
|
+
// Provider labels ride along with the aggregate: the ranked provider card
|
|
178
|
+
// shows the selector's display name, and the hover detail names the models
|
|
179
|
+
// underneath it. Only providers actually present in the range are included,
|
|
180
|
+
// so the payload stays proportional to the ranking it decorates.
|
|
181
|
+
const resolved = resolveProviderNames(llm)
|
|
182
|
+
const providerNames: Record<string, string> = Object.create(null)
|
|
183
|
+
for (const provider of stats.providers) {
|
|
184
|
+
const label = resolved[provider.provider]
|
|
185
|
+
if (label !== undefined) providerNames[provider.provider] = label
|
|
186
|
+
}
|
|
187
|
+
if (Object.keys(providerNames).length > 0) stats.providerNames = providerNames
|
|
188
|
+
return stats
|
|
171
189
|
}
|
|
172
190
|
|
|
173
191
|
/** Build the single fenced prefix route serving the /token-total/api JSON RPC.
|
package/src/session-usage.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
/**
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
private
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
145
|
-
*
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|