dsh-token-total 1.0.0 → 1.0.1

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.
@@ -1,115 +1,62 @@
1
- /**
2
- * The composer row's window onto the WHOLE session tree.
3
- *
4
- * dsh's native `tokenUsage` projection is the main agent of the current
5
- * session only: a session that delegates to subagents (each subagent is its
6
- * own persisted session, linked by `header.parentSession`) shows a fraction of
7
- * what it actually spent, and nothing in the shipped UI shows the rest. The
8
- * host half answers that question over `/token-total/api/session`, replaying
9
- * the root log plus every descendant's log; this hook is the browser end.
10
- *
11
- * Refresh policy: the request is cheap on the host (its tree cache is
12
- * invalidated by live events), and the native usage figure moves exactly when
13
- * a call settles — so a change in the native signature is the trigger to
14
- * re-ask. There is no timer: an idle session costs nothing.
15
- *
16
- * Failure policy: the pill must never break the composer, so a failed request
17
- * keeps the last good answer and simply does not retry until the next trigger.
18
- */
1
+ /** The composer row's window onto the whole session tree. The native usage
2
+ * projection only moves for main-agent calls, so poll the displayed session
3
+ * even before its first child appears. Requests are serialized and late answers
4
+ * from a previous session are discarded. Failures keep the last good reading. */
19
5
  import { useEffect, useRef, useState } from 'react'
20
6
  import { fetchSessionUsage } from './api.ts'
7
+ import { combinedTotals } from './session-usage-core.ts'
21
8
  import type { SessionUsageTotals, SessionUsageTree } from '../wire.ts'
22
9
 
23
- const ZERO: SessionUsageTotals = {
24
- uncachedInputTokens: 0,
25
- outputTokens: 0,
26
- cacheReadTokens: 0,
27
- cacheWriteTokens: 0,
28
- }
29
-
30
- function add(into: SessionUsageTotals, from: SessionUsageTotals): SessionUsageTotals {
31
- return {
32
- uncachedInputTokens: into.uncachedInputTokens + from.uncachedInputTokens,
33
- outputTokens: into.outputTokens + from.outputTokens,
34
- cacheReadTokens: into.cacheReadTokens + from.cacheReadTokens,
35
- cacheWriteTokens: into.cacheWriteTokens + from.cacheWriteTokens,
36
- }
37
- }
38
-
39
- /** Fold a tree into the flat shape the pill's dialog renders. */
40
- export function combinedTotals(tree: SessionUsageTree): SessionUsageTotals {
41
- return add(tree.self, tree.subagents)
42
- }
43
-
44
- /** Whether a totals record carries any billable token at all. */
45
- export function hasAnyTokens(totals: SessionUsageTotals | undefined): boolean {
46
- if (totals === undefined) return false
47
- return totals.uncachedInputTokens + totals.outputTokens
48
- + totals.cacheReadTokens + totals.cacheWriteTokens > 0
49
- }
50
-
51
- /** Total billed tokens of a flat totals record (prompt side + completion). */
52
- export function billedTotal(totals: SessionUsageTotals): number {
53
- return totals.uncachedInputTokens + totals.cacheReadTokens
54
- + totals.cacheWriteTokens + totals.outputTokens
55
- }
56
-
57
- /** Cache hit rate over the prompt side, or null when nothing was prompted. */
58
- export function cacheHitPercentOf(totals: SessionUsageTotals): number | null {
59
- const hit = totals.cacheReadTokens
60
- const miss = totals.uncachedInputTokens + totals.cacheWriteTokens
61
- if (hit + miss === 0) return null
62
- return (hit / (hit + miss)) * 100
63
- }
10
+ export { billedTotal, cacheHitPercentOf, combinedTotals, hasAnyTokens } from './session-usage-core.ts'
64
11
 
65
12
  export interface SessionTreeReading {
66
13
  tree: SessionUsageTree | null
67
- /** main + subagents, or null before the first successful answer. */
14
+ /** Log-based main + subagents. The pill substitutes the live main projection. */
68
15
  totals: SessionUsageTotals | null
69
16
  }
70
17
 
71
- /** How often a session that HAS subagents re-asks. The native figure only moves
72
- * when the main agent's own call settles, so without this a subagent still
73
- * working (or one that just finished) would not update the pill. A session
74
- * with no subagents never polls: nothing exists to miss. */
75
- const SUBAGENT_POLL_MS = 4_000
18
+ const SESSION_POLL_MS = 4_000
76
19
 
77
- /**
78
- * Subscribe to one session's whole-tree usage.
79
- *
80
- * `signature` is any string that changes when the native usage figure moves;
81
- * passing it in keeps this hook out of the projection seat and lets the caller
82
- * decide what "a new call settled" means for its own pill. A session that turns
83
- * out to have subagents additionally re-asks on a slow beat, because subagent
84
- * spend lands in a different session log and no native figure moves for it.
85
- */
86
20
  export function useSessionUsage(sessionId: string | undefined, signature: string): SessionTreeReading {
87
- const [tree, setTree] = useState<SessionUsageTree | null>(null)
88
- const latest = useRef<string>('')
89
- const [beat, setBeat] = useState(0)
90
- const hasSubagents = (tree?.subagentSessions ?? 0) > 0
91
- useEffect(() => {
92
- if (!hasSubagents) return
93
- const handle = setInterval(() => { setBeat(Date.now()) }, SUBAGENT_POLL_MS)
94
- return () => { clearInterval(handle) }
95
- }, [hasSubagents])
21
+ const [reading, setReading] = useState<{ sessionId: string; tree: SessionUsageTree } | null>(null)
22
+ const refresh = useRef<(() => void) | null>(null)
96
23
  useEffect(() => {
97
24
  if (typeof sessionId !== 'string' || sessionId === '') return
98
- const key = `${sessionId}|${signature}|${beat}`
99
- if (latest.current === key) return
100
- latest.current = key
101
- let cancelled = false
102
- void fetchSessionUsage(sessionId).then((value) => {
103
- if (cancelled) return
104
- setTree(value)
105
- }).catch(() => {
106
- // Keep the previous reading; the next trigger retries.
107
- latest.current = ''
108
- })
25
+ let active = true
26
+ let pending = false
27
+ let again = false
28
+ const request = (): void => {
29
+ if (!active) return
30
+ if (pending) {
31
+ again = true
32
+ return
33
+ }
34
+ pending = true
35
+ void fetchSessionUsage(sessionId).then((tree) => {
36
+ if (active) setReading({ sessionId, tree })
37
+ }).catch(() => {
38
+ // Retry on the next beat/signature; do not replace good data with zero.
39
+ }).finally(() => {
40
+ pending = false
41
+ if (active && again) {
42
+ again = false
43
+ request()
44
+ }
45
+ })
46
+ }
47
+ refresh.current = request
48
+ const handle = setInterval(request, SESSION_POLL_MS)
109
49
  return () => {
110
- cancelled = true
50
+ active = false
51
+ refresh.current = null
52
+ clearInterval(handle)
111
53
  }
112
- }, [sessionId, signature, beat])
113
- const totals = tree === null ? null : combinedTotals(tree)
114
- return { tree, totals }
54
+ }, [sessionId])
55
+ useEffect(() => {
56
+ refresh.current?.()
57
+ }, [sessionId, signature])
58
+ // Key the answer on its session, not just effect cleanup: the very first
59
+ // render after switching must not show the previous session's descendants.
60
+ const tree = reading !== null && reading.sessionId === sessionId ? reading.tree : null
61
+ return { tree, totals: tree === null ? null : combinedTotals(tree) }
115
62
  }
package/src/collector.ts CHANGED
@@ -3,10 +3,10 @@
3
3
  * Two sources feed the same fold:
4
4
  *
5
5
  * - live: `session/event` subscription — every committed session event
6
- * reaches this plugin after the fact. `assistant/message` carries the
7
- * step's final `usage` (TokenUsage); `assistant/chunk` carries an early
8
- * `usage` sample from the streaming adapter; `request/context` carries the
9
- * provider/model route. A completed call's usage is attributed to the model
6
+ * reaches this plugin after the fact. Assistant messages expose final usage,
7
+ * and modern message/attempt streams retain raw usage even on failed calls.
8
+ * Legacy `assistant/chunk` reports an early sample; `request/context` carries
9
+ * the provider/model route. A completed call's usage is attributed to the model
10
10
  * the request used — in priority order: the message's own `source`
11
11
  * (authoritative per call), then the session's last-known route, which
12
12
  * comes from observed `request/context` events OR is lazily seeded from
@@ -27,11 +27,10 @@
27
27
  * >= 0.1.2-rc.1, 0 for a non-forked session), and the replay skips it —
28
28
  * the parent's own backfill already counted those events.
29
29
  *
30
- * Idempotence: within ONE session, the same (turn, step) may report usage
31
- * more than once (a streaming sample then the final assistant/message;
32
- * compaction replays). The fold emits each (turn, step) key exactly once and
33
- * keeps the newest values in its own state — see the replaceSample note for
34
- * why the store observably keeps the FIRST emission. Cross-session
30
+ * Idempotence: within ONE session, repeated reports for the same (turn, step)
31
+ * emit only signed bucket corrections: the accumulated usage is last-wins.
32
+ * A started retry closes that replacement slot because its predecessor was a
33
+ * separately billed attempt. Cross-session
35
34
  * (turn, step) collisions are NOT deduped: two sessions both own a turn 1 /
36
35
  * step 1, and each must count. The store's per-day rows accumulate, so
37
36
  * re-folding the same log must not add twice: live events are only folded
@@ -51,6 +50,8 @@ interface UsageEventData {
51
50
  turn?: number
52
51
  step?: number
53
52
  usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number; reasoningTokens?: number }
53
+ /** v2+ durable Assistant stream: usage is a raw (never packed) chunk. */
54
+ stream?: readonly { type?: string; chunk?: { type?: string; usage?: UsageEventData['usage'] } }[]
54
55
  }
55
56
 
56
57
  /** The assistant/message payload. */
@@ -170,6 +171,11 @@ export class UsageFold {
170
171
  // reasonix emits TurnDone regardless of the run's error.
171
172
  return { day: dayKey(ev.time), hour: hourKey(ev.time), inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, turn: true }
172
173
  }
174
+ if (ev.type === 'llm/retry-started') {
175
+ // A retry is a separate billed attempt even though it shares turn/step.
176
+ const key = this.keyOf(ev)
177
+ if (key !== null) this.seen.delete(key)
178
+ }
173
179
  if (ev.type === 'step/start' || ev.type === 'llm/retry-started') {
174
180
  // Attribution note: agent-loop appends step/start BEFORE request/context
175
181
  // (core/agent-loop agent.ts), so the FIRST request marker of a fresh —
@@ -194,17 +200,26 @@ export class UsageFold {
194
200
  request: true,
195
201
  }
196
202
  }
197
- // `assistant/chunk` only exists on DSH <= 0.1.2 (streaming usage sample);
198
- // 0.1.3+ logs attempts as `assistant/attempt` (no usage) and the final
199
- // `assistant/message` stays authoritative. Kept as a dead branch for rc.1.
203
+ // Legacy streaming event. Modern hosts commit the complete stream on an
204
+ // assistant/message OR assistant/attempt, including failed/aborted calls.
200
205
  if (ev.type === 'assistant/chunk') {
201
206
  const data = ev.data as AssistantChunkData
202
207
  const usage = data.chunk?.type === 'usage' ? data.chunk.usage : undefined
203
208
  return usage ? this.replaceSample(ev, usage) : null
204
209
  }
205
- if (ev.type === 'assistant/message') {
210
+ if (ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
206
211
  const data = ev.data as AssistantMessageData
207
- return data.usage ? this.replaceSample(ev, data.usage) : null
212
+ let usage = ev.type === 'assistant/message' ? data.usage : undefined
213
+ if (usage === undefined && Array.isArray(data.stream)) {
214
+ for (let i = data.stream.length - 1; i >= 0; i--) {
215
+ const record = data.stream[i]
216
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage') {
217
+ usage = record.chunk.usage
218
+ break
219
+ }
220
+ }
221
+ }
222
+ return usage ? this.replaceSample(ev, usage) : null
208
223
  }
209
224
  if (ev.type === 'compaction/summary') return this.compactionSample(ev)
210
225
  return null
@@ -213,53 +228,44 @@ export class UsageFold {
213
228
  private replaceSample(ev: UsageSessionEvent, usage: UsageEventData['usage']): UsageSample | null {
214
229
  const usage2 = usage
215
230
  if (!usage2) return null
216
- // A sample with nothing at all is noise; a pure-cache call (zero
217
- // uncached input AND zero output but real cache traffic) still counts —
218
- // its cacheRead/cacheWrite buckets are real provider-reported tokens.
219
- if (
220
- (usage2.inputTokens ?? 0)
221
- + (usage2.outputTokens ?? 0)
222
- + (usage2.cacheReadTokens ?? 0)
223
- + (usage2.cacheWriteTokens ?? 0)
224
- <= 0
225
- ) {
226
- return null
227
- }
228
231
  const key = this.keyOf(ev)
229
232
  const sample: UsageSample = {
230
233
  day: dayKey(ev.time),
231
234
  hour: hourKey(ev.time),
232
- inputTokens: usage2.inputTokens,
233
- outputTokens: usage2.outputTokens,
235
+ inputTokens: usage2.inputTokens ?? 0,
236
+ outputTokens: usage2.outputTokens ?? 0,
234
237
  cacheReadTokens: usage2.cacheReadTokens ?? 0,
235
238
  cacheWriteTokens: usage2.cacheWriteTokens ?? 0,
236
239
  }
237
- if (key === null) {
238
- // No (turn, step) marker: every emission counts (no dedupe slot). The
239
- // shipped adapters always stamp both on usage events, so this path is
240
- // a defensive tail, not a live double-count source. ponytail: a
241
- // synthetic key (e.g. by time+usage hash) could dedupe it if an
242
- // adapter ever reports usage without turn/step.
243
- return sample
244
- }
245
- const prev = this.seen.get(key)
246
- // Track the newest report internally either way; the fold must NEVER
247
- // mutate an object the store already received.
240
+ const prev = key === null ? undefined : this.seen.get(key)
241
+ // A first all-zero report carries no traffic. A later zero report may
242
+ // replace a nonzero sample, so it must emit a negative correction.
243
+ if (!prev && sample.inputTokens + sample.outputTokens + sample.cacheReadTokens + sample.cacheWriteTokens <= 0) return null
244
+ if (key === null) return sample
248
245
  this.seen.set(key, sample)
249
246
  if (prev) {
250
- // Duplicate report for a known (turn, step): swallow it. The shipped
251
- // adapters emit the identical TokenUsage on the streaming chunk and
252
- // the final message (llm-deepseek yields one `pendingUsage` at DONE;
253
- // llm-pi-ai only on done/error), so first == last observationally.
254
- // ponytail: true last-wins would need store-side delta corrections
255
- // keyed by (session, turn, step); add them only if an adapter ever
256
- // reports differing values for one call.
257
- return null
247
+ const correction: UsageSample = {
248
+ day: prev.day,
249
+ hour: prev.hour,
250
+ model: prev.model,
251
+ inputTokens: sample.inputTokens - prev.inputTokens,
252
+ outputTokens: sample.outputTokens - prev.outputTokens,
253
+ cacheReadTokens: sample.cacheReadTokens - prev.cacheReadTokens,
254
+ cacheWriteTokens: sample.cacheWriteTokens - prev.cacheWriteTokens,
255
+ }
256
+ // Keep the original bucket identity across cumulative samples.
257
+ sample.day = prev.day
258
+ sample.hour = prev.hour
259
+ sample.model = prev.model
260
+ if (correction.inputTokens === 0 && correction.outputTokens === 0 && correction.cacheReadTokens === 0 && correction.cacheWriteTokens === 0) return null
261
+ return correction
258
262
  }
259
- // First emission: hand the store its own copy. The internal object stays
260
- // fold-owned — mutating it on a later duplicate would rewrite the sample
261
- // already sitting in the store's write path through the shared reference.
262
- return { ...sample }
263
+ // The collector fills in model attribution on this returned copy. Retain
264
+ // the same identity privately for any correction, without ever mutating
265
+ // a sample already handed to the store.
266
+ const emitted = { ...sample }
267
+ this.seen.set(key, emitted)
268
+ return emitted
263
269
  }
264
270
 
265
271
  /**
@@ -150,6 +150,16 @@ export interface UsageSessionPersistence {
150
150
  export interface UsageLlmService {
151
151
  resolveModelInfo?(provider: string, model: string, signal?: AbortSignal): Promise<{ name: string }>
152
152
  listModels?(provider: string): Promise<readonly { id: string; name: string }[]>
153
+ /** The provider catalog: route id -> the display name the model selector
154
+ * shows. Synchronous on every host line this plugin supports (the llm
155
+ * runtime's own `listProviders`, mirrored by the dsh-llm service). Optional:
156
+ * a host without it leaves every ranked provider row labelled by its raw
157
+ * historical route id. */
158
+ listProviders?(): readonly { id: string; name: string }[]
159
+ /** The configured-provider view (the models page's own list), whose
160
+ * `displayName` is the same label. Read as a fallback for a route that
161
+ * `listProviders` omits. */
162
+ listConfigurableProviders?(): readonly { provider: string; displayName: string }[]
153
163
  }
154
164
 
155
165
  /** The route a session's log records for its model calls (mirror of
@@ -168,8 +178,13 @@ export interface UsageSessionHandle {
168
178
  id: string
169
179
  cwd?: string
170
180
  createdAt?: number
171
- /** The parent session a subagent session was delegated from (mirror of the
172
- * SessionHeader field; absent on a root session). */
181
+ /** Modern hosts retain creation metadata on Session.header, not as getters. */
182
+ header?: UsageSessionHeader
183
+ /** Exact fork prefix, distinct from the resume/construction seed length. */
184
+ inheritedEventCount?: number
185
+ /** Immutable current live log; avoids the persistence backend's batching lag. */
186
+ snapshotEvents?(): readonly UsageSessionEvent[]
187
+ /** Legacy direct parent; modern hosts expose header.parentSession instead. */
173
188
  parentSession?: string
174
189
  /** 0 for a root session; the delegation depth for a subagent. */
175
190
  delegationDepth?: number
@@ -26,6 +26,40 @@ async function lookup<T>(read: (signal: AbortSignal) => Promise<T>, timeoutMs: n
26
26
  }
27
27
  }
28
28
 
29
+ /** Resolve provider route ids to the labels the model selector shows. Both
30
+ * catalogs are presentational and synchronous on the host runtime, so this is
31
+ * a plain in-process read rather than a per-request lookup: a missing service,
32
+ * a throwing adapter, or an unexpected promise all resolve to an empty map and
33
+ * every surface falls back to the raw historical route id. */
34
+ export function resolveProviderNames(llm?: UsageLlmService): Record<string, string> {
35
+ const names: Record<string, string> = Object.create(null)
36
+ if (!llm) return names
37
+ try {
38
+ for (const provider of catalog(llm.listProviders?.())) {
39
+ const name = provider.name?.trim()
40
+ if (provider.id && name) names[provider.id] = name
41
+ }
42
+ } catch {
43
+ // Metadata is auxiliary: a broken catalog must never fail the range query.
44
+ }
45
+ try {
46
+ for (const entry of catalog(llm.listConfigurableProviders?.())) {
47
+ const name = entry.displayName?.trim()
48
+ if (entry.provider && name && names[entry.provider] === undefined) names[entry.provider] = name
49
+ }
50
+ } catch {
51
+ // Same contract as above.
52
+ }
53
+ return names
54
+ }
55
+
56
+ /** Both catalogs are declared synchronous, but a host that publishes them over
57
+ * a remote face would hand back a promise; treat anything that is not an array
58
+ * as "no metadata" instead of throwing mid-query. */
59
+ function catalog<T>(value: readonly T[] | undefined): readonly T[] {
60
+ return Array.isArray(value) ? value : []
61
+ }
62
+
29
63
  /** Resolve each distinct route once per range query. Unknown/deleted providers
30
64
  * fall back to their bare historical model names in aggregateSamples(). */
31
65
  export async function resolveModelNames(
package/src/query.ts CHANGED
@@ -123,6 +123,12 @@ export function aggregateSamples(
123
123
  // tracked alongside the token volume.
124
124
  const modelCache = new Map<string, { hit: number; miss: number }>()
125
125
  const providerCache = new Map<string, { hit: number; miss: number }>()
126
+ // Cross-dimension splits: the two ranked lists render as side-by-side cards
127
+ // whose rows explain themselves on hover (which providers served this model,
128
+ // which models this provider served), so both directions are tracked here
129
+ // in the same pass rather than by re-walking the samples per row.
130
+ const modelProviderTotals = new Map<string, Map<string, number>>()
131
+ const providerModelTotals = new Map<string, Map<string, number>>()
126
132
  const active = new Set<string>()
127
133
  // Per-bucket accumulation (the reasonix dayTotals): the stacked trend chart
128
134
  // reads the bucket's byModel / byProvider, so every token-bearing sample
@@ -178,6 +184,18 @@ export function aggregateSamples(
178
184
  modelProviders.set(model, sources)
179
185
  modelTotals.set(model, (modelTotals.get(model) ?? 0) + total)
180
186
  providerTotals.set(provider, (providerTotals.get(provider) ?? 0) + total)
187
+ let modelSplitMap = modelProviderTotals.get(model)
188
+ if (!modelSplitMap) {
189
+ modelSplitMap = new Map()
190
+ modelProviderTotals.set(model, modelSplitMap)
191
+ }
192
+ modelSplitMap.set(provider, (modelSplitMap.get(provider) ?? 0) + total)
193
+ let providerSplitMap = providerModelTotals.get(provider)
194
+ if (!providerSplitMap) {
195
+ providerSplitMap = new Map()
196
+ providerModelTotals.set(provider, providerSplitMap)
197
+ }
198
+ providerSplitMap.set(model, (providerSplitMap.get(model) ?? 0) + total)
181
199
  const mc = modelCache.get(model) ?? { hit: 0, miss: 0 }
182
200
  mc.hit += sample.cacheReadTokens
183
201
  mc.miss += missSide
@@ -228,8 +246,8 @@ export function aggregateSamples(
228
246
  })
229
247
  }
230
248
 
231
- out.models = modelsSorted(modelTotals, modelCache, modelProviders)
232
- out.providers = providersSorted(providerTotals, providerCache)
249
+ out.models = modelsSorted(modelTotals, modelCache, modelProviders, modelProviderTotals)
250
+ out.providers = providersSorted(providerTotals, providerCache, providerModelTotals)
233
251
  out.topModel = out.models[0]?.model ?? ''
234
252
  out.topProvider = out.providers[0]?.provider ?? ''
235
253
  if (out.tokens > 0) {
@@ -243,23 +261,61 @@ function modelsSorted(
243
261
  totals: Map<string, number>,
244
262
  cache: Map<string, { hit: number; miss: number }>,
245
263
  sources: Map<string, Set<string>>,
264
+ splits: Map<string, Map<string, number>>,
246
265
  ): ModelTokenUsage[] {
247
266
  const out: ModelTokenUsage[] = []
248
267
  for (const [model, tokens] of totals) {
249
268
  const c = cache.get(model)
250
269
  const providers = [...(sources.get(model) ?? [])].sort()
251
- out.push({ model, provider: providers.length === 1 ? providers[0]! : '', providers, tokens, percent: 0, cacheHit: c?.hit ?? 0, cacheMiss: c?.miss ?? 0 })
270
+ out.push({
271
+ model,
272
+ provider: providers.length === 1 ? providers[0]! : '',
273
+ providers,
274
+ providerTokens: providerSplit(splits.get(model)),
275
+ tokens,
276
+ percent: 0,
277
+ cacheHit: c?.hit ?? 0,
278
+ cacheMiss: c?.miss ?? 0,
279
+ })
252
280
  }
253
281
  out.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
254
282
  return out
255
283
  }
256
284
 
257
- function providersSorted(totals: Map<string, number>, cache: Map<string, { hit: number; miss: number }>): ProviderTokenUsage[] {
285
+ function providersSorted(
286
+ totals: Map<string, number>,
287
+ cache: Map<string, { hit: number; miss: number }>,
288
+ splits: Map<string, Map<string, number>>,
289
+ ): ProviderTokenUsage[] {
258
290
  const out: ProviderTokenUsage[] = []
259
291
  for (const [provider, tokens] of totals) {
260
292
  const c = cache.get(provider)
261
- out.push({ provider, tokens, percent: 0, cacheHit: c?.hit ?? 0, cacheMiss: c?.miss ?? 0 })
293
+ out.push({
294
+ provider,
295
+ tokens,
296
+ percent: 0,
297
+ cacheHit: c?.hit ?? 0,
298
+ cacheMiss: c?.miss ?? 0,
299
+ modelTokens: modelSplit(splits.get(provider)),
300
+ })
262
301
  }
263
302
  out.sort((a, b) => b.tokens - a.tokens)
264
303
  return out
265
304
  }
305
+
306
+ /** One model's per-provider split, largest first (ties by id for a stable
307
+ * order across refreshes). Empty when the response carries no cross data. */
308
+ function providerSplit(split: Map<string, number> | undefined): Array<{ provider: string; tokens: number }> {
309
+ if (split === undefined) return []
310
+ return [...split]
311
+ .map(([provider, tokens]) => ({ provider, tokens }))
312
+ .sort((a, b) => b.tokens - a.tokens || a.provider.localeCompare(b.provider))
313
+ }
314
+
315
+ /** One provider's per-model split, largest first (ties by name). */
316
+ function modelSplit(split: Map<string, number> | undefined): Array<{ model: string; tokens: number }> {
317
+ if (split === undefined) return []
318
+ return [...split]
319
+ .map(([model, tokens]) => ({ model, tokens }))
320
+ .sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
321
+ }
package/src/routes.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import type { Context, UsageLlmService } from './context-types.ts'
15
- import { resolveModelNames } from './model-names.ts'
15
+ import { resolveModelNames, resolveProviderNames } from './model-names.ts'
16
16
  import type { UsageHttpRequest, UsageHttpResponse, UsageWebRoute } from './context-types.ts'
17
17
  import type { UsageStore, UsageDayRow, UsageHourRow } from './store.ts'
18
18
  import type { UsageCollector } from './collector.ts'
@@ -167,7 +167,19 @@ export async function aggregateRange(
167
167
  }
168
168
  }
169
169
  })()
170
- return aggregateSamples(samples, { from, to }, granularity)
170
+ const stats = aggregateSamples(samples, { from, to }, granularity)
171
+ // Provider labels ride along with the aggregate: the ranked provider card
172
+ // shows the selector's display name, and the hover detail names the models
173
+ // underneath it. Only providers actually present in the range are included,
174
+ // so the payload stays proportional to the ranking it decorates.
175
+ const resolved = resolveProviderNames(llm)
176
+ const providerNames: Record<string, string> = Object.create(null)
177
+ for (const provider of stats.providers) {
178
+ const label = resolved[provider.provider]
179
+ if (label !== undefined) providerNames[provider.provider] = label
180
+ }
181
+ if (Object.keys(providerNames).length > 0) stats.providerNames = providerNames
182
+ return stats
171
183
  }
172
184
 
173
185
  /** Build the single fenced prefix route serving the /token-total/api JSON RPC.