dsh-token-total 1.0.3 → 1.0.5

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.
@@ -43,6 +43,25 @@ export type TokenTotalKey =
43
43
  | 'heatmap'
44
44
  | 'heatLess'
45
45
  | 'heatMore'
46
+ | 'dow.mon'
47
+ | 'dow.tue'
48
+ | 'dow.wed'
49
+ | 'dow.thu'
50
+ | 'dow.fri'
51
+ | 'dow.sat'
52
+ | 'dow.sun'
53
+ | 'month.jan'
54
+ | 'month.feb'
55
+ | 'month.mar'
56
+ | 'month.apr'
57
+ | 'month.may'
58
+ | 'month.jun'
59
+ | 'month.jul'
60
+ | 'month.aug'
61
+ | 'month.sep'
62
+ | 'month.oct'
63
+ | 'month.nov'
64
+ | 'month.dec'
46
65
  | 'dailyTrend'
47
66
  | 'hourlyTrend'
48
67
  | 'trendLimited'
@@ -112,6 +131,25 @@ export const en: Record<TokenTotalKey, string> = {
112
131
  heatmap: 'Activity heatmap',
113
132
  heatLess: 'Less',
114
133
  heatMore: 'More',
134
+ 'dow.mon': 'Mon',
135
+ 'dow.tue': 'Tue',
136
+ 'dow.wed': 'Wed',
137
+ 'dow.thu': 'Thu',
138
+ 'dow.fri': 'Fri',
139
+ 'dow.sat': 'Sat',
140
+ 'dow.sun': 'Sun',
141
+ 'month.jan': 'Jan',
142
+ 'month.feb': 'Feb',
143
+ 'month.mar': 'Mar',
144
+ 'month.apr': 'Apr',
145
+ 'month.may': 'May',
146
+ 'month.jun': 'Jun',
147
+ 'month.jul': 'Jul',
148
+ 'month.aug': 'Aug',
149
+ 'month.sep': 'Sep',
150
+ 'month.oct': 'Oct',
151
+ 'month.nov': 'Nov',
152
+ 'month.dec': 'Dec',
115
153
  dailyTrend: 'Daily token trend',
116
154
  hourlyTrend: 'Hourly token trend',
117
155
  trendLimited: 'Showing the latest {n} days',
@@ -182,6 +220,25 @@ export const zh: Record<TokenTotalKey, string> = {
182
220
  heatmap: '活跃热力图',
183
221
  heatLess: '较少',
184
222
  heatMore: '较多',
223
+ 'dow.mon': '周一',
224
+ 'dow.tue': '周二',
225
+ 'dow.wed': '周三',
226
+ 'dow.thu': '周四',
227
+ 'dow.fri': '周五',
228
+ 'dow.sat': '周六',
229
+ 'dow.sun': '周日',
230
+ 'month.jan': '1月',
231
+ 'month.feb': '2月',
232
+ 'month.mar': '3月',
233
+ 'month.apr': '4月',
234
+ 'month.may': '5月',
235
+ 'month.jun': '6月',
236
+ 'month.jul': '7月',
237
+ 'month.aug': '8月',
238
+ 'month.sep': '9月',
239
+ 'month.oct': '10月',
240
+ 'month.nov': '11月',
241
+ 'month.dec': '12月',
185
242
  dailyTrend: '按天 Token 趋势',
186
243
  hourlyTrend: '按小时 Token 趋势',
187
244
  trendLimited: '仅显示最近 {n} 天',
@@ -252,6 +309,25 @@ export const zhTW: Record<TokenTotalKey, string> = {
252
309
  heatmap: '活躍熱力圖',
253
310
  heatLess: '較少',
254
311
  heatMore: '較多',
312
+ 'dow.mon': '週一',
313
+ 'dow.tue': '週二',
314
+ 'dow.wed': '週三',
315
+ 'dow.thu': '週四',
316
+ 'dow.fri': '週五',
317
+ 'dow.sat': '週六',
318
+ 'dow.sun': '週日',
319
+ 'month.jan': '1月',
320
+ 'month.feb': '2月',
321
+ 'month.mar': '3月',
322
+ 'month.apr': '4月',
323
+ 'month.may': '5月',
324
+ 'month.jun': '6月',
325
+ 'month.jul': '7月',
326
+ 'month.aug': '8月',
327
+ 'month.sep': '9月',
328
+ 'month.oct': '10月',
329
+ 'month.nov': '11月',
330
+ 'month.dec': '12月',
255
331
  dailyTrend: '每日 Token 趨勢',
256
332
  hourlyTrend: '每小時 Token 趨勢',
257
333
  trendLimited: '僅顯示最近 {n} 天',
@@ -0,0 +1,50 @@
1
+ /** Session Usage view translations, independent of the global dashboard. */
2
+ export const SESSION_USAGE_NS = 'sessionUsage'
3
+ export const usageZh = {
4
+ tab: '用量', requests: '请求数', tokens: '总 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
5
+ input: '入', output: '出', read: '读', write: '写',
6
+ requestsCount: '请求数量', inputFull: '输入', outputFull: '输出', readFull: '缓存读取', writeFull: '缓存写入',
7
+ cacheHit: '缓存命中', compaction: '压缩调用', reqTimes: '个请求',
8
+ children: '子代理', session: '会话', models: '按模型', model: '模型',
9
+ records: '请求记录', expand: '全部展开', collapse: '全部收起', loading: '正在读取会话用量…',
10
+ empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
11
+ scope: '本会话及所有子代理 · 历史日志累计',
12
+ note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
13
+ failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
14
+ main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
15
+ provider: '供应商', status: '状态', exact: '完整 token 数',
16
+ answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
17
+ cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
18
+ }
19
+ export type SessionUsageKey = keyof typeof usageZh
20
+ export const usageEn: Record<SessionUsageKey, string> = {
21
+ tab: 'Usage', requests: 'Requests', tokens: 'Total tokens', hit: 'Cache hit rate', first: 'Avg. first token', speed: 'Token speed',
22
+ input: 'In', output: 'Out', read: 'Read', write: 'Write',
23
+ requestsCount: 'Requests', inputFull: 'Input', outputFull: 'Output', readFull: 'Cache read', writeFull: 'Cache write',
24
+ cacheHit: 'cache hits', compaction: 'Compaction', reqTimes: 'requests',
25
+ children: 'Subagents', session: 'Session', models: 'By model', model: 'Model',
26
+ records: 'Request log', expand: 'Expand all', collapse: 'Collapse all', loading: 'Reading session usage…',
27
+ empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
28
+ scope: 'This session and all subagents · Historical log totals',
29
+ note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
30
+ failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
31
+ main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
32
+ provider: 'Provider', status: 'Status', exact: 'Exact token count',
33
+ answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
34
+ cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
35
+ }
36
+ export const usageZhTW: Record<SessionUsageKey, string> = {
37
+ ...usageZh,
38
+ tab: '用量', requests: '請求數', tokens: '總 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
39
+ input: '入', output: '出', read: '讀', write: '寫',
40
+ requestsCount: '請求數量', inputFull: '輸入', outputFull: '輸出', readFull: '快取讀取', writeFull: '快取寫入',
41
+ cacheHit: '快取命中', compaction: '壓縮呼叫', reqTimes: '個請求',
42
+ children: '子代理', session: '會話', models: '按模型', records: '請求記錄', expand: '全部展開', collapse: '全部收起',
43
+ loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
44
+ scope: '本會話及所有子代理 · 歷史日誌累計',
45
+ note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
46
+ failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
47
+ main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
48
+ answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
49
+ cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
50
+ }
@@ -0,0 +1,50 @@
1
+ /** Serialized polling for one visible Usage view, never leaking another
2
+ * session's answer. Abort and stop all timers when the tab unmounts. */
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import type { SessionUsageDetail } from '../wire.ts'
5
+ import { fetchSessionDetail } from './api.ts'
6
+
7
+ export const DETAIL_POLL_MS = 1_500
8
+
9
+ export function useSessionDetail(sessionId: string): {
10
+ detail: SessionUsageDetail | null
11
+ loading: boolean
12
+ error: boolean
13
+ retry: () => void
14
+ } {
15
+ const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
16
+ const refresh = useRef<(() => void) | null>(null)
17
+ useEffect(() => {
18
+ let active = true
19
+ let pending = false
20
+ let timer: ReturnType<typeof setTimeout> | undefined
21
+ let controller: AbortController | undefined
22
+ const request = (): void => {
23
+ if (!active || pending || !sessionId) return
24
+ if (timer !== undefined) clearTimeout(timer)
25
+ pending = true
26
+ controller = new AbortController()
27
+ void fetchSessionDetail(sessionId, controller.signal).then(detail => {
28
+ if (active) setReading({ sessionId, detail, error: false })
29
+ }).catch(() => {
30
+ if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
31
+ }).finally(() => {
32
+ pending = false
33
+ if (active) timer = setTimeout(request, DETAIL_POLL_MS)
34
+ })
35
+ }
36
+ const wake = (): void => { if (document.visibilityState === 'visible') request() }
37
+ refresh.current = request
38
+ document.addEventListener('visibilitychange', wake)
39
+ request()
40
+ return () => {
41
+ active = false
42
+ refresh.current = null
43
+ controller?.abort()
44
+ if (timer !== undefined) clearTimeout(timer)
45
+ document.removeEventListener('visibilitychange', wake)
46
+ }
47
+ }, [sessionId])
48
+ const current = reading?.sessionId === sessionId ? reading : null
49
+ return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
50
+ }
package/src/query.ts CHANGED
@@ -104,6 +104,9 @@ export function aggregateSamples(
104
104
  const from = filter.from
105
105
  const to = filter.to
106
106
  const buckets = bucketsInRange(from, to, granularity)
107
+ // Materialize once: the qualified-name pre-pass below and the main fold
108
+ // both walk the samples, and a generator input may only be consumed once.
109
+ const all = [...samples]
107
110
  const out: TokenTotalRange = {
108
111
  from,
109
112
  to,
@@ -151,7 +154,11 @@ export function aggregateSamples(
151
154
  const bucketCacheHit = new Map<string, number>()
152
155
  const bucketCacheMiss = new Map<string, number>()
153
156
 
154
- for (const sample of samples) {
157
+ // One model, one row: every qualified routing name folds onto its bare
158
+ // model key before the first aggregation (see aliasQualifiedModels).
159
+ const modelAlias = aliasQualifiedModels(new Set(all.map((sample) => modelKeyOf(sample))))
160
+
161
+ for (const sample of all) {
155
162
  const bucket = bucketOf(sample, from, to, granularity)
156
163
  if (bucket === null) continue
157
164
  if (sample.turn) {
@@ -192,7 +199,8 @@ export function aggregateSamples(
192
199
  out.cacheMiss += missSide
193
200
  const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
194
201
  const provider = providerOf(modelRef)
195
- const model = normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef))
202
+ const modelBase = modelKeyOf(sample)
203
+ const model = modelAlias.get(modelBase) ?? modelBase
196
204
  const sources = modelProviders.get(model) ?? new Set<string>()
197
205
  sources.add(provider)
198
206
  modelProviders.set(model, sources)
@@ -272,6 +280,49 @@ export function aggregateSamples(
272
280
  return out
273
281
  }
274
282
 
283
+ /** The aggregation key of one sample: the resolved display name when the
284
+ * host resolved one, else the bare model ref — normalized like every
285
+ * display name so the key is stable across routes and refreshes. */
286
+ function modelKeyOf(sample: UsageSample): string {
287
+ const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
288
+ return normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef))
289
+ }
290
+
291
+ /**
292
+ * Qualified-name aliases: "workbuddy-ai-plugin/deepseek-v4.1-flash" and
293
+ * "deepseek-v4.1-flash" are the same model reached through a routing
294
+ * namespace, so a name containing "/" merges onto the LONGEST suffix after a
295
+ * "/" that is itself a model key in this dataset (chains like a/b/c → b/c → c
296
+ * resolve transitively). The merge is dataset-relative on purpose: a slash
297
+ * that names no bare model in the data ("GPT/6.1 sol" beside "gpt-6.1-sol")
298
+ * is a genuinely different name and stays its own row — see the
299
+ * distinct-slashes case in tests/model-aggregation.test.mjs.
300
+ */
301
+ function aliasQualifiedModels(names: Set<string>): Map<string, string> {
302
+ const alias = new Map<string, string>()
303
+ for (const name of names) {
304
+ if (!name.includes('/')) continue
305
+ const parts = name.split('/')
306
+ for (let i = 1; i < parts.length; i++) {
307
+ const suffix = parts.slice(i).join('/')
308
+ if (names.has(suffix)) {
309
+ alias.set(name, suffix)
310
+ break
311
+ }
312
+ }
313
+ }
314
+ for (const name of [...alias.keys()]) {
315
+ let target = alias.get(name)!
316
+ const seen = new Set([name])
317
+ while (alias.has(target) && !seen.has(target)) {
318
+ seen.add(target)
319
+ target = alias.get(target)!
320
+ }
321
+ alias.set(name, target)
322
+ }
323
+ return alias
324
+ }
325
+
275
326
  function modelsSorted(
276
327
  totals: Map<string, number>,
277
328
  cache: Map<string, { hit: number; miss: number }>,
package/src/routes.ts CHANGED
@@ -18,7 +18,8 @@ import type { UsageStore, UsageDayRow, UsageHourRow } from './store.ts'
18
18
  import type { UsageCollector } from './collector.ts'
19
19
  import { aggregateSamples } from './query.ts'
20
20
  import type { Granularity, UsageSample } from './query.ts'
21
- import type { BackfillStatus, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts'
21
+ import type { BackfillStatus, SessionUsageDetail, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts'
22
+ import { buildSessionUsageDetail } from './session-detail.ts'
22
23
  import { UsageError, readJsonBody, writeError, writeJson } from './wire.ts'
23
24
  import type { SessionUsageService } from './session-usage.ts'
24
25
  import { createTrustFence, type TrustFence, type TrustInputs } from './trust-fence.ts'
@@ -228,6 +229,14 @@ export function buildUsageRoute(ctx: Context, deps: RoutesDeps): UsageWebRoute {
228
229
  writeJson(res, { ok: true, value })
229
230
  return
230
231
  }
232
+ if (method === 'POST' && path === '/token-total/api/session-detail') {
233
+ const body = (await readJsonBody(req)) as Partial<SessionUsageRequest>
234
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
235
+ if (sessionId === '' || sessionId.length > 200) throw new UsageError(400, 'token total: session needs a valid sessionId')
236
+ const value = await buildSessionUsageDetail(deps.persistence(), deps.sessions(), sessionId, ctx)
237
+ writeJson(res, { ok: true, value })
238
+ return
239
+ }
231
240
  if (method === 'POST' && path === '/token-total/api/status') {
232
241
  writeJson(res, { ok: true, value: deps.collector.status })
233
242
  return
@@ -260,4 +269,4 @@ export function buildUsageRoute(ctx: Context, deps: RoutesDeps): UsageWebRoute {
260
269
  return { kind: 'prefix', path: '/token-total/api', handler }
261
270
  }
262
271
 
263
- export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest }
272
+ export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest, SessionUsageDetail }
@@ -0,0 +1,182 @@
1
+ import type {
2
+ Context,
3
+ UsageSessionEvent,
4
+ UsageSessionHandle,
5
+ UsageSessionHeader,
6
+ UsageSessionPersistence,
7
+ UsageSessionSnapshot,
8
+ UsageSessionStore,
9
+ } from './context-types.ts'
10
+ import { UsageFold } from './collector.ts'
11
+ import { resolveModelNames, resolveProviderNames } from './model-names.ts'
12
+ import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
13
+
14
+ const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
15
+ function add(a: SessionUsageTotals, b: SessionUsageTotals): void {
16
+ a.uncachedInputTokens += b.uncachedInputTokens; a.outputTokens += b.outputTokens
17
+ a.cacheReadTokens += b.cacheReadTokens; a.cacheWriteTokens += b.cacheWriteTokens
18
+ }
19
+ function usageOf(x: any): SessionUsageTotals {
20
+ const u = x?.usage ?? x
21
+ return { uncachedInputTokens: Number(u?.inputTokens) || 0, outputTokens: Number(u?.outputTokens) || 0, cacheReadTokens: Number(u?.cacheReadTokens) || 0, cacheWriteTokens: Number(u?.cacheWriteTokens) || 0 }
22
+ }
23
+ function positive(u: SessionUsageTotals): boolean { return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0 }
24
+ function routeOf(x: any): { provider: string; model: string } {
25
+ const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x
26
+ return { provider: typeof source?.provider === 'string' ? source.provider : '', model: typeof source?.model === 'string' ? source.model : '' }
27
+ }
28
+ function streamUsage(data: any): { usage?: SessionUsageTotals; firstTime?: number } {
29
+ let firstTime: number | undefined
30
+ let usage: SessionUsageTotals | undefined
31
+ for (const r of Array.isArray(data?.stream) ? data.stream : []) {
32
+ const t = typeof r?.time === 'number' ? r.time : undefined
33
+ if (firstTime === undefined && t !== undefined && (r?.type === 'chunk' || r?.chunk?.type === 'text' || r?.chunk?.type === 'content')) firstTime = t
34
+ if (r?.type === 'chunk' && r?.chunk?.type === 'usage') usage = usageOf(r.chunk)
35
+ }
36
+ return { usage, firstTime }
37
+ }
38
+ function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
39
+ const x: any = item
40
+ if (x?.header?.id && typeof x.header.id === 'string') return x.header
41
+ return typeof x?.id === 'string' && x.id ? x : null
42
+ }
43
+ async function read(p: UsageSessionPersistence, id: string, live?: UsageSessionHandle): Promise<{ events: readonly UsageSessionEvent[]; cut: number }> {
44
+ if (live?.snapshotEvents && typeof live.inheritedEventCount === 'number') return { events: live.snapshotEvents(), cut: Math.max(0, live.inheritedEventCount) }
45
+ if (p.open) {
46
+ const h = await p.open(id, 'read'); const out: UsageSessionEvent[] = []
47
+ try { for (let off = 0;;) { const page = await h.read(off, 500); if (!page.events.length) break; out.push(...page.events); off += page.events.length } }
48
+ finally { await h.close().catch(() => {}) }
49
+ return { events: out, cut: Math.max(0, Number(h.inheritedEventCount) || 0) }
50
+ }
51
+ if (p.inspect) { const x = await p.inspect(id); return { events: x.events, cut: Math.max(0, Number(x.inheritedEventCount) || 0) } }
52
+ throw new Error('no readable persistence seam (need open or inspect)')
53
+ }
54
+
55
+ type MutableRecord = SessionUsageDetail['records'][number] & { _attempt: number; _lastUsage: SessionUsageTotals }
56
+ function nameOf(h: UsageSessionHeader | undefined, id: string): string {
57
+ const x: any = h
58
+ if (typeof x?.name === 'string' && x.name) return x.name
59
+ if (typeof x?.title === 'string' && x.title) return x.title
60
+ if (typeof x?.cwd === 'string' && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id
61
+ return id
62
+ }
63
+
64
+ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionHeader | undefined, live: UsageSessionHandle | undefined) {
65
+ const { events, cut } = await read(p, id, live)
66
+ const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
67
+ const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
68
+ let ordinal = 0
69
+ // Per-turn display names come from the first real user prompt of each turn
70
+ // (other `user/message` kinds are runtime context / skill catalog injections);
71
+ // the prompt usually lands before `turn/start`, so buffer it until the turn opens.
72
+ let currentTurn = 0; let effort = ''; let pendingTopic = ''
73
+ const turnTopics = new Map<number, string>()
74
+ const own = events.filter(e => !(cut > 0 && typeof e.seq === 'number' && e.seq < cut))
75
+ // Real session names: a subagent carries its delegation label on its
76
+ // `subagent/descriptor` event ("完成日志明细后端"), every session can carry a
77
+ // generated `session/title`; only fall back to the header/cwd/id guesses.
78
+ let label = '', title = ''
79
+ for (const ev of own) {
80
+ const d: any = ev.data ?? {}
81
+ if (!label && ev.type === 'subagent/descriptor' && typeof d.label === 'string' && d.label.trim()) label = d.label.trim()
82
+ if (!title && ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
83
+ if (label && title) break
84
+ }
85
+ const name = label || title || nameOf(header, id)
86
+ for (const ev of own) {
87
+ const d: any = ev.data ?? {}; const turn = Number.isFinite(d.turn) ? d.turn : 0; const step = Number.isFinite(d.step) ? d.step : 0
88
+ if (ev.type === 'request/context') { route = { ...route, ...routeOf(d) }; continue }
89
+ const r = routeOf(d); if (r.provider || r.model) route = { ...route, ...r }
90
+ if (ev.type === 'turn/start') {
91
+ currentTurn = turn
92
+ if (pendingTopic && !turnTopics.has(currentTurn)) { turnTopics.set(currentTurn, pendingTopic); pendingTopic = '' }
93
+ } else if (ev.type === 'user/message' && d.source?.kind === 'user') {
94
+ const block = (Array.isArray(d.content) ? d.content : []).find((b: any) => b?.type === 'text' && typeof b.text === 'string')
95
+ const text = String(block?.text ?? '').replace(/\s+/g, ' ').trim()
96
+ if (text) {
97
+ if (currentTurn > 0 && !turnTopics.has(currentTurn)) turnTopics.set(currentTurn, text)
98
+ else pendingTopic = text
99
+ }
100
+ } else if (ev.type === 'request/header') {
101
+ const e = d.header?.config?.reasoningEffort
102
+ if (typeof e === 'string' && e) {
103
+ effort = e
104
+ for (const x of records) if (x.status === 'running') x.effort = e
105
+ }
106
+ }
107
+ let rec: MutableRecord | undefined
108
+ if (ev.type === 'step/start' || ev.type === 'llm/retry-started') {
109
+ const key = `${turn}:${step}`
110
+ if (ev.type === 'llm/retry-started' || !byKey.has(key)) {
111
+ const previous = byKey.get(key)
112
+ if (previous && ev.type === 'llm/retry-started' && previous.status === 'running') { previous.status = 'failed'; previous.finishedAt = ev.time }
113
+ rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: ev.type === 'llm/retry-started' ? 1 : 0, _lastUsage: zero() }
114
+ records.push(rec); byKey.set(key, rec)
115
+ }
116
+ } else if (ev.type === 'assistant/chunk' || ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
117
+ rec = byKey.get(`${turn}:${step}`)
118
+ if (!rec) { rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: 0, _lastUsage: zero() }; records.push(rec); byKey.set(`${turn}:${step}`, rec) }
119
+ if (r.provider) rec.provider = r.provider; if (r.model) rec.model = r.model
120
+ const su = streamUsage(d); const u = ev.type === 'assistant/message' ? (d.usage ? usageOf(d) : su.usage) : ev.type === 'assistant/chunk' ? (d.chunk?.type === 'usage' ? usageOf(d.chunk) : undefined) : (d.usage ? usageOf(d) : su.usage)
121
+ if (u) {
122
+ rec._lastUsage = u; rec.totals = u
123
+ if (positive(u) && rec.firstTokenMs === null) {
124
+ const rawFirst = su.firstTime ?? (typeof d.chunk?.time === 'number' ? d.chunk.time : ev.time)
125
+ rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt)
126
+ }
127
+ }
128
+ if (ev.type !== 'assistant/chunk') { rec.finishedAt = ev.time; rec.status = ev.type === 'assistant/attempt' ? 'failed' : 'completed' }
129
+ const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt)
130
+ if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1000)
131
+ } else if (ev.type === 'turn/end') {
132
+ const recs = records.filter(x => x.turn === turn && x.status === 'running'); for (const x of recs) { x.status = 'interrupted'; x.finishedAt = ev.time }
133
+ } else if (ev.type === 'compaction/summary') {
134
+ const u = usageOf(d); rec = { id: `${id}:compaction:${ordinal++}`, sessionId: id, sessionName: name, turn: 0, step: 0, startedAt: ev.time, finishedAt: ev.time, provider: typeof d.provider === 'string' ? d.provider : '', model: typeof d.model === 'string' ? d.model : '', status: 'completed', totals: u, firstTokenMs: null, tokensPerSecond: null, turnName: '', effort: '', _attempt: 0, _lastUsage: u }; records.push(rec)
135
+ }
136
+ const sample = fold.fold(ev); if (sample && !sample.turn) add(totals, { uncachedInputTokens: sample.inputTokens, outputTokens: sample.outputTokens, cacheReadTokens: sample.cacheReadTokens, cacheWriteTokens: sample.cacheWriteTokens })
137
+ }
138
+ // Records intentionally expose the final fold value; fold corrections can make
139
+ // a stream sample disappear, so reconcile aggregate totals from the records only
140
+ // for presentation while `totals` remains the authoritative UsageFold result.
141
+ const requests = records.length
142
+ const firsts = records.map(x => x.firstTokenMs).filter((x): x is number => x !== null)
143
+ const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0)
144
+ const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0)
145
+ for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
146
+ return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1000) : null }
147
+ }
148
+
149
+ export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
150
+ const raw = await persistence.list(typeof persistence.open === 'function' ? undefined : undefined)
151
+ const headers = raw.map(headerOf).filter((x): x is UsageSessionHeader => x !== null)
152
+ const live = new Map((sessions?.list() ?? []).map(x => [x.id, x])); const children = new Map<string, string[]>()
153
+ for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...(children.get(h.parentSession) ?? []), h.id])
154
+ for (const h of live.values()) { const p = h.header?.parentSession ?? h.parentSession; if (p) children.set(p, [...(children.get(p) ?? []).filter(x => x !== h.id), h.id]) }
155
+ const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
156
+ while (q.length) for (const c of children.get(q.shift()!) ?? []) if (!seen.has(c)) { seen.add(c); ids.push(c); q.push(c) }
157
+ const all = [rootId, ...ids]; const parts = await Promise.all(all.map(id => one(persistence, id, headers.find(h => h.id === id), live.get(id))))
158
+ const root = parts[0]; const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
159
+ for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
160
+ const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
161
+ const routeMap = new Map<string, SessionUsageDetail['routes'][number]>()
162
+ for (const r of records) if (r.provider || r.model) { const id = `${r.provider}/${r.model}`; routeMap.set(id, { id, provider: r.provider, name: r.provider, model: r.model, status: r.status === 'running' ? 'running' : r.status === 'failed' ? 'failed' : 'completed' }) }
163
+ const llm: any = ctx?.get?.('llm'); for (const x of llm?.listProviders?.() ?? []) if (x?.id && !routeMap.has(x.id)) routeMap.set(x.id, { id: x.id, provider: x.id, name: x.name || x.id, model: '', status: 'idle' })
164
+ // Model rows key on the raw historical model id; attach the model selector's
165
+ // display name plus the serving providers so the by-model card never shows a
166
+ // route id as the model name and can disambiguate same-named models.
167
+ const providerNames = resolveProviderNames(llm)
168
+ const refOf = (provider: string, model: string): string => provider ? `${provider}/${model}` : model
169
+ const refs: string[] = []
170
+ for (const [model, m] of models) if (model !== '(unknown)') {
171
+ if (m.providers.size) for (const provider of m.providers) refs.push(refOf(provider, model))
172
+ else refs.push(refOf('', model))
173
+ }
174
+ const names = await resolveModelNames(refs, llm)
175
+ const displayNameOf = (model: string, providers: Set<string>): string | undefined => {
176
+ // One row can span providers; take the first display name they agree on.
177
+ const keys = providers.size ? [...providers].map(provider => refOf(provider, model)) : [refOf('', model)]
178
+ for (const key of keys) { const resolved = names.get(key)?.trim(); if (resolved) return resolved }
179
+ return undefined
180
+ }
181
+ return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: root.averageFirstTokenMs, tokensPerSecond: root.tokensPerSecond, subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()] }
182
+ }
package/src/wire.ts CHANGED
@@ -136,6 +136,62 @@ export interface SessionUsageRequest {
136
136
  sessionId: string
137
137
  }
138
138
 
139
+ /** Detailed per-session usage view used by the Usage tab. */
140
+ export interface SessionUsageDetail {
141
+ sessionId: string
142
+ totals: SessionUsageTotals
143
+ requests: number
144
+ averageFirstTokenMs: number | null
145
+ tokensPerSecond: number | null
146
+ subagents: Array<{
147
+ sessionId: string
148
+ name: string
149
+ totals: SessionUsageTotals
150
+ requests: number
151
+ averageFirstTokenMs: number | null
152
+ tokensPerSecond: number | null
153
+ }>
154
+ models: Array<{
155
+ /** Raw model id from the historical route. Stays the row key and the
156
+ * fallback label (also the tooltip) when no display name is resolved. */
157
+ model: string
158
+ /** The model selector's display name for this model, resolved host-side
159
+ * from the llm service metadata. Optional so an older host (or an
160
+ * unresolvable route) still serves a valid payload. */
161
+ name?: string
162
+ /** Display names (raw ids when unknown) of every provider that served this
163
+ * model in the session, first-seen order. The by-model card shows these in
164
+ * gray when two rows end up sharing one display name. */
165
+ providers?: string[]
166
+ totals: SessionUsageTotals
167
+ requests: number
168
+ }>
169
+ records: Array<{
170
+ id: string
171
+ sessionId: string
172
+ sessionName: string
173
+ turn: number
174
+ step: number
175
+ startedAt: number
176
+ finishedAt: number | null
177
+ provider: string
178
+ model: string
179
+ status: 'running' | 'completed' | 'failed' | 'interrupted'
180
+ totals: SessionUsageTotals
181
+ firstTokenMs: number | null
182
+ tokensPerSecond: number | null
183
+ turnName: string
184
+ effort: string
185
+ }>
186
+ routes: Array<{
187
+ id: string
188
+ provider: string
189
+ name: string
190
+ model: string
191
+ status: 'idle' | 'running' | 'completed' | 'failed'
192
+ }>
193
+ }
194
+
139
195
  /** The backfill (historical session scan) progress state. */
140
196
  export interface BackfillStatus {
141
197
  running: boolean