dsh-token-total 1.0.5 → 1.0.7
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/lib/client.js +279 -37
- package/lib/index.js +105 -28
- package/lib/types/client/ModelLogo.d.ts +18 -0
- package/lib/types/client/format.d.ts +1 -1
- 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 +3 -0
- package/lib/types/client/use-session-detail.d.ts +4 -1
- package/lib/types/shared.d.ts +15 -0
- package/lib/types/wire.d.ts +5 -0
- package/package.json +1 -1
- package/src/client/ModelLogo.tsx +29 -0
- package/src/client/SessionUsageView.module.css +18 -4
- package/src/client/SessionUsageView.tsx +29 -11
- package/src/client/format.ts +1 -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 +3 -0
- package/src/client/use-session-detail.ts +16 -7
- package/src/collector.ts +7 -0
- package/src/index.ts +8 -1
- package/src/model-names.ts +14 -4
- package/src/session-detail.ts +93 -25
- package/src/shared.ts +23 -0
- package/src/wire.ts +5 -0
package/src/session-detail.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
} from './context-types.ts'
|
|
10
10
|
import { UsageFold } from './collector.ts'
|
|
11
11
|
import { resolveModelNames, resolveProviderNames } from './model-names.ts'
|
|
12
|
+
import { decodeRateOf } from './shared.ts'
|
|
12
13
|
import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
|
|
13
14
|
|
|
14
15
|
const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
@@ -20,20 +21,65 @@ function usageOf(x: any): SessionUsageTotals {
|
|
|
20
21
|
const u = x?.usage ?? x
|
|
21
22
|
return { uncachedInputTokens: Number(u?.inputTokens) || 0, outputTokens: Number(u?.outputTokens) || 0, cacheReadTokens: Number(u?.cacheReadTokens) || 0, cacheWriteTokens: Number(u?.cacheWriteTokens) || 0 }
|
|
22
23
|
}
|
|
23
|
-
function positive(u: SessionUsageTotals): boolean { return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0 }
|
|
24
24
|
function routeOf(x: any): { provider: string; model: string } {
|
|
25
25
|
const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x
|
|
26
26
|
return { provider: typeof source?.provider === 'string' ? source.provider : '', model: typeof source?.model === 'string' ? source.model : '' }
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/** Whether one stream chunk carries the model's first output token. Mirrors
|
|
29
|
+
* `isTokenDelta` in @deepseek-ai/dsh-llm so the decode window starts on the
|
|
30
|
+
* same chunk the official `sessionStats` projection starts it on. */
|
|
31
|
+
function isTokenDelta(chunk: any): boolean {
|
|
32
|
+
switch (chunk?.type) {
|
|
33
|
+
case 'text-delta': case 'reasoning-delta': return chunk.text !== ''
|
|
34
|
+
case 'tool-call-delta': return chunk.argumentsDelta !== '' || chunk.name !== undefined
|
|
35
|
+
default: return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Reconstructed time of the first token in one packed delta run (mirrors
|
|
39
|
+
* `runFirstTokenTime`): a name-bearing tool-call run starts at its first
|
|
40
|
+
* member, every other run at its first non-empty fragment. */
|
|
41
|
+
function runFirstTokenTime(run: any): number | undefined {
|
|
42
|
+
if (run?.type === 'tool-call-chunks' && run.name !== undefined) return run.time0
|
|
43
|
+
const fragments = run?.type === 'tool-call-chunks' ? run.args : run?.texts
|
|
44
|
+
if (!Array.isArray(fragments)) return undefined
|
|
45
|
+
let time = Number(run.time0)
|
|
46
|
+
for (let i = 0; i < fragments.length; i++) {
|
|
47
|
+
if (i > 0) time += Number(run.dt?.[i - 1]) || 0
|
|
48
|
+
if (fragments[i] !== '') return time
|
|
49
|
+
}
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
/** Time of the first token in one compact attempt stream (mirrors
|
|
53
|
+
* `assistantStreamFirstTokenTime`). */
|
|
54
|
+
function streamFirstTokenTime(stream: any): number | undefined {
|
|
55
|
+
for (const r of Array.isArray(stream) ? stream : []) {
|
|
56
|
+
const t = r?.type === 'chunk' ? (isTokenDelta(r.chunk) ? r.time : undefined) : runFirstTokenTime(r)
|
|
57
|
+
if (typeof t === 'number' && Number.isFinite(t)) return t
|
|
58
|
+
}
|
|
59
|
+
return undefined
|
|
60
|
+
}
|
|
61
|
+
/** Time of the first token carried by one live `assistant/chunk` payload. */
|
|
62
|
+
function chunkFirstTokenTime(data: any, fallback: number): number | undefined {
|
|
63
|
+
const c = data?.chunk
|
|
64
|
+
if (!c) return undefined
|
|
65
|
+
const timed = c.chunk && typeof c.time === 'number' ? c : null
|
|
66
|
+
if (!isTokenDelta(timed ? timed.chunk : c)) return undefined
|
|
67
|
+
return timed ? timed.time : typeof c.time === 'number' ? c.time : fallback
|
|
68
|
+
}
|
|
69
|
+
function streamUsage(data: any): { usage?: SessionUsageTotals; raw?: any } {
|
|
30
70
|
let usage: SessionUsageTotals | undefined
|
|
71
|
+
let raw: any
|
|
31
72
|
for (const r of Array.isArray(data?.stream) ? data.stream : []) {
|
|
32
|
-
|
|
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)
|
|
73
|
+
if (r?.type === 'chunk' && r?.chunk?.type === 'usage') { usage = usageOf(r.chunk); raw = r.chunk.usage ?? r.chunk }
|
|
35
74
|
}
|
|
36
|
-
return { usage,
|
|
75
|
+
return { usage, raw }
|
|
76
|
+
}
|
|
77
|
+
/** Provider-reported completion tokens, or null when unreported or invalid:
|
|
78
|
+
* an invalid count must not enter the decode quotient (mirrors
|
|
79
|
+
* `usageOutputTokens` in the sessionStats projection). */
|
|
80
|
+
function usageOutputTokens(x: any): number | null {
|
|
81
|
+
const value = (x?.usage ?? x)?.outputTokens
|
|
82
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null
|
|
37
83
|
}
|
|
38
84
|
function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
|
|
39
85
|
const x: any = item
|
|
@@ -83,6 +129,12 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
|
|
|
83
129
|
if (label && title) break
|
|
84
130
|
}
|
|
85
131
|
const name = label || title || nameOf(header, id)
|
|
132
|
+
// Decode accounting mirrors the official `sessionStats` projection: a step
|
|
133
|
+
// records its first token once (the earliest attempt wins, surviving an
|
|
134
|
+
// in-step retry), and only the assembled `assistant/message` closes the
|
|
135
|
+
// decode window, so failed attempts and cancelled steps stay uncounted.
|
|
136
|
+
const stepTokens = new Map<string, number>()
|
|
137
|
+
const closedSteps = new Set<string>()
|
|
86
138
|
for (const ev of own) {
|
|
87
139
|
const d: any = ev.data ?? {}; const turn = Number.isFinite(d.turn) ? d.turn : 0; const step = Number.isFinite(d.step) ? d.step : 0
|
|
88
140
|
if (ev.type === 'request/context') { route = { ...route, ...routeOf(d) }; continue }
|
|
@@ -110,28 +162,41 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
|
|
|
110
162
|
if (ev.type === 'llm/retry-started' || !byKey.has(key)) {
|
|
111
163
|
const previous = byKey.get(key)
|
|
112
164
|
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() }
|
|
165
|
+
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, decodeMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: ev.type === 'llm/retry-started' ? 1 : 0, _lastUsage: zero() }
|
|
114
166
|
records.push(rec); byKey.set(key, rec)
|
|
115
167
|
}
|
|
116
168
|
} else if (ev.type === 'assistant/chunk' || ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
|
|
117
|
-
|
|
118
|
-
|
|
169
|
+
const key = `${turn}:${step}`
|
|
170
|
+
rec = byKey.get(key)
|
|
171
|
+
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, decodeMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: 0, _lastUsage: zero() }; records.push(rec); byKey.set(key, rec) }
|
|
119
172
|
if (r.provider) rec.provider = r.provider; if (r.model) rec.model = r.model
|
|
120
173
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
174
|
+
if (u) { rec._lastUsage = u; rec.totals = u }
|
|
175
|
+
// The first token opens both this row's first-token wait and the step's
|
|
176
|
+
// decode window; the earliest attempt keeps it across an in-step retry.
|
|
177
|
+
const tokenAt = streamFirstTokenTime(d.stream) ?? chunkFirstTokenTime(d, ev.time)
|
|
178
|
+
if (tokenAt !== undefined) {
|
|
179
|
+
if (!stepTokens.has(key)) stepTokens.set(key, tokenAt)
|
|
180
|
+
if (rec.firstTokenMs === null) rec.firstTokenMs = Math.max(0, tokenAt - rec.startedAt)
|
|
181
|
+
}
|
|
182
|
+
if (ev.type !== 'assistant/chunk') {
|
|
183
|
+
rec.finishedAt = ev.time; rec.status = ev.type === 'assistant/attempt' ? 'failed' : 'completed'
|
|
184
|
+
// Only an assembled message closes the decode window, and only its own
|
|
185
|
+
// top-level `usage` counts, exactly as the official sessionStats
|
|
186
|
+
// projection counts it: a failed attempt, a cancelled step or a
|
|
187
|
+
// stream-only usage sample contributes no decode time, so the card, the
|
|
188
|
+
// turn summaries and the composer bottom bar all quote one speed.
|
|
189
|
+
const stepFirst = stepTokens.get(key)
|
|
190
|
+
if (rec.status === 'completed' && !closedSteps.has(key) && stepFirst !== undefined && usageOutputTokens(d.usage) !== null) {
|
|
191
|
+
closedSteps.add(key)
|
|
192
|
+
rec.decodeMs = Math.max(0, rec.finishedAt - stepFirst)
|
|
193
|
+
if (rec.decodeMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (rec.decodeMs / 1000)
|
|
126
194
|
}
|
|
127
195
|
}
|
|
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
196
|
} else if (ev.type === 'turn/end') {
|
|
132
197
|
const recs = records.filter(x => x.turn === turn && x.status === 'running'); for (const x of recs) { x.status = 'interrupted'; x.finishedAt = ev.time }
|
|
133
198
|
} 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)
|
|
199
|
+
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, decodeMs: null, tokensPerSecond: null, turnName: '', effort: '', _attempt: 0, _lastUsage: u }; records.push(rec)
|
|
135
200
|
}
|
|
136
201
|
const sample = fold.fold(ev); if (sample && !sample.turn) add(totals, { uncachedInputTokens: sample.inputTokens, outputTokens: sample.outputTokens, cacheReadTokens: sample.cacheReadTokens, cacheWriteTokens: sample.cacheWriteTokens })
|
|
137
202
|
}
|
|
@@ -139,11 +204,11 @@ async function one(p: UsageSessionPersistence, id: string, header: UsageSessionH
|
|
|
139
204
|
// a stream sample disappear, so reconcile aggregate totals from the records only
|
|
140
205
|
// for presentation while `totals` remains the authoritative UsageFold result.
|
|
141
206
|
const requests = records.length
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const
|
|
207
|
+
// The first-token average quotes answered requests only: a failed attempt or
|
|
208
|
+
// a cancelled step is not a wait the reader experienced as an answer.
|
|
209
|
+
const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
145
210
|
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:
|
|
211
|
+
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: decodeRateOf(records) }
|
|
147
212
|
}
|
|
148
213
|
|
|
149
214
|
export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
|
|
@@ -155,7 +220,7 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
|
|
|
155
220
|
const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
|
|
156
221
|
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
222
|
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
|
|
223
|
+
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
224
|
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
225
|
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
226
|
const routeMap = new Map<string, SessionUsageDetail['routes'][number]>()
|
|
@@ -178,5 +243,8 @@ export async function buildSessionUsageDetail(persistence: UsageSessionPersisten
|
|
|
178
243
|
for (const key of keys) { const resolved = names.get(key)?.trim(); if (resolved) return resolved }
|
|
179
244
|
return undefined
|
|
180
245
|
}
|
|
181
|
-
|
|
246
|
+
// The card is scoped to this session and every subagent, so its speed and
|
|
247
|
+
// first-token average fold the same records its token totals do.
|
|
248
|
+
const firsts = records.filter(x => x.status === 'completed').map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
249
|
+
return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: decodeRateOf(records), 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
250
|
}
|
package/src/shared.ts
CHANGED
|
@@ -53,6 +53,29 @@ export function modelNameOf(modelRef: string): string {
|
|
|
53
53
|
return modelRef
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** The decode accounting one request row must expose: the wall window from the
|
|
57
|
+
* step's first token to its assembled assistant message, plus the output tokens
|
|
58
|
+
* that window produced. */
|
|
59
|
+
export interface DecodeRowLike {
|
|
60
|
+
decodeMs: number | null
|
|
61
|
+
totals: { outputTokens: number }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Decode throughput across rows: Σ(output tokens) / Σ(decode window), matching
|
|
65
|
+
* the `sessionStats` projection's decodeTokens/decodeMs rate that the composer
|
|
66
|
+
* bottom bar shows. Rows without a decode window (failed attempts, cancelled
|
|
67
|
+
* steps, running requests) are excluded, so the card, the turn summaries and
|
|
68
|
+
* the bottom bar all report one speed. */
|
|
69
|
+
export function decodeRateOf(rows: readonly DecodeRowLike[]): number | null {
|
|
70
|
+
let ms = 0
|
|
71
|
+
let tokens = 0
|
|
72
|
+
// typeof, not `!== null`: a bundle newer than the payload it is handed (a
|
|
73
|
+
// cached detail, an older server) leaves `decodeMs` absent, and one absent
|
|
74
|
+
// field must not turn the whole sum into NaN.
|
|
75
|
+
for (const row of rows) if (typeof row.decodeMs === 'number' && Number.isFinite(row.decodeMs)) { ms += row.decodeMs; tokens += row.totals.outputTokens }
|
|
76
|
+
return ms > 0 ? tokens / (ms / 1000) : null
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
/** Canonicalize a DISPLAY NAME, never a route id. Case and word separators
|
|
57
80
|
* do not distinguish models, but versions, punctuation and suffixes do:
|
|
58
81
|
* gpt-6.1-sol / GPT 6.1 sol -> gpt-6.1-sol, not gpt-6.1 or gpt-61-sol.
|
package/src/wire.ts
CHANGED
|
@@ -179,6 +179,11 @@ export interface SessionUsageDetail {
|
|
|
179
179
|
status: 'running' | 'completed' | 'failed' | 'interrupted'
|
|
180
180
|
totals: SessionUsageTotals
|
|
181
181
|
firstTokenMs: number | null
|
|
182
|
+
/** Wall window from this step's first token to the assembled assistant
|
|
183
|
+
* message — the same decode window the `sessionStats` projection counts
|
|
184
|
+
* (decodeMs). Null when the request never assembled a message or reported
|
|
185
|
+
* no usage, so it stays out of every throughput quotient. */
|
|
186
|
+
decodeMs: number | null
|
|
182
187
|
tokensPerSecond: number | null
|
|
183
188
|
turnName: string
|
|
184
189
|
effort: string
|