dsh-token-total 1.0.4 → 1.0.6

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,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