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.
- package/README.md +16 -2
- package/lib/client.js +1163 -62
- package/lib/index.js +392 -1
- package/lib/types/client/ModelLogo.d.ts +18 -0
- package/lib/types/client/SessionUsageView.d.ts +14 -0
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/format.d.ts +1 -1
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/model-logos-core.d.ts +11 -0
- package/lib/types/client/model-logos-data.d.ts +8 -0
- package/lib/types/client/session-usage-locales.d.ts +62 -0
- package/lib/types/client/use-session-detail.d.ts +8 -0
- package/lib/types/routes.d.ts +2 -2
- package/lib/types/session-detail.d.ts +3 -0
- package/lib/types/wire.d.ts +55 -0
- package/package.json +1 -1
- package/src/client/ModelLogo.tsx +29 -0
- package/src/client/SessionUsageView.module.css +122 -0
- package/src/client/SessionUsageView.tsx +216 -0
- package/src/client/TokenTotalPanel.tsx +31 -19
- package/src/client/api.ts +8 -2
- package/src/client/format.ts +4 -2
- package/src/client/index.tsx +20 -1
- package/src/client/model-logos-core.ts +101 -0
- package/src/client/model-logos-data.ts +58 -0
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/collector.ts +7 -0
- package/src/index.ts +8 -1
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
|
@@ -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
|