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.
- package/README.md +3 -3
- package/lib/client.js +273 -147
- package/lib/index.js +159 -43
- package/lib/types/client/session-usage-core.d.ts +10 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +8 -9
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/wire.d.ts +23 -1
- package/package.json +1 -1
- package/src/client/StatsLineEnhanced.tsx +15 -33
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +199 -53
- package/src/client/session-usage-core.ts +58 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +57 -51
- package/src/context-types.ts +17 -2
- package/src/model-names.ts +34 -0
- package/src/query.ts +61 -5
- package/src/routes.ts +14 -2
- package/src/session-usage.ts +50 -38
- package/src/wire.ts +17 -1
|
@@ -1,115 +1,62 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
|
14
|
+
/** Log-based main + subagents. The pill substitutes the live main projection. */
|
|
68
15
|
totals: SessionUsageTotals | null
|
|
69
16
|
}
|
|
70
17
|
|
|
71
|
-
|
|
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 [
|
|
88
|
-
const
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
50
|
+
active = false
|
|
51
|
+
refresh.current = null
|
|
52
|
+
clearInterval(handle)
|
|
111
53
|
}
|
|
112
|
-
}, [sessionId
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
//
|
|
198
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
//
|
|
260
|
-
//
|
|
261
|
-
// already
|
|
262
|
-
|
|
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
|
/**
|
package/src/context-types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
172
|
-
|
|
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
|
package/src/model-names.ts
CHANGED
|
@@ -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({
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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.
|