dsh-token-total 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -2
- package/lib/client.js +1318 -176
- package/lib/index.js +434 -4
- package/lib/types/client/SessionUsageView.d.ts +14 -0
- package/lib/types/client/api.d.ts +3 -1
- package/lib/types/client/format.d.ts +9 -5
- package/lib/types/client/index.d.ts +2 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-locales.d.ts +62 -0
- package/lib/types/client/use-session-detail.d.ts +8 -0
- package/lib/types/routes.d.ts +2 -2
- package/lib/types/session-detail.d.ts +3 -0
- package/lib/types/wire.d.ts +55 -0
- package/package.json +1 -1
- package/src/client/SessionUsageView.module.css +117 -0
- package/src/client/SessionUsageView.tsx +217 -0
- package/src/client/TokenTotalPanel.module.css +259 -99
- package/src/client/TokenTotalPanel.tsx +210 -63
- package/src/client/api.ts +8 -2
- package/src/client/format.ts +13 -9
- package/src/client/index.tsx +20 -1
- package/src/client/locales.ts +76 -0
- package/src/client/session-usage-locales.ts +50 -0
- package/src/client/use-session-detail.ts +50 -0
- package/src/query.ts +53 -2
- package/src/routes.ts +11 -2
- package/src/session-detail.ts +182 -0
- package/src/wire.ts +56 -0
package/src/client/locales.ts
CHANGED
|
@@ -43,6 +43,25 @@ export type TokenTotalKey =
|
|
|
43
43
|
| 'heatmap'
|
|
44
44
|
| 'heatLess'
|
|
45
45
|
| 'heatMore'
|
|
46
|
+
| 'dow.mon'
|
|
47
|
+
| 'dow.tue'
|
|
48
|
+
| 'dow.wed'
|
|
49
|
+
| 'dow.thu'
|
|
50
|
+
| 'dow.fri'
|
|
51
|
+
| 'dow.sat'
|
|
52
|
+
| 'dow.sun'
|
|
53
|
+
| 'month.jan'
|
|
54
|
+
| 'month.feb'
|
|
55
|
+
| 'month.mar'
|
|
56
|
+
| 'month.apr'
|
|
57
|
+
| 'month.may'
|
|
58
|
+
| 'month.jun'
|
|
59
|
+
| 'month.jul'
|
|
60
|
+
| 'month.aug'
|
|
61
|
+
| 'month.sep'
|
|
62
|
+
| 'month.oct'
|
|
63
|
+
| 'month.nov'
|
|
64
|
+
| 'month.dec'
|
|
46
65
|
| 'dailyTrend'
|
|
47
66
|
| 'hourlyTrend'
|
|
48
67
|
| 'trendLimited'
|
|
@@ -112,6 +131,25 @@ export const en: Record<TokenTotalKey, string> = {
|
|
|
112
131
|
heatmap: 'Activity heatmap',
|
|
113
132
|
heatLess: 'Less',
|
|
114
133
|
heatMore: 'More',
|
|
134
|
+
'dow.mon': 'Mon',
|
|
135
|
+
'dow.tue': 'Tue',
|
|
136
|
+
'dow.wed': 'Wed',
|
|
137
|
+
'dow.thu': 'Thu',
|
|
138
|
+
'dow.fri': 'Fri',
|
|
139
|
+
'dow.sat': 'Sat',
|
|
140
|
+
'dow.sun': 'Sun',
|
|
141
|
+
'month.jan': 'Jan',
|
|
142
|
+
'month.feb': 'Feb',
|
|
143
|
+
'month.mar': 'Mar',
|
|
144
|
+
'month.apr': 'Apr',
|
|
145
|
+
'month.may': 'May',
|
|
146
|
+
'month.jun': 'Jun',
|
|
147
|
+
'month.jul': 'Jul',
|
|
148
|
+
'month.aug': 'Aug',
|
|
149
|
+
'month.sep': 'Sep',
|
|
150
|
+
'month.oct': 'Oct',
|
|
151
|
+
'month.nov': 'Nov',
|
|
152
|
+
'month.dec': 'Dec',
|
|
115
153
|
dailyTrend: 'Daily token trend',
|
|
116
154
|
hourlyTrend: 'Hourly token trend',
|
|
117
155
|
trendLimited: 'Showing the latest {n} days',
|
|
@@ -182,6 +220,25 @@ export const zh: Record<TokenTotalKey, string> = {
|
|
|
182
220
|
heatmap: '活跃热力图',
|
|
183
221
|
heatLess: '较少',
|
|
184
222
|
heatMore: '较多',
|
|
223
|
+
'dow.mon': '周一',
|
|
224
|
+
'dow.tue': '周二',
|
|
225
|
+
'dow.wed': '周三',
|
|
226
|
+
'dow.thu': '周四',
|
|
227
|
+
'dow.fri': '周五',
|
|
228
|
+
'dow.sat': '周六',
|
|
229
|
+
'dow.sun': '周日',
|
|
230
|
+
'month.jan': '1月',
|
|
231
|
+
'month.feb': '2月',
|
|
232
|
+
'month.mar': '3月',
|
|
233
|
+
'month.apr': '4月',
|
|
234
|
+
'month.may': '5月',
|
|
235
|
+
'month.jun': '6月',
|
|
236
|
+
'month.jul': '7月',
|
|
237
|
+
'month.aug': '8月',
|
|
238
|
+
'month.sep': '9月',
|
|
239
|
+
'month.oct': '10月',
|
|
240
|
+
'month.nov': '11月',
|
|
241
|
+
'month.dec': '12月',
|
|
185
242
|
dailyTrend: '按天 Token 趋势',
|
|
186
243
|
hourlyTrend: '按小时 Token 趋势',
|
|
187
244
|
trendLimited: '仅显示最近 {n} 天',
|
|
@@ -252,6 +309,25 @@ export const zhTW: Record<TokenTotalKey, string> = {
|
|
|
252
309
|
heatmap: '活躍熱力圖',
|
|
253
310
|
heatLess: '較少',
|
|
254
311
|
heatMore: '較多',
|
|
312
|
+
'dow.mon': '週一',
|
|
313
|
+
'dow.tue': '週二',
|
|
314
|
+
'dow.wed': '週三',
|
|
315
|
+
'dow.thu': '週四',
|
|
316
|
+
'dow.fri': '週五',
|
|
317
|
+
'dow.sat': '週六',
|
|
318
|
+
'dow.sun': '週日',
|
|
319
|
+
'month.jan': '1月',
|
|
320
|
+
'month.feb': '2月',
|
|
321
|
+
'month.mar': '3月',
|
|
322
|
+
'month.apr': '4月',
|
|
323
|
+
'month.may': '5月',
|
|
324
|
+
'month.jun': '6月',
|
|
325
|
+
'month.jul': '7月',
|
|
326
|
+
'month.aug': '8月',
|
|
327
|
+
'month.sep': '9月',
|
|
328
|
+
'month.oct': '10月',
|
|
329
|
+
'month.nov': '11月',
|
|
330
|
+
'month.dec': '12月',
|
|
255
331
|
dailyTrend: '每日 Token 趨勢',
|
|
256
332
|
hourlyTrend: '每小時 Token 趨勢',
|
|
257
333
|
trendLimited: '僅顯示最近 {n} 天',
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Session Usage view translations, independent of the global dashboard. */
|
|
2
|
+
export const SESSION_USAGE_NS = 'sessionUsage'
|
|
3
|
+
export const usageZh = {
|
|
4
|
+
tab: '用量', requests: '请求数', tokens: '总 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
|
|
5
|
+
input: '入', output: '出', read: '读', write: '写',
|
|
6
|
+
requestsCount: '请求数量', inputFull: '输入', outputFull: '输出', readFull: '缓存读取', writeFull: '缓存写入',
|
|
7
|
+
cacheHit: '缓存命中', compaction: '压缩调用', reqTimes: '个请求',
|
|
8
|
+
children: '子代理', session: '会话', models: '按模型', model: '模型',
|
|
9
|
+
records: '请求记录', expand: '全部展开', collapse: '全部收起', loading: '正在读取会话用量…',
|
|
10
|
+
empty: '本会话还没有请求记录', noChildren: '暂无子代理', noModels: '暂无模型用量',
|
|
11
|
+
scope: '本会话及所有子代理 · 历史日志累计',
|
|
12
|
+
note: '统计包含本会话与全部后代的独立请求、重试和压缩调用,不重复计算继承历史。首 token 与速度仅使用已记录的时间信息,缺失时显示 —。',
|
|
13
|
+
failedLoad: '用量读取失败,已保留上次结果。', retry: '重新读取', completed: '已完成', running: '进行中', failed: '失败', interrupted: '已中断',
|
|
14
|
+
main: '主会话', turn: '轮', step: '步', started: '开始', duration: '耗时', firstDetail: '首 token',
|
|
15
|
+
provider: '供应商', status: '状态', exact: '完整 token 数',
|
|
16
|
+
answering: '正在回答...', firstWait: '首字耗时', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
17
|
+
cacheShort: '缓存', avgCache: '平均缓存', avgRate: '平均速率', inProgress: '个进行中',
|
|
18
|
+
}
|
|
19
|
+
export type SessionUsageKey = keyof typeof usageZh
|
|
20
|
+
export const usageEn: Record<SessionUsageKey, string> = {
|
|
21
|
+
tab: 'Usage', requests: 'Requests', tokens: 'Total tokens', hit: 'Cache hit rate', first: 'Avg. first token', speed: 'Token speed',
|
|
22
|
+
input: 'In', output: 'Out', read: 'Read', write: 'Write',
|
|
23
|
+
requestsCount: 'Requests', inputFull: 'Input', outputFull: 'Output', readFull: 'Cache read', writeFull: 'Cache write',
|
|
24
|
+
cacheHit: 'cache hits', compaction: 'Compaction', reqTimes: 'requests',
|
|
25
|
+
children: 'Subagents', session: 'Session', models: 'By model', model: 'Model',
|
|
26
|
+
records: 'Request log', expand: 'Expand all', collapse: 'Collapse all', loading: 'Reading session usage…',
|
|
27
|
+
empty: 'No requests in this session yet', noChildren: 'No subagents yet', noModels: 'No model usage yet',
|
|
28
|
+
scope: 'This session and all subagents · Historical log totals',
|
|
29
|
+
note: 'Includes independent requests, retries and compaction calls from this session and all descendants, without counting inherited history twice. Timing uses recorded timestamps only; missing values appear as —.',
|
|
30
|
+
failedLoad: 'Could not read usage. The last successful result is preserved.', retry: 'Retry', completed: 'Completed', running: 'Running', failed: 'Failed', interrupted: 'Interrupted',
|
|
31
|
+
main: 'Main session', turn: 'Turn', step: 'Step', started: 'Started', duration: 'Duration', firstDetail: 'First token',
|
|
32
|
+
provider: 'Provider', status: 'Status', exact: 'Exact token count',
|
|
33
|
+
answering: 'answering...', firstWait: 'first token', sec: 's', tokenWord: 'token', perSec: 'token/s',
|
|
34
|
+
cacheShort: 'cache', avgCache: 'avg cache', avgRate: 'avg rate', inProgress: 'in progress',
|
|
35
|
+
}
|
|
36
|
+
export const usageZhTW: Record<SessionUsageKey, string> = {
|
|
37
|
+
...usageZh,
|
|
38
|
+
tab: '用量', requests: '請求數', tokens: '總 tokens', hit: '命中率', first: '平均首 token', speed: 'token 速度',
|
|
39
|
+
input: '入', output: '出', read: '讀', write: '寫',
|
|
40
|
+
requestsCount: '請求數量', inputFull: '輸入', outputFull: '輸出', readFull: '快取讀取', writeFull: '快取寫入',
|
|
41
|
+
cacheHit: '快取命中', compaction: '壓縮呼叫', reqTimes: '個請求',
|
|
42
|
+
children: '子代理', session: '會話', models: '按模型', records: '請求記錄', expand: '全部展開', collapse: '全部收起',
|
|
43
|
+
loading: '正在讀取會話用量…', empty: '本會話還沒有請求記錄', noChildren: '暫無子代理', noModels: '暫無模型用量',
|
|
44
|
+
scope: '本會話及所有子代理 · 歷史日誌累計',
|
|
45
|
+
note: '統計包含本會話與全部後代的獨立請求、重試和壓縮呼叫,不重複計算繼承歷史。首 token 與速度僅使用已記錄的時間資訊,缺失時顯示 —。',
|
|
46
|
+
failedLoad: '用量讀取失敗,已保留上次結果。', retry: '重新讀取', completed: '已完成', running: '進行中', failed: '失敗', interrupted: '已中斷',
|
|
47
|
+
main: '主會話', turn: '輪', step: '步', started: '開始', duration: '耗時', firstDetail: '首 token', provider: '供應商', status: '狀態', exact: '完整 token 數',
|
|
48
|
+
answering: '正在回答...', firstWait: '首字耗時', sec: '秒', tokenWord: 'token', perSec: 'token/秒',
|
|
49
|
+
cacheShort: '快取', avgCache: '平均快取', avgRate: '平均速率', inProgress: '個進行中',
|
|
50
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/** Serialized polling for one visible Usage view, never leaking another
|
|
2
|
+
* session's answer. Abort and stop all timers when the tab unmounts. */
|
|
3
|
+
import { useEffect, useRef, useState } from 'react'
|
|
4
|
+
import type { SessionUsageDetail } from '../wire.ts'
|
|
5
|
+
import { fetchSessionDetail } from './api.ts'
|
|
6
|
+
|
|
7
|
+
export const DETAIL_POLL_MS = 1_500
|
|
8
|
+
|
|
9
|
+
export function useSessionDetail(sessionId: string): {
|
|
10
|
+
detail: SessionUsageDetail | null
|
|
11
|
+
loading: boolean
|
|
12
|
+
error: boolean
|
|
13
|
+
retry: () => void
|
|
14
|
+
} {
|
|
15
|
+
const [reading, setReading] = useState<{ sessionId: string; detail: SessionUsageDetail | null; error: boolean } | null>(null)
|
|
16
|
+
const refresh = useRef<(() => void) | null>(null)
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let active = true
|
|
19
|
+
let pending = false
|
|
20
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
21
|
+
let controller: AbortController | undefined
|
|
22
|
+
const request = (): void => {
|
|
23
|
+
if (!active || pending || !sessionId) return
|
|
24
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
25
|
+
pending = true
|
|
26
|
+
controller = new AbortController()
|
|
27
|
+
void fetchSessionDetail(sessionId, controller.signal).then(detail => {
|
|
28
|
+
if (active) setReading({ sessionId, detail, error: false })
|
|
29
|
+
}).catch(() => {
|
|
30
|
+
if (active) setReading(previous => ({ sessionId, detail: previous?.sessionId === sessionId ? previous.detail : null, error: true }))
|
|
31
|
+
}).finally(() => {
|
|
32
|
+
pending = false
|
|
33
|
+
if (active) timer = setTimeout(request, DETAIL_POLL_MS)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
const wake = (): void => { if (document.visibilityState === 'visible') request() }
|
|
37
|
+
refresh.current = request
|
|
38
|
+
document.addEventListener('visibilitychange', wake)
|
|
39
|
+
request()
|
|
40
|
+
return () => {
|
|
41
|
+
active = false
|
|
42
|
+
refresh.current = null
|
|
43
|
+
controller?.abort()
|
|
44
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
45
|
+
document.removeEventListener('visibilitychange', wake)
|
|
46
|
+
}
|
|
47
|
+
}, [sessionId])
|
|
48
|
+
const current = reading?.sessionId === sessionId ? reading : null
|
|
49
|
+
return { detail: current?.detail ?? null, loading: current === null, error: current?.error ?? false, retry: () => refresh.current?.() }
|
|
50
|
+
}
|
package/src/query.ts
CHANGED
|
@@ -104,6 +104,9 @@ export function aggregateSamples(
|
|
|
104
104
|
const from = filter.from
|
|
105
105
|
const to = filter.to
|
|
106
106
|
const buckets = bucketsInRange(from, to, granularity)
|
|
107
|
+
// Materialize once: the qualified-name pre-pass below and the main fold
|
|
108
|
+
// both walk the samples, and a generator input may only be consumed once.
|
|
109
|
+
const all = [...samples]
|
|
107
110
|
const out: TokenTotalRange = {
|
|
108
111
|
from,
|
|
109
112
|
to,
|
|
@@ -151,7 +154,11 @@ export function aggregateSamples(
|
|
|
151
154
|
const bucketCacheHit = new Map<string, number>()
|
|
152
155
|
const bucketCacheMiss = new Map<string, number>()
|
|
153
156
|
|
|
154
|
-
|
|
157
|
+
// One model, one row: every qualified routing name folds onto its bare
|
|
158
|
+
// model key before the first aggregation (see aliasQualifiedModels).
|
|
159
|
+
const modelAlias = aliasQualifiedModels(new Set(all.map((sample) => modelKeyOf(sample))))
|
|
160
|
+
|
|
161
|
+
for (const sample of all) {
|
|
155
162
|
const bucket = bucketOf(sample, from, to, granularity)
|
|
156
163
|
if (bucket === null) continue
|
|
157
164
|
if (sample.turn) {
|
|
@@ -192,7 +199,8 @@ export function aggregateSamples(
|
|
|
192
199
|
out.cacheMiss += missSide
|
|
193
200
|
const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
|
|
194
201
|
const provider = providerOf(modelRef)
|
|
195
|
-
const
|
|
202
|
+
const modelBase = modelKeyOf(sample)
|
|
203
|
+
const model = modelAlias.get(modelBase) ?? modelBase
|
|
196
204
|
const sources = modelProviders.get(model) ?? new Set<string>()
|
|
197
205
|
sources.add(provider)
|
|
198
206
|
modelProviders.set(model, sources)
|
|
@@ -272,6 +280,49 @@ export function aggregateSamples(
|
|
|
272
280
|
return out
|
|
273
281
|
}
|
|
274
282
|
|
|
283
|
+
/** The aggregation key of one sample: the resolved display name when the
|
|
284
|
+
* host resolved one, else the bare model ref — normalized like every
|
|
285
|
+
* display name so the key is stable across routes and refreshes. */
|
|
286
|
+
function modelKeyOf(sample: UsageSample): string {
|
|
287
|
+
const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
|
|
288
|
+
return normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef))
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Qualified-name aliases: "workbuddy-ai-plugin/deepseek-v4.1-flash" and
|
|
293
|
+
* "deepseek-v4.1-flash" are the same model reached through a routing
|
|
294
|
+
* namespace, so a name containing "/" merges onto the LONGEST suffix after a
|
|
295
|
+
* "/" that is itself a model key in this dataset (chains like a/b/c → b/c → c
|
|
296
|
+
* resolve transitively). The merge is dataset-relative on purpose: a slash
|
|
297
|
+
* that names no bare model in the data ("GPT/6.1 sol" beside "gpt-6.1-sol")
|
|
298
|
+
* is a genuinely different name and stays its own row — see the
|
|
299
|
+
* distinct-slashes case in tests/model-aggregation.test.mjs.
|
|
300
|
+
*/
|
|
301
|
+
function aliasQualifiedModels(names: Set<string>): Map<string, string> {
|
|
302
|
+
const alias = new Map<string, string>()
|
|
303
|
+
for (const name of names) {
|
|
304
|
+
if (!name.includes('/')) continue
|
|
305
|
+
const parts = name.split('/')
|
|
306
|
+
for (let i = 1; i < parts.length; i++) {
|
|
307
|
+
const suffix = parts.slice(i).join('/')
|
|
308
|
+
if (names.has(suffix)) {
|
|
309
|
+
alias.set(name, suffix)
|
|
310
|
+
break
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const name of [...alias.keys()]) {
|
|
315
|
+
let target = alias.get(name)!
|
|
316
|
+
const seen = new Set([name])
|
|
317
|
+
while (alias.has(target) && !seen.has(target)) {
|
|
318
|
+
seen.add(target)
|
|
319
|
+
target = alias.get(target)!
|
|
320
|
+
}
|
|
321
|
+
alias.set(name, target)
|
|
322
|
+
}
|
|
323
|
+
return alias
|
|
324
|
+
}
|
|
325
|
+
|
|
275
326
|
function modelsSorted(
|
|
276
327
|
totals: Map<string, number>,
|
|
277
328
|
cache: Map<string, { hit: number; miss: number }>,
|
package/src/routes.ts
CHANGED
|
@@ -18,7 +18,8 @@ import type { UsageStore, UsageDayRow, UsageHourRow } from './store.ts'
|
|
|
18
18
|
import type { UsageCollector } from './collector.ts'
|
|
19
19
|
import { aggregateSamples } from './query.ts'
|
|
20
20
|
import type { Granularity, UsageSample } from './query.ts'
|
|
21
|
-
import type { BackfillStatus, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts'
|
|
21
|
+
import type { BackfillStatus, SessionUsageDetail, SessionUsageRequest, TokenTotalRange, TokenTotalRequest } from './wire.ts'
|
|
22
|
+
import { buildSessionUsageDetail } from './session-detail.ts'
|
|
22
23
|
import { UsageError, readJsonBody, writeError, writeJson } from './wire.ts'
|
|
23
24
|
import type { SessionUsageService } from './session-usage.ts'
|
|
24
25
|
import { createTrustFence, type TrustFence, type TrustInputs } from './trust-fence.ts'
|
|
@@ -228,6 +229,14 @@ export function buildUsageRoute(ctx: Context, deps: RoutesDeps): UsageWebRoute {
|
|
|
228
229
|
writeJson(res, { ok: true, value })
|
|
229
230
|
return
|
|
230
231
|
}
|
|
232
|
+
if (method === 'POST' && path === '/token-total/api/session-detail') {
|
|
233
|
+
const body = (await readJsonBody(req)) as Partial<SessionUsageRequest>
|
|
234
|
+
const sessionId = typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
|
|
235
|
+
if (sessionId === '' || sessionId.length > 200) throw new UsageError(400, 'token total: session needs a valid sessionId')
|
|
236
|
+
const value = await buildSessionUsageDetail(deps.persistence(), deps.sessions(), sessionId, ctx)
|
|
237
|
+
writeJson(res, { ok: true, value })
|
|
238
|
+
return
|
|
239
|
+
}
|
|
231
240
|
if (method === 'POST' && path === '/token-total/api/status') {
|
|
232
241
|
writeJson(res, { ok: true, value: deps.collector.status })
|
|
233
242
|
return
|
|
@@ -260,4 +269,4 @@ export function buildUsageRoute(ctx: Context, deps: RoutesDeps): UsageWebRoute {
|
|
|
260
269
|
return { kind: 'prefix', path: '/token-total/api', handler }
|
|
261
270
|
}
|
|
262
271
|
|
|
263
|
-
export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest }
|
|
272
|
+
export type { BackfillStatus, TokenTotalRange, TokenTotalRequest, SessionUsageRequest, SessionUsageDetail }
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Context,
|
|
3
|
+
UsageSessionEvent,
|
|
4
|
+
UsageSessionHandle,
|
|
5
|
+
UsageSessionHeader,
|
|
6
|
+
UsageSessionPersistence,
|
|
7
|
+
UsageSessionSnapshot,
|
|
8
|
+
UsageSessionStore,
|
|
9
|
+
} from './context-types.ts'
|
|
10
|
+
import { UsageFold } from './collector.ts'
|
|
11
|
+
import { resolveModelNames, resolveProviderNames } from './model-names.ts'
|
|
12
|
+
import type { SessionUsageDetail, SessionUsageTotals } from './wire.ts'
|
|
13
|
+
|
|
14
|
+
const zero = (): SessionUsageTotals => ({ uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 })
|
|
15
|
+
function add(a: SessionUsageTotals, b: SessionUsageTotals): void {
|
|
16
|
+
a.uncachedInputTokens += b.uncachedInputTokens; a.outputTokens += b.outputTokens
|
|
17
|
+
a.cacheReadTokens += b.cacheReadTokens; a.cacheWriteTokens += b.cacheWriteTokens
|
|
18
|
+
}
|
|
19
|
+
function usageOf(x: any): SessionUsageTotals {
|
|
20
|
+
const u = x?.usage ?? x
|
|
21
|
+
return { uncachedInputTokens: Number(u?.inputTokens) || 0, outputTokens: Number(u?.outputTokens) || 0, cacheReadTokens: Number(u?.cacheReadTokens) || 0, cacheWriteTokens: Number(u?.cacheWriteTokens) || 0 }
|
|
22
|
+
}
|
|
23
|
+
function positive(u: SessionUsageTotals): boolean { return u.uncachedInputTokens + u.outputTokens + u.cacheReadTokens + u.cacheWriteTokens > 0 }
|
|
24
|
+
function routeOf(x: any): { provider: string; model: string } {
|
|
25
|
+
const source = x?.message?.source ?? x?.source ?? x?.request ?? x?.context ?? x
|
|
26
|
+
return { provider: typeof source?.provider === 'string' ? source.provider : '', model: typeof source?.model === 'string' ? source.model : '' }
|
|
27
|
+
}
|
|
28
|
+
function streamUsage(data: any): { usage?: SessionUsageTotals; firstTime?: number } {
|
|
29
|
+
let firstTime: number | undefined
|
|
30
|
+
let usage: SessionUsageTotals | undefined
|
|
31
|
+
for (const r of Array.isArray(data?.stream) ? data.stream : []) {
|
|
32
|
+
const t = typeof r?.time === 'number' ? r.time : undefined
|
|
33
|
+
if (firstTime === undefined && t !== undefined && (r?.type === 'chunk' || r?.chunk?.type === 'text' || r?.chunk?.type === 'content')) firstTime = t
|
|
34
|
+
if (r?.type === 'chunk' && r?.chunk?.type === 'usage') usage = usageOf(r.chunk)
|
|
35
|
+
}
|
|
36
|
+
return { usage, firstTime }
|
|
37
|
+
}
|
|
38
|
+
function headerOf(item: UsageSessionHeader | UsageSessionSnapshot): UsageSessionHeader | null {
|
|
39
|
+
const x: any = item
|
|
40
|
+
if (x?.header?.id && typeof x.header.id === 'string') return x.header
|
|
41
|
+
return typeof x?.id === 'string' && x.id ? x : null
|
|
42
|
+
}
|
|
43
|
+
async function read(p: UsageSessionPersistence, id: string, live?: UsageSessionHandle): Promise<{ events: readonly UsageSessionEvent[]; cut: number }> {
|
|
44
|
+
if (live?.snapshotEvents && typeof live.inheritedEventCount === 'number') return { events: live.snapshotEvents(), cut: Math.max(0, live.inheritedEventCount) }
|
|
45
|
+
if (p.open) {
|
|
46
|
+
const h = await p.open(id, 'read'); const out: UsageSessionEvent[] = []
|
|
47
|
+
try { for (let off = 0;;) { const page = await h.read(off, 500); if (!page.events.length) break; out.push(...page.events); off += page.events.length } }
|
|
48
|
+
finally { await h.close().catch(() => {}) }
|
|
49
|
+
return { events: out, cut: Math.max(0, Number(h.inheritedEventCount) || 0) }
|
|
50
|
+
}
|
|
51
|
+
if (p.inspect) { const x = await p.inspect(id); return { events: x.events, cut: Math.max(0, Number(x.inheritedEventCount) || 0) } }
|
|
52
|
+
throw new Error('no readable persistence seam (need open or inspect)')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type MutableRecord = SessionUsageDetail['records'][number] & { _attempt: number; _lastUsage: SessionUsageTotals }
|
|
56
|
+
function nameOf(h: UsageSessionHeader | undefined, id: string): string {
|
|
57
|
+
const x: any = h
|
|
58
|
+
if (typeof x?.name === 'string' && x.name) return x.name
|
|
59
|
+
if (typeof x?.title === 'string' && x.title) return x.title
|
|
60
|
+
if (typeof x?.cwd === 'string' && x.cwd) return x.cwd.split(/[\\/]/).filter(Boolean).pop() || id
|
|
61
|
+
return id
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function one(p: UsageSessionPersistence, id: string, header: UsageSessionHeader | undefined, live: UsageSessionHandle | undefined) {
|
|
65
|
+
const { events, cut } = await read(p, id, live)
|
|
66
|
+
const fold = new UsageFold(); const totals = zero(); const records: MutableRecord[] = []
|
|
67
|
+
const byKey = new Map<string, MutableRecord>(); let route = live?.requestContext?.() ?? {}
|
|
68
|
+
let ordinal = 0
|
|
69
|
+
// Per-turn display names come from the first real user prompt of each turn
|
|
70
|
+
// (other `user/message` kinds are runtime context / skill catalog injections);
|
|
71
|
+
// the prompt usually lands before `turn/start`, so buffer it until the turn opens.
|
|
72
|
+
let currentTurn = 0; let effort = ''; let pendingTopic = ''
|
|
73
|
+
const turnTopics = new Map<number, string>()
|
|
74
|
+
const own = events.filter(e => !(cut > 0 && typeof e.seq === 'number' && e.seq < cut))
|
|
75
|
+
// Real session names: a subagent carries its delegation label on its
|
|
76
|
+
// `subagent/descriptor` event ("完成日志明细后端"), every session can carry a
|
|
77
|
+
// generated `session/title`; only fall back to the header/cwd/id guesses.
|
|
78
|
+
let label = '', title = ''
|
|
79
|
+
for (const ev of own) {
|
|
80
|
+
const d: any = ev.data ?? {}
|
|
81
|
+
if (!label && ev.type === 'subagent/descriptor' && typeof d.label === 'string' && d.label.trim()) label = d.label.trim()
|
|
82
|
+
if (!title && ev.type === 'session/title' && typeof d.title === 'string' && d.title.trim()) title = d.title.trim()
|
|
83
|
+
if (label && title) break
|
|
84
|
+
}
|
|
85
|
+
const name = label || title || nameOf(header, id)
|
|
86
|
+
for (const ev of own) {
|
|
87
|
+
const d: any = ev.data ?? {}; const turn = Number.isFinite(d.turn) ? d.turn : 0; const step = Number.isFinite(d.step) ? d.step : 0
|
|
88
|
+
if (ev.type === 'request/context') { route = { ...route, ...routeOf(d) }; continue }
|
|
89
|
+
const r = routeOf(d); if (r.provider || r.model) route = { ...route, ...r }
|
|
90
|
+
if (ev.type === 'turn/start') {
|
|
91
|
+
currentTurn = turn
|
|
92
|
+
if (pendingTopic && !turnTopics.has(currentTurn)) { turnTopics.set(currentTurn, pendingTopic); pendingTopic = '' }
|
|
93
|
+
} else if (ev.type === 'user/message' && d.source?.kind === 'user') {
|
|
94
|
+
const block = (Array.isArray(d.content) ? d.content : []).find((b: any) => b?.type === 'text' && typeof b.text === 'string')
|
|
95
|
+
const text = String(block?.text ?? '').replace(/\s+/g, ' ').trim()
|
|
96
|
+
if (text) {
|
|
97
|
+
if (currentTurn > 0 && !turnTopics.has(currentTurn)) turnTopics.set(currentTurn, text)
|
|
98
|
+
else pendingTopic = text
|
|
99
|
+
}
|
|
100
|
+
} else if (ev.type === 'request/header') {
|
|
101
|
+
const e = d.header?.config?.reasoningEffort
|
|
102
|
+
if (typeof e === 'string' && e) {
|
|
103
|
+
effort = e
|
|
104
|
+
for (const x of records) if (x.status === 'running') x.effort = e
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let rec: MutableRecord | undefined
|
|
108
|
+
if (ev.type === 'step/start' || ev.type === 'llm/retry-started') {
|
|
109
|
+
const key = `${turn}:${step}`
|
|
110
|
+
if (ev.type === 'llm/retry-started' || !byKey.has(key)) {
|
|
111
|
+
const previous = byKey.get(key)
|
|
112
|
+
if (previous && ev.type === 'llm/retry-started' && previous.status === 'running') { previous.status = 'failed'; previous.finishedAt = ev.time }
|
|
113
|
+
rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: ev.type === 'llm/retry-started' ? 1 : 0, _lastUsage: zero() }
|
|
114
|
+
records.push(rec); byKey.set(key, rec)
|
|
115
|
+
}
|
|
116
|
+
} else if (ev.type === 'assistant/chunk' || ev.type === 'assistant/message' || ev.type === 'assistant/attempt') {
|
|
117
|
+
rec = byKey.get(`${turn}:${step}`)
|
|
118
|
+
if (!rec) { rec = { id: `${id}:${turn}:${step}:${ordinal++}`, sessionId: id, sessionName: name, turn, step, startedAt: ev.time, finishedAt: null, provider: r.provider || route.provider || '', model: r.model || route.model || '', status: 'running', totals: zero(), firstTokenMs: null, tokensPerSecond: null, turnName: '', effort, _attempt: 0, _lastUsage: zero() }; records.push(rec); byKey.set(`${turn}:${step}`, rec) }
|
|
119
|
+
if (r.provider) rec.provider = r.provider; if (r.model) rec.model = r.model
|
|
120
|
+
const su = streamUsage(d); const u = ev.type === 'assistant/message' ? (d.usage ? usageOf(d) : su.usage) : ev.type === 'assistant/chunk' ? (d.chunk?.type === 'usage' ? usageOf(d.chunk) : undefined) : (d.usage ? usageOf(d) : su.usage)
|
|
121
|
+
if (u) {
|
|
122
|
+
rec._lastUsage = u; rec.totals = u
|
|
123
|
+
if (positive(u) && rec.firstTokenMs === null) {
|
|
124
|
+
const rawFirst = su.firstTime ?? (typeof d.chunk?.time === 'number' ? d.chunk.time : ev.time)
|
|
125
|
+
rec.firstTokenMs = Math.max(0, rawFirst - rec.startedAt)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (ev.type !== 'assistant/chunk') { rec.finishedAt = ev.time; rec.status = ev.type === 'assistant/attempt' ? 'failed' : 'completed' }
|
|
129
|
+
const elapsedMs = rec.finishedAt === null ? null : Math.max(0, rec.finishedAt - rec.startedAt)
|
|
130
|
+
if (elapsedMs !== null && elapsedMs > 0 && rec.totals.outputTokens > 0) rec.tokensPerSecond = rec.totals.outputTokens / (elapsedMs / 1000)
|
|
131
|
+
} else if (ev.type === 'turn/end') {
|
|
132
|
+
const recs = records.filter(x => x.turn === turn && x.status === 'running'); for (const x of recs) { x.status = 'interrupted'; x.finishedAt = ev.time }
|
|
133
|
+
} else if (ev.type === 'compaction/summary') {
|
|
134
|
+
const u = usageOf(d); rec = { id: `${id}:compaction:${ordinal++}`, sessionId: id, sessionName: name, turn: 0, step: 0, startedAt: ev.time, finishedAt: ev.time, provider: typeof d.provider === 'string' ? d.provider : '', model: typeof d.model === 'string' ? d.model : '', status: 'completed', totals: u, firstTokenMs: null, tokensPerSecond: null, turnName: '', effort: '', _attempt: 0, _lastUsage: u }; records.push(rec)
|
|
135
|
+
}
|
|
136
|
+
const sample = fold.fold(ev); if (sample && !sample.turn) add(totals, { uncachedInputTokens: sample.inputTokens, outputTokens: sample.outputTokens, cacheReadTokens: sample.cacheReadTokens, cacheWriteTokens: sample.cacheWriteTokens })
|
|
137
|
+
}
|
|
138
|
+
// Records intentionally expose the final fold value; fold corrections can make
|
|
139
|
+
// a stream sample disappear, so reconcile aggregate totals from the records only
|
|
140
|
+
// for presentation while `totals` remains the authoritative UsageFold result.
|
|
141
|
+
const requests = records.length
|
|
142
|
+
const firsts = records.map(x => x.firstTokenMs).filter((x): x is number => x !== null)
|
|
143
|
+
const outputSeconds = records.reduce((n, x) => n + x.totals.outputTokens, 0)
|
|
144
|
+
const elapsed = records.reduce((n, x) => n + (x.firstTokenMs ?? 0), 0)
|
|
145
|
+
for (const x of records) { x.turnName = turnTopics.get(x.turn) ?? ''; delete (x as any)._attempt; delete (x as any)._lastUsage }
|
|
146
|
+
return { id, name, header, totals, records: records as SessionUsageDetail['records'], requests, averageFirstTokenMs: firsts.length ? firsts.reduce((a, b) => a + b, 0) / firsts.length : null, tokensPerSecond: elapsed > 0 ? outputSeconds / (elapsed / 1000) : null }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function buildSessionUsageDetail(persistence: UsageSessionPersistence, sessions: UsageSessionStore | undefined, rootId: string, ctx?: Context): Promise<SessionUsageDetail> {
|
|
150
|
+
const raw = await persistence.list(typeof persistence.open === 'function' ? undefined : undefined)
|
|
151
|
+
const headers = raw.map(headerOf).filter((x): x is UsageSessionHeader => x !== null)
|
|
152
|
+
const live = new Map((sessions?.list() ?? []).map(x => [x.id, x])); const children = new Map<string, string[]>()
|
|
153
|
+
for (const h of headers) if (h.parentSession) children.set(h.parentSession, [...(children.get(h.parentSession) ?? []), h.id])
|
|
154
|
+
for (const h of live.values()) { const p = h.header?.parentSession ?? h.parentSession; if (p) children.set(p, [...(children.get(p) ?? []).filter(x => x !== h.id), h.id]) }
|
|
155
|
+
const ids: string[] = []; const seen = new Set([rootId]); const q = [rootId]
|
|
156
|
+
while (q.length) for (const c of children.get(q.shift()!) ?? []) if (!seen.has(c)) { seen.add(c); ids.push(c); q.push(c) }
|
|
157
|
+
const all = [rootId, ...ids]; const parts = await Promise.all(all.map(id => one(persistence, id, headers.find(h => h.id === id), live.get(id))))
|
|
158
|
+
const root = parts[0]; const totals = zero(); const models = new Map<string, { totals: SessionUsageTotals; requests: number; providers: Set<string> }>(); const records = parts.flatMap(x => x.records); for (const p of parts) add(totals, p.totals)
|
|
159
|
+
for (const r of records) { const key = r.model || '(unknown)'; const m = models.get(key) ?? { totals: zero(), requests: 0, providers: new Set<string>() }; add(m.totals, r.totals); m.requests++; if (r.provider) m.providers.add(r.provider); models.set(key, m) }
|
|
160
|
+
const subagents = parts.slice(1).map(p => ({ sessionId: p.id, name: p.name, totals: p.totals, requests: p.requests, averageFirstTokenMs: p.averageFirstTokenMs, tokensPerSecond: p.tokensPerSecond }))
|
|
161
|
+
const routeMap = new Map<string, SessionUsageDetail['routes'][number]>()
|
|
162
|
+
for (const r of records) if (r.provider || r.model) { const id = `${r.provider}/${r.model}`; routeMap.set(id, { id, provider: r.provider, name: r.provider, model: r.model, status: r.status === 'running' ? 'running' : r.status === 'failed' ? 'failed' : 'completed' }) }
|
|
163
|
+
const llm: any = ctx?.get?.('llm'); for (const x of llm?.listProviders?.() ?? []) if (x?.id && !routeMap.has(x.id)) routeMap.set(x.id, { id: x.id, provider: x.id, name: x.name || x.id, model: '', status: 'idle' })
|
|
164
|
+
// Model rows key on the raw historical model id; attach the model selector's
|
|
165
|
+
// display name plus the serving providers so the by-model card never shows a
|
|
166
|
+
// route id as the model name and can disambiguate same-named models.
|
|
167
|
+
const providerNames = resolveProviderNames(llm)
|
|
168
|
+
const refOf = (provider: string, model: string): string => provider ? `${provider}/${model}` : model
|
|
169
|
+
const refs: string[] = []
|
|
170
|
+
for (const [model, m] of models) if (model !== '(unknown)') {
|
|
171
|
+
if (m.providers.size) for (const provider of m.providers) refs.push(refOf(provider, model))
|
|
172
|
+
else refs.push(refOf('', model))
|
|
173
|
+
}
|
|
174
|
+
const names = await resolveModelNames(refs, llm)
|
|
175
|
+
const displayNameOf = (model: string, providers: Set<string>): string | undefined => {
|
|
176
|
+
// One row can span providers; take the first display name they agree on.
|
|
177
|
+
const keys = providers.size ? [...providers].map(provider => refOf(provider, model)) : [refOf('', model)]
|
|
178
|
+
for (const key of keys) { const resolved = names.get(key)?.trim(); if (resolved) return resolved }
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
|
181
|
+
return { sessionId: rootId, totals, requests: parts.reduce((n, p) => n + p.requests, 0), averageFirstTokenMs: root.averageFirstTokenMs, tokensPerSecond: root.tokensPerSecond, subagents, models: [...models.entries()].map(([model, x]) => { const name = displayNameOf(model, x.providers); return { model, ...(name ? { name } : {}), providers: [...x.providers].map(provider => providerNames[provider] || provider), totals: x.totals, requests: x.requests } }), records, routes: [...routeMap.values()] }
|
|
182
|
+
}
|
package/src/wire.ts
CHANGED
|
@@ -136,6 +136,62 @@ export interface SessionUsageRequest {
|
|
|
136
136
|
sessionId: string
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** Detailed per-session usage view used by the Usage tab. */
|
|
140
|
+
export interface SessionUsageDetail {
|
|
141
|
+
sessionId: string
|
|
142
|
+
totals: SessionUsageTotals
|
|
143
|
+
requests: number
|
|
144
|
+
averageFirstTokenMs: number | null
|
|
145
|
+
tokensPerSecond: number | null
|
|
146
|
+
subagents: Array<{
|
|
147
|
+
sessionId: string
|
|
148
|
+
name: string
|
|
149
|
+
totals: SessionUsageTotals
|
|
150
|
+
requests: number
|
|
151
|
+
averageFirstTokenMs: number | null
|
|
152
|
+
tokensPerSecond: number | null
|
|
153
|
+
}>
|
|
154
|
+
models: Array<{
|
|
155
|
+
/** Raw model id from the historical route. Stays the row key and the
|
|
156
|
+
* fallback label (also the tooltip) when no display name is resolved. */
|
|
157
|
+
model: string
|
|
158
|
+
/** The model selector's display name for this model, resolved host-side
|
|
159
|
+
* from the llm service metadata. Optional so an older host (or an
|
|
160
|
+
* unresolvable route) still serves a valid payload. */
|
|
161
|
+
name?: string
|
|
162
|
+
/** Display names (raw ids when unknown) of every provider that served this
|
|
163
|
+
* model in the session, first-seen order. The by-model card shows these in
|
|
164
|
+
* gray when two rows end up sharing one display name. */
|
|
165
|
+
providers?: string[]
|
|
166
|
+
totals: SessionUsageTotals
|
|
167
|
+
requests: number
|
|
168
|
+
}>
|
|
169
|
+
records: Array<{
|
|
170
|
+
id: string
|
|
171
|
+
sessionId: string
|
|
172
|
+
sessionName: string
|
|
173
|
+
turn: number
|
|
174
|
+
step: number
|
|
175
|
+
startedAt: number
|
|
176
|
+
finishedAt: number | null
|
|
177
|
+
provider: string
|
|
178
|
+
model: string
|
|
179
|
+
status: 'running' | 'completed' | 'failed' | 'interrupted'
|
|
180
|
+
totals: SessionUsageTotals
|
|
181
|
+
firstTokenMs: number | null
|
|
182
|
+
tokensPerSecond: number | null
|
|
183
|
+
turnName: string
|
|
184
|
+
effort: string
|
|
185
|
+
}>
|
|
186
|
+
routes: Array<{
|
|
187
|
+
id: string
|
|
188
|
+
provider: string
|
|
189
|
+
name: string
|
|
190
|
+
model: string
|
|
191
|
+
status: 'idle' | 'running' | 'completed' | 'failed'
|
|
192
|
+
}>
|
|
193
|
+
}
|
|
194
|
+
|
|
139
195
|
/** The backfill (historical session scan) progress state. */
|
|
140
196
|
export interface BackfillStatus {
|
|
141
197
|
running: boolean
|