dsh-token-total 1.0.6 → 1.0.8

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.
@@ -10,13 +10,17 @@ export function useSessionDetail(sessionId: string): {
10
10
  detail: SessionUsageDetail | null
11
11
  loading: boolean
12
12
  error: boolean
13
- retry: () => void
13
+ /** Manual refresh: runs now when idle, or right after an in-flight poll lands. */
14
+ refresh: () => void
15
+ /** Wall-clock time of the last successful read, for the "as of" stamp. */
16
+ updatedAt: number | null
14
17
  } {
15
- const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
18
+ const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean; updatedAt: number | null } | null>(null)
16
19
  const refresh = useRef<(() => void) | null>(null)
17
20
  useEffect(() => {
18
21
  let active = true
19
22
  let pending = false
23
+ let wantedNow = false
20
24
  let timer: ReturnType<typeof setTimeout> | undefined
21
25
  let controller: AbortController | undefined
22
26
  const request = (): void => {
@@ -25,16 +29,21 @@ export function useSessionDetail(sessionId: string): {
25
29
  pending = true
26
30
  controller = new AbortController()
27
31
  void fetchSessionDetail(sessionId, controller.signal).then(detail => {
28
- if (active) setReading({ sessionId, detail, error: false })
32
+ if (active) setReading({ sessionId, detail, error: false, updatedAt: Date.now() })
29
33
  }).catch(() => {
30
- if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
34
+ if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true, updatedAt: previous?.sessionId === sessionId ? previous.updatedAt : null }))
31
35
  }).finally(() => {
32
36
  pending = false
33
- if (active) timer = setTimeout(request, DETAIL_POLL_MS)
37
+ // A refresh pressed mid-poll skips the regular interval and refetches
38
+ // as soon as the in-flight answer lands, so the click never stalls.
39
+ if (active) timer = setTimeout(request, wantedNow ? 0 : DETAIL_POLL_MS)
40
+ wantedNow = false
34
41
  })
35
42
  }
36
43
  const wake = (): void => { if (document.visibilityState === 'visible') request() }
37
- refresh.current = request
44
+ // Manual refresh: fire immediately when idle; when a poll is already in
45
+ // flight, mark it and refetch right after it settles.
46
+ refresh.current = () => { if (pending) wantedNow = true; else request() }
38
47
  document.addEventListener('visibilitychange', wake)
39
48
  request()
40
49
  return () => {
@@ -46,5 +55,5 @@ export function useSessionDetail(sessionId: string): {
46
55
  }
47
56
  }, [sessionId])
48
57
  const current = reading?.sessionId === sessionId ? reading : null
49
- return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
58
+ return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, refresh: () => refresh.current?.(), updatedAt: current?.updatedAt ?? null }
50
59
  }
@@ -7,6 +7,10 @@ import { modelNameOf, providerOf } from './shared.ts'
7
7
  const LOOKUP_CONCURRENCY = 4
8
8
  const LOOKUP_TIMEOUT_MS = 2000
9
9
 
10
+ /** Raised when a lookup's own timer fires — i.e. the shared phase budget is
11
+ * spent. Workers stop on this instead of trusting the wall clock again. */
12
+ class LookupTimeoutError extends Error {}
13
+
10
14
  /** Bound metadata queries even if an adapter fails to honor cancellation. */
11
15
  async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: number): Promise<T> {
12
16
  const controller = new AbortController()
@@ -17,7 +21,7 @@ async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: n
17
21
  new Promise<never>((_, reject) => {
18
22
  timer = setTimeout(() => {
19
23
  controller.abort()
20
- reject(new Error('model display-name lookup timed out'))
24
+ reject(new LookupTimeoutError('model display-name lookup timed out'))
21
25
  }, timeoutMs)
22
26
  }),
23
27
  ])
@@ -81,8 +85,12 @@ export async function resolveModelNames(
81
85
  try {
82
86
  const info = await lookup((signal) => llm.resolveModelInfo!(providerOf(ref), modelNameOf(ref), signal), remaining)
83
87
  if (info.name?.trim()) names.set(ref, info.name)
84
- } catch {
88
+ } catch (error) {
85
89
  // Metadata is auxiliary: never lose usage because a route is gone.
90
+ // The shared budget is spent once our own timer fires; stop instead
91
+ // of trusting the clock again (setTimeout vs Date.now can disagree
92
+ // by ~1ms and would otherwise queue extra lookups past the deadline).
93
+ if (error instanceof LookupTimeoutError) break
86
94
  }
87
95
  }
88
96
  }))
@@ -105,8 +113,10 @@ export async function resolveModelNames(
105
113
  const name = byId.get(modelNameOf(ref))
106
114
  if (name?.trim()) names.set(ref, name)
107
115
  }
108
- } catch {
109
- // A dormant provider must not prevent historical totals from loading.
116
+ } catch (error) {
117
+ // A dormant provider must not prevent historical totals from loading;
118
+ // a fired timer still ends the whole catalog phase (see above).
119
+ if (error instanceof LookupTimeoutError) break
110
120
  }
111
121
  }
112
122
  }))
@@ -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
- function streamUsage(data: any): { usage?: SessionUsageTotals; firstTime?: number } {
29
- let firstTime: number | undefined
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
- 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)
73
+ if (r?.type === 'chunk' && r?.chunk?.type === 'usage') { usage = usageOf(r.chunk); raw = r.chunk.usage ?? r.chunk }
35
74
  }
36
- return { usage, firstTime }
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
- 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) }
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
- 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)
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
- 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)
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: elapsed > 0 ? outputSeconds / (elapsed / 1000) : null }
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 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)
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
- 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()] }
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