oc-metricboard 0.1.4
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 +137 -0
- package/package.json +92 -0
- package/src/assistant-progress.ts +99 -0
- package/src/badge-contrast.ts +62 -0
- package/src/collector-state.ts +36 -0
- package/src/collector.ts +615 -0
- package/src/components/SidebarMetrics.tsx +331 -0
- package/src/components/StatRow.tsx +83 -0
- package/src/config.ts +59 -0
- package/src/event-bus.ts +61 -0
- package/src/event-handlers.ts +345 -0
- package/src/event-shapes.ts +198 -0
- package/src/live-speed.ts +79 -0
- package/src/logger.ts +36 -0
- package/src/metrics.ts +243 -0
- package/src/request-state.ts +100 -0
- package/src/request-updates.ts +40 -0
- package/src/scripts/init-tui-preferences.ts +107 -0
- package/src/session-hydration.ts +278 -0
- package/src/session-timing.ts +78 -0
- package/src/session-tree.ts +43 -0
- package/src/tui-preferences.ts +203 -0
- package/src/tui-prefs-io.ts +159 -0
- package/src/tui.tsx +60 -0
- package/src/turn-state.ts +85 -0
- package/src/types.ts +138 -0
package/src/collector.ts
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
|
+
import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
|
|
3
|
+
import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
|
|
4
|
+
import { registerEventHandlers } from "./event-handlers"
|
|
5
|
+
import type { CollectorState } from "./collector-state"
|
|
6
|
+
import type { MetricsEventApi } from "./event-bus"
|
|
7
|
+
import { hydrateSession, isHydrationApi, type HydrationApi } from "./session-hydration"
|
|
8
|
+
import { createSessionTree } from "./session-tree"
|
|
9
|
+
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
|
|
10
|
+
import { clearLiveSpeed, getLiveTps } from "./live-speed"
|
|
11
|
+
import { liveRequestOutput, turnInputTokens } from "./turn-state"
|
|
12
|
+
|
|
13
|
+
export type MetricsListener = () => void
|
|
14
|
+
type MetricsHydrationApi = MetricsEventApi & HydrationApi
|
|
15
|
+
|
|
16
|
+
interface TreeHydrationApi {
|
|
17
|
+
readonly client: {
|
|
18
|
+
readonly session: {
|
|
19
|
+
children(input: { sessionID: string }): Promise<unknown>
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isTreeHydrationApi(value: unknown): value is TreeHydrationApi {
|
|
29
|
+
if (!isRecord(value) || !isRecord(value.client)) return false
|
|
30
|
+
const session = isRecord(value.client.session) ? value.client.session : null
|
|
31
|
+
return session !== null && typeof session.children === "function"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function childSessions(value: unknown): Array<{ id: string; parentID: string | null }> {
|
|
35
|
+
if (isRecord(value) && value.error !== undefined && value.error !== null) {
|
|
36
|
+
throw new Error(`session.children returned an error: ${String(value.error)}`)
|
|
37
|
+
}
|
|
38
|
+
const data = Array.isArray(value)
|
|
39
|
+
? value
|
|
40
|
+
: isRecord(value) && Array.isArray(value.data) ? value.data : []
|
|
41
|
+
return data.flatMap((item) => {
|
|
42
|
+
if (!isRecord(item) || typeof item.id !== "string" || item.id.length === 0) return []
|
|
43
|
+
return [{ id: item.id, parentID: typeof item.parentID === "string" ? item.parentID : null }]
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface MetricsCollector {
|
|
48
|
+
getCurrent(sessionID: string): RequestMetrics | null
|
|
49
|
+
getAggregate(sessionID: string, scope: MetricsScope, now?: number): MetricsAggregate | null
|
|
50
|
+
getSessionElapsedMs(sessionID: string, scope?: MetricsScope, now?: number): number
|
|
51
|
+
getChildSessionCount(sessionID: string): number
|
|
52
|
+
subscribe(listener: MetricsListener): () => void
|
|
53
|
+
dispose(): void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createCollector(
|
|
57
|
+
api: TuiPluginApi,
|
|
58
|
+
config: BarConfig,
|
|
59
|
+
log: (msg: string) => void,
|
|
60
|
+
): MetricsCollector
|
|
61
|
+
export function createCollector(
|
|
62
|
+
api: MetricsEventApi,
|
|
63
|
+
config: BarConfig,
|
|
64
|
+
log: (msg: string) => void,
|
|
65
|
+
): MetricsCollector
|
|
66
|
+
export function createCollector(
|
|
67
|
+
api: MetricsHydrationApi,
|
|
68
|
+
config: BarConfig,
|
|
69
|
+
log: (msg: string) => void,
|
|
70
|
+
): MetricsCollector
|
|
71
|
+
export function createCollector(
|
|
72
|
+
api: TuiPluginApi | MetricsEventApi | MetricsHydrationApi,
|
|
73
|
+
config: BarConfig,
|
|
74
|
+
log: (msg: string) => void,
|
|
75
|
+
): MetricsCollector {
|
|
76
|
+
const state: CollectorState = {
|
|
77
|
+
requests: new Map(),
|
|
78
|
+
turns: new Map(),
|
|
79
|
+
liveSpeeds: new Map(),
|
|
80
|
+
holdTimers: new Map(),
|
|
81
|
+
sessionTree: createSessionTree(),
|
|
82
|
+
sessionModels: new Map(),
|
|
83
|
+
sessionTimings: new Map(),
|
|
84
|
+
userMessageIds: new Map(),
|
|
85
|
+
assistantMessageIds: new Map(),
|
|
86
|
+
partTokenEstimates: new Map(),
|
|
87
|
+
sessionAliases: new Map(),
|
|
88
|
+
seenEventKeys: new Set(),
|
|
89
|
+
seenEventOrder: [],
|
|
90
|
+
lastRequestSessionID: null,
|
|
91
|
+
}
|
|
92
|
+
const listeners = new Set<MetricsListener>()
|
|
93
|
+
const hydrationApi = isHydrationApi(api) ? api : null
|
|
94
|
+
const treeHydrationApi = isTreeHydrationApi(api) ? api : null
|
|
95
|
+
const hydratedSessions = new Set<string>()
|
|
96
|
+
const hydratingSessions = new Set<string>()
|
|
97
|
+
const hydrationRetryAfter = new Map<string, number>()
|
|
98
|
+
// Cap hydration attempts per session: a session with no positive-token
|
|
99
|
+
// history will never succeed, and retrying every 2s on every render tick
|
|
100
|
+
// would fire SDK network requests indefinitely. Give up after N attempts.
|
|
101
|
+
const hydrationRetries = new Map<string, number>()
|
|
102
|
+
const MAX_HYDRATION_RETRIES = 5
|
|
103
|
+
const loggedFallbacks = new Set<string>()
|
|
104
|
+
const hydratedTreeRoots = new Set<string>()
|
|
105
|
+
const hydratingTreeRoots = new Set<string>()
|
|
106
|
+
const treeRetryAfter = new Map<string, number>()
|
|
107
|
+
let disposed = false
|
|
108
|
+
|
|
109
|
+
// Short-TTL aggregate cache: SidebarMetrics calls getAggregate once per row
|
|
110
|
+
// (~10 rows) per 200ms tick, and each call traverses the whole session tree.
|
|
111
|
+
// Reusing the result within one tick avoids redundant O(tree) work.
|
|
112
|
+
const AGGREGATE_CACHE_TTL_MS = 150
|
|
113
|
+
let aggregateCacheKey = ""
|
|
114
|
+
let aggregateCacheAt = 0
|
|
115
|
+
let aggregateCacheValue: MetricsAggregate | null = null
|
|
116
|
+
|
|
117
|
+
function notify(): void {
|
|
118
|
+
if (disposed) return
|
|
119
|
+
// Invalidate the aggregate cache: events mean state changed.
|
|
120
|
+
aggregateCacheKey = ""
|
|
121
|
+
aggregateCacheAt = 0
|
|
122
|
+
aggregateCacheValue = null
|
|
123
|
+
for (const listener of listeners) listener()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function clearHoldTimer(sessionID: string): void {
|
|
127
|
+
const timer = state.holdTimers.get(sessionID)
|
|
128
|
+
if (timer) {
|
|
129
|
+
clearTimeout(timer)
|
|
130
|
+
state.holdTimers.delete(sessionID)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const disposers = registerEventHandlers({
|
|
135
|
+
api,
|
|
136
|
+
config,
|
|
137
|
+
log,
|
|
138
|
+
state,
|
|
139
|
+
actions: {
|
|
140
|
+
notify,
|
|
141
|
+
startSessionTiming: (sessionID, now) => startSessionTiming(state.sessionTimings, sessionID, now),
|
|
142
|
+
stopSessionTiming: (sessionID, now) => stopSessionTiming(state.sessionTimings, sessionID, now),
|
|
143
|
+
clearHoldTimer,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
function hydrate(sessionID: string): void {
|
|
148
|
+
if (!hydrationApi || hydratedSessions.has(sessionID) || hydratingSessions.has(sessionID)) return
|
|
149
|
+
const now = performance.now()
|
|
150
|
+
if ((hydrationRetryAfter.get(sessionID) ?? 0) > now) return
|
|
151
|
+
if ((hydrationRetries.get(sessionID) ?? 0) >= MAX_HYDRATION_RETRIES) return
|
|
152
|
+
hydratingSessions.add(sessionID)
|
|
153
|
+
|
|
154
|
+
// Hydration is async (SDK client calls). Kick it off fire-and-forget;
|
|
155
|
+
// completion re-renders the UI via notify().
|
|
156
|
+
void (async () => {
|
|
157
|
+
let hydrated = false
|
|
158
|
+
try {
|
|
159
|
+
hydrated = await hydrateSession({ api: hydrationApi, state, sessionID, now })
|
|
160
|
+
} catch (error) {
|
|
161
|
+
log(`session hydration failed: session=${sessionID} error=${String(error)}`)
|
|
162
|
+
} finally {
|
|
163
|
+
hydratingSessions.delete(sessionID)
|
|
164
|
+
}
|
|
165
|
+
if (disposed) return
|
|
166
|
+
const current = state.requests.get(sessionID)
|
|
167
|
+
if (hydrated && current) {
|
|
168
|
+
hydratedSessions.add(sessionID)
|
|
169
|
+
hydrationRetryAfter.delete(sessionID)
|
|
170
|
+
hydrationRetries.delete(sessionID)
|
|
171
|
+
// Drop live-speed samples: reopening a session replays history deltas
|
|
172
|
+
// in a burst, which would inflate the rolling TPS window to absurd
|
|
173
|
+
// values. Historical token counts are restored via exact tokens, so
|
|
174
|
+
// replay-produced samples are garbage and must be discarded.
|
|
175
|
+
clearLiveSpeed(state.liveSpeeds, sessionID)
|
|
176
|
+
log(`hydrated session state: session=${sessionID} message=${current.messageID} in=${current.exactInputTokens} out=${current.exactOutputTokens}`)
|
|
177
|
+
notify()
|
|
178
|
+
} else {
|
|
179
|
+
hydrationRetryAfter.set(sessionID, now + 2000)
|
|
180
|
+
hydrationRetries.set(sessionID, (hydrationRetries.get(sessionID) ?? 0) + 1)
|
|
181
|
+
}
|
|
182
|
+
})()
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function hydrateTree(rootSessionID: string): void {
|
|
186
|
+
if (!treeHydrationApi || hydratedTreeRoots.has(rootSessionID) || hydratingTreeRoots.has(rootSessionID)) return
|
|
187
|
+
const now = performance.now()
|
|
188
|
+
if ((treeRetryAfter.get(rootSessionID) ?? 0) > now) return
|
|
189
|
+
hydratingTreeRoots.add(rootSessionID)
|
|
190
|
+
|
|
191
|
+
void (async () => {
|
|
192
|
+
try {
|
|
193
|
+
let parents = [rootSessionID]
|
|
194
|
+
const visited = new Set<string>(parents)
|
|
195
|
+
while (parents.length > 0) {
|
|
196
|
+
const responses = await Promise.all(parents.map(async (parentID) => ({
|
|
197
|
+
parentID,
|
|
198
|
+
response: await treeHydrationApi.client.session.children({ sessionID: parentID }),
|
|
199
|
+
})))
|
|
200
|
+
if (disposed) return
|
|
201
|
+
const next: string[] = []
|
|
202
|
+
for (const { parentID, response } of responses) {
|
|
203
|
+
for (const child of childSessions(response)) {
|
|
204
|
+
const childID = child.id
|
|
205
|
+
state.sessionTree.setParent(childID, child.parentID ?? parentID)
|
|
206
|
+
hydrate(childID)
|
|
207
|
+
if (!visited.has(childID)) {
|
|
208
|
+
visited.add(childID)
|
|
209
|
+
next.push(childID)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
parents = next
|
|
214
|
+
}
|
|
215
|
+
hydratedTreeRoots.add(rootSessionID)
|
|
216
|
+
treeRetryAfter.delete(rootSessionID)
|
|
217
|
+
notify()
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!disposed) {
|
|
220
|
+
treeRetryAfter.set(rootSessionID, performance.now() + 2000)
|
|
221
|
+
log(`tree hydration failed: session=${rootSessionID} error=${String(error)}`)
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
hydratingTreeRoots.delete(rootSessionID)
|
|
225
|
+
}
|
|
226
|
+
})()
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function normalizeSessionID(sessionID: string): string {
|
|
230
|
+
return typeof sessionID === "string" ? sessionID : ""
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function hasUsefulMetrics(metrics: readonly RequestMetrics[]): boolean {
|
|
234
|
+
return metrics.some((item) => (
|
|
235
|
+
getDisplayInputTokens(item) > 0
|
|
236
|
+
|| getDisplayOutputTokens(item) > 0
|
|
237
|
+
|| item.exactCacheReadTokens > 0
|
|
238
|
+
|| item.exactCacheWriteTokens > 0
|
|
239
|
+
|| item.firstTokenTime !== null
|
|
240
|
+
|| item.lastDeltaTime !== null
|
|
241
|
+
))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function hasUsefulSession(sessionID: string): boolean {
|
|
245
|
+
const request = state.requests.get(sessionID)
|
|
246
|
+
const turn = state.turns.get(sessionID)
|
|
247
|
+
return Boolean(
|
|
248
|
+
(request && hasUsefulMetrics([request]))
|
|
249
|
+
|| (turn && (
|
|
250
|
+
turn.finalizedOutputTokens > 0
|
|
251
|
+
|| turn.hasStickyContextTokens
|
|
252
|
+
)),
|
|
253
|
+
)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function usefulMetricsFor(ids: readonly string[]): readonly RequestMetrics[] {
|
|
257
|
+
const metrics = ids
|
|
258
|
+
.map((id) => state.requests.get(id))
|
|
259
|
+
.filter((item): item is RequestMetrics => item !== undefined)
|
|
260
|
+
return metrics.length > 0 && ids.some(hasUsefulSession) ? metrics : []
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function aliasScopeSessionIDs(sessionID: string, scope: MetricsScope): { readonly rootID: string; readonly ids: readonly string[] } | null {
|
|
264
|
+
for (const aliasID of state.sessionAliases.get(sessionID) ?? []) {
|
|
265
|
+
const aliasIDs = state.sessionTree.getScopeSessionIDs(aliasID, scope)
|
|
266
|
+
if (usefulMetricsFor(aliasIDs).length > 0) {
|
|
267
|
+
return { rootID: aliasID, ids: aliasIDs }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function resolveMetricsSessionID(sessionID: string): string {
|
|
274
|
+
const requestedSessionID = normalizeSessionID(sessionID)
|
|
275
|
+
const requested = state.requests.get(requestedSessionID)
|
|
276
|
+
if ((requested && hasUsefulMetrics([requested])) || hasUsefulSession(requestedSessionID)) return requestedSessionID
|
|
277
|
+
const alias = aliasScopeSessionIDs(requestedSessionID, "current")
|
|
278
|
+
if (alias) return alias.rootID
|
|
279
|
+
return requestedSessionID
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function scopeSessionIDs(sessionID: string, scope: MetricsScope): { readonly rootID: string; readonly ids: readonly string[] } {
|
|
283
|
+
const requestedSessionID = normalizeSessionID(sessionID)
|
|
284
|
+
const requestedIDs = state.sessionTree.getScopeSessionIDs(requestedSessionID, scope)
|
|
285
|
+
if (usefulMetricsFor(requestedIDs).length > 0) {
|
|
286
|
+
return { rootID: requestedSessionID, ids: requestedIDs }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const alias = aliasScopeSessionIDs(requestedSessionID, scope)
|
|
290
|
+
if (alias) {
|
|
291
|
+
const fallbackKey = `${requestedSessionID}->${alias.rootID}`
|
|
292
|
+
if (!loggedFallbacks.has(fallbackKey)) {
|
|
293
|
+
loggedFallbacks.add(fallbackKey)
|
|
294
|
+
log(`sidebar session alias: requested=${requestedSessionID || "(empty)"} metrics=${alias.rootID}`)
|
|
295
|
+
}
|
|
296
|
+
return alias
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { rootID: requestedSessionID, ids: requestedIDs }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function aggregateByModel(ids: readonly string[], now: number, scope: MetricsScope): ModelMetrics[] {
|
|
303
|
+
// Group requests by (modelID, providerID) — one row per distinct model
|
|
304
|
+
// across all sub-agent sessions, with a ×N session count.
|
|
305
|
+
const modelGroups = new Map<string, RequestMetrics[]>()
|
|
306
|
+
|
|
307
|
+
for (const id of ids) {
|
|
308
|
+
const request = state.requests.get(id)
|
|
309
|
+
if (!request) continue
|
|
310
|
+
|
|
311
|
+
// Key: modelID|providerID (model-grouped, not per-session)
|
|
312
|
+
const key = `${request.modelID}|${request.providerID}`
|
|
313
|
+
const group = modelGroups.get(key) ?? []
|
|
314
|
+
group.push(request)
|
|
315
|
+
modelGroups.set(key, group)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const result: ModelMetrics[] = []
|
|
319
|
+
|
|
320
|
+
for (const [key, metrics] of modelGroups) {
|
|
321
|
+
const [modelID, providerID] = key.split("|")
|
|
322
|
+
|
|
323
|
+
// Use aggregateRequestMetrics logic but per-model
|
|
324
|
+
let inputTokens = 0
|
|
325
|
+
let outputTokens = 0
|
|
326
|
+
let cacheReadTokens = 0
|
|
327
|
+
let exactCacheCount = 0
|
|
328
|
+
let requestStartTime = Number.POSITIVE_INFINITY
|
|
329
|
+
let firstTokenTime: number | null = null
|
|
330
|
+
let completeTime: number | null = null
|
|
331
|
+
let isStreaming = false
|
|
332
|
+
let isComplete = true
|
|
333
|
+
const sessionCount = new Set(metrics.map((m) => m.sessionID)).size
|
|
334
|
+
|
|
335
|
+
for (const m of metrics) {
|
|
336
|
+
inputTokens += getDisplayInputTokens(m)
|
|
337
|
+
outputTokens += getDisplayOutputTokens(m)
|
|
338
|
+
if (m.hasExactCacheReadTokens) {
|
|
339
|
+
cacheReadTokens += Math.max(0, m.exactCacheReadTokens)
|
|
340
|
+
exactCacheCount += 1
|
|
341
|
+
}
|
|
342
|
+
requestStartTime = Math.min(requestStartTime, m.requestStartTime)
|
|
343
|
+
if (m.firstTokenTime !== null) {
|
|
344
|
+
firstTokenTime = firstTokenTime === null ? m.firstTokenTime : Math.min(firstTokenTime, m.firstTokenTime)
|
|
345
|
+
}
|
|
346
|
+
if (m.completeTime !== null) {
|
|
347
|
+
completeTime = completeTime === null ? m.completeTime : Math.max(completeTime, m.completeTime)
|
|
348
|
+
}
|
|
349
|
+
isStreaming = isStreaming || m.isStreaming
|
|
350
|
+
isComplete = isComplete && m.isComplete
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const cacheReadCompleteness: CacheReadCompleteness =
|
|
354
|
+
exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
|
|
355
|
+
const ttft = firstTokenTime === null ? null : Math.round(firstTokenTime - requestStartTime)
|
|
356
|
+
|
|
357
|
+
// Live TPS for this model group
|
|
358
|
+
let liveTps = 0
|
|
359
|
+
let liveRateCount = 0
|
|
360
|
+
for (const m of metrics) {
|
|
361
|
+
const rate = getLiveTps(state.liveSpeeds.get(m.sessionID), now)
|
|
362
|
+
if (rate !== null) {
|
|
363
|
+
liveTps += rate
|
|
364
|
+
liveRateCount += 1
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// TPS logic (same as main aggregate with the same timing sanity check):
|
|
369
|
+
// - Streaming with live samples: live rolling-window TPS.
|
|
370
|
+
// - Complete: freeze final average TPS (firstTokenTime preferred,
|
|
371
|
+
// requestStartTime as end-to-end fallback).
|
|
372
|
+
// - Active but no live samples: running average to `now`.
|
|
373
|
+
// Negative timestamps (messages predating this TUI process) are unusable
|
|
374
|
+
// for rate math — treated as missing.
|
|
375
|
+
let displayTps: number | null = null
|
|
376
|
+
if (liveRateCount > 0 && !isComplete) {
|
|
377
|
+
// Live TPS must pass the same sanity gate: replay bursts can seed
|
|
378
|
+
// absurd windowed rates (1300+ t/s) which are never real.
|
|
379
|
+
const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300
|
|
380
|
+
if (saneLive) {
|
|
381
|
+
displayTps = Math.round(liveTps * 10) / 10
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
if (displayTps === null && metrics.length > 0) {
|
|
385
|
+
// Frozen TPS: use the LATEST request's own tokens and timing so the
|
|
386
|
+
// numerator and denominator describe the same generation. The summed
|
|
387
|
+
// `outputTokens` spans multiple requests; dividing it by the group
|
|
388
|
+
// window would inflate the rate (e.g. 1800+ t/s). "Latest" is by
|
|
389
|
+
// requestStartTime (chronological), not array order (tree DFS).
|
|
390
|
+
const latest = metrics.reduce((a, b) => (b.requestStartTime >= a.requestStartTime ? b : a))
|
|
391
|
+
const frTokens = getDisplayOutputTokens(latest)
|
|
392
|
+
const genStart = latest.firstTokenTime ?? latest.requestStartTime
|
|
393
|
+
const genEnd = isComplete && completeTime !== null ? completeTime : now
|
|
394
|
+
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
395
|
+
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
396
|
+
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
397
|
+
displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
result.push({
|
|
402
|
+
modelID,
|
|
403
|
+
providerID,
|
|
404
|
+
sessionID: providerID,
|
|
405
|
+
sessionCount,
|
|
406
|
+
inputTokens,
|
|
407
|
+
outputTokens,
|
|
408
|
+
cacheReadTokens,
|
|
409
|
+
cacheReadCompleteness,
|
|
410
|
+
requestStartTime: requestStartTime === Number.POSITIVE_INFINITY ? now : requestStartTime,
|
|
411
|
+
firstTokenTime,
|
|
412
|
+
completeTime: isComplete ? completeTime ?? now : null,
|
|
413
|
+
ttft,
|
|
414
|
+
liveTps: displayTps,
|
|
415
|
+
isStreaming,
|
|
416
|
+
isComplete,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Sort by provider then model
|
|
421
|
+
return result.sort((a, b) => {
|
|
422
|
+
const providerCompare = a.providerID.localeCompare(b.providerID)
|
|
423
|
+
if (providerCompare !== 0) return providerCompare
|
|
424
|
+
return a.modelID.localeCompare(b.modelID)
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
getCurrent(sessionID: string): RequestMetrics | null {
|
|
430
|
+
const requestedSessionID = normalizeSessionID(sessionID)
|
|
431
|
+
hydrate(requestedSessionID)
|
|
432
|
+
return state.requests.get(resolveMetricsSessionID(requestedSessionID)) ?? null
|
|
433
|
+
},
|
|
434
|
+
getAggregate(sessionID: string, scope: MetricsScope, now = performance.now()): MetricsAggregate | null {
|
|
435
|
+
const requestedSessionID = normalizeSessionID(sessionID)
|
|
436
|
+
hydrate(requestedSessionID)
|
|
437
|
+
if (scope === "tree") hydrateTree(requestedSessionID)
|
|
438
|
+
|
|
439
|
+
// Serve from cache when fresh: multiple rows request the same aggregate
|
|
440
|
+
// within one render tick.
|
|
441
|
+
const cacheKey = `${requestedSessionID}:${scope}`
|
|
442
|
+
if (cacheKey === aggregateCacheKey && now - aggregateCacheAt < AGGREGATE_CACHE_TTL_MS) {
|
|
443
|
+
return aggregateCacheValue
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope)
|
|
447
|
+
const foregroundTurn = state.turns.get(rootID)
|
|
448
|
+
const foregroundRequest = state.requests.get(rootID)
|
|
449
|
+
|
|
450
|
+
if (!foregroundTurn && !foregroundRequest) return null
|
|
451
|
+
|
|
452
|
+
const foregroundTurnStart = foregroundTurn?.turnStartTime ?? foregroundRequest!.requestStartTime
|
|
453
|
+
let inputTokens = 0
|
|
454
|
+
let outputTokens = 0
|
|
455
|
+
let cacheReadTokens = 0
|
|
456
|
+
let cacheExactCount = 0
|
|
457
|
+
let contributingCount = 0
|
|
458
|
+
let liveTps = 0
|
|
459
|
+
let liveRateCount = 0
|
|
460
|
+
let isStreaming = false
|
|
461
|
+
const contributingSessionIDs: string[] = []
|
|
462
|
+
|
|
463
|
+
for (const id of ids) {
|
|
464
|
+
const turn = state.turns.get(id)
|
|
465
|
+
const request = state.requests.get(id)
|
|
466
|
+
if (!turn && !request) continue
|
|
467
|
+
const belongsToForegroundTurn = id === rootID
|
|
468
|
+
|| Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete))
|
|
469
|
+
if (belongsToForegroundTurn) {
|
|
470
|
+
const sessionInput = turn ? turnInputTokens(turn, request) : request ? getDisplayInputTokens(request) : 0
|
|
471
|
+
const sessionOutput = turn
|
|
472
|
+
? turn.finalizedOutputTokens + liveRequestOutput(turn, request)
|
|
473
|
+
: request ? getDisplayOutputTokens(request) : 0
|
|
474
|
+
const hasContribution = sessionInput > 0 || sessionOutput > 0 || Boolean(request?.isStreaming)
|
|
475
|
+
if (hasContribution) {
|
|
476
|
+
inputTokens += sessionInput
|
|
477
|
+
outputTokens += sessionOutput
|
|
478
|
+
contributingCount += 1
|
|
479
|
+
contributingSessionIDs.push(id)
|
|
480
|
+
if (turn?.hasStickyCacheReadTokens) {
|
|
481
|
+
cacheReadTokens += turn.stickyCacheReadTokens
|
|
482
|
+
cacheExactCount += 1
|
|
483
|
+
} else if (!turn && request?.hasExactCacheReadTokens) {
|
|
484
|
+
cacheReadTokens += Math.max(0, request.exactCacheReadTokens)
|
|
485
|
+
cacheExactCount += 1
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const rate = getLiveTps(state.liveSpeeds.get(id), now)
|
|
491
|
+
if (rate !== null) {
|
|
492
|
+
liveTps += rate
|
|
493
|
+
liveRateCount += 1
|
|
494
|
+
}
|
|
495
|
+
isStreaming = isStreaming || Boolean(request?.isStreaming)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const cacheReadCompleteness: CacheReadCompleteness = cacheExactCount === 0
|
|
499
|
+
? "unknown"
|
|
500
|
+
: cacheExactCount === contributingCount ? "exact" : "partial"
|
|
501
|
+
const requestStartTime = foregroundTurn?.turnStartTime ?? foregroundRequest!.requestStartTime
|
|
502
|
+
const firstTokenTime = foregroundRequest?.firstTokenTime ?? null
|
|
503
|
+
// Tree-scope completion semantics: the aggregate is NOT complete while
|
|
504
|
+
// any contributing descendant session is still streaming, even if the
|
|
505
|
+
// foreground root finished. Otherwise the frozen-TPS branch would use the
|
|
506
|
+
// root's completeTime as the window end while outputTokens keeps
|
|
507
|
+
// accumulating from sub-agents, understating the rate.
|
|
508
|
+
const foregroundComplete = foregroundTurn?.isComplete ?? foregroundRequest?.isComplete ?? false
|
|
509
|
+
const isComplete = isStreaming ? false : foregroundComplete
|
|
510
|
+
const completeTime = foregroundTurn?.completeTime ?? foregroundRequest?.completeTime ?? null
|
|
511
|
+
|
|
512
|
+
// TPS logic.
|
|
513
|
+
// Timing sanity: hydration converts wall-clock times into the current
|
|
514
|
+
// process's performance.now() base. Messages created BEFORE this TUI
|
|
515
|
+
// process started convert to NEGATIVE timestamps. Any negative (or
|
|
516
|
+
// future) timestamp is unusable for rate math — treat it as missing.
|
|
517
|
+
// - Streaming with live samples: live rolling-window TPS.
|
|
518
|
+
// - Complete: freeze final average TPS using the generation window
|
|
519
|
+
// (firstTokenTime preferred, requestStartTime as end-to-end fallback).
|
|
520
|
+
// - Active but no live samples (reopened busy session): running
|
|
521
|
+
// average to now. genEnd is ALWAYS `now` here — never a stale
|
|
522
|
+
// historical completeTime.
|
|
523
|
+
let displayTps: number | null = null
|
|
524
|
+
if (liveRateCount > 0 && !isComplete) {
|
|
525
|
+
// Live TPS must pass the same sanity gate: replay bursts can seed
|
|
526
|
+
// absurd windowed rates (e.g. 1379 t/s) which are never real.
|
|
527
|
+
const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300
|
|
528
|
+
if (saneLive) {
|
|
529
|
+
displayTps = Math.round(liveTps * 10) / 10
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (displayTps === null && foregroundRequest) {
|
|
533
|
+
// Frozen TPS: use the FOREGROUND request's own tokens and timing so
|
|
534
|
+
// the numerator and denominator describe the same generation. In tree
|
|
535
|
+
// scope `outputTokens` is summed across sub-agents — dividing that by
|
|
536
|
+
// the foreground window would inflate TPS absurdly (1800+ t/s).
|
|
537
|
+
const frTokens = getDisplayOutputTokens(foregroundRequest)
|
|
538
|
+
const genStart = firstTokenTime ?? requestStartTime
|
|
539
|
+
const genEnd = isComplete ? completeTime : now
|
|
540
|
+
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
541
|
+
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
542
|
+
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
543
|
+
displayTps = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// NEW: Build per-model breakdown for tree scope
|
|
548
|
+
const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
|
|
549
|
+
const ttft = foregroundRequest ? getTtft(foregroundRequest) : null
|
|
550
|
+
|
|
551
|
+
const result: MetricsAggregate = {
|
|
552
|
+
sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
|
|
553
|
+
childSessionCount: scope === "tree" ? state.sessionTree.getChildSessionCount(rootID) : 0,
|
|
554
|
+
inputTokens,
|
|
555
|
+
outputTokens,
|
|
556
|
+
cacheReadTokens,
|
|
557
|
+
cacheReadCompleteness,
|
|
558
|
+
requestStartTime,
|
|
559
|
+
firstTokenTime,
|
|
560
|
+
completeTime: isComplete ? completeTime ?? now : null,
|
|
561
|
+
ttft,
|
|
562
|
+
liveTps: displayTps,
|
|
563
|
+
isStreaming,
|
|
564
|
+
isComplete,
|
|
565
|
+
modelBreakdown,
|
|
566
|
+
}
|
|
567
|
+
aggregateCacheKey = cacheKey
|
|
568
|
+
aggregateCacheAt = now
|
|
569
|
+
aggregateCacheValue = result
|
|
570
|
+
return result
|
|
571
|
+
},
|
|
572
|
+
getSessionElapsedMs(sessionID: string, scope: MetricsScope = "current", now = performance.now()): number {
|
|
573
|
+
const requestedSessionID = normalizeSessionID(sessionID)
|
|
574
|
+
hydrate(requestedSessionID)
|
|
575
|
+
if (scope === "tree") hydrateTree(requestedSessionID)
|
|
576
|
+
const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope)
|
|
577
|
+
if (scope === "current") return getSessionElapsedMs(state.sessionTimings.get(rootID), now)
|
|
578
|
+
return getScopeElapsedMs(state.sessionTimings, ids, now)
|
|
579
|
+
},
|
|
580
|
+
getChildSessionCount(sessionID: string): number {
|
|
581
|
+
return state.sessionTree.getChildSessionCount(sessionID)
|
|
582
|
+
},
|
|
583
|
+
subscribe(listener: MetricsListener): () => void {
|
|
584
|
+
listeners.add(listener)
|
|
585
|
+
return () => { listeners.delete(listener) }
|
|
586
|
+
},
|
|
587
|
+
dispose(): void {
|
|
588
|
+
disposed = true
|
|
589
|
+
for (const dispose of disposers.splice(0)) dispose()
|
|
590
|
+
for (const timer of state.holdTimers.values()) clearTimeout(timer)
|
|
591
|
+
state.holdTimers.clear()
|
|
592
|
+
state.requests.clear()
|
|
593
|
+
state.turns.clear()
|
|
594
|
+
state.liveSpeeds.clear()
|
|
595
|
+
state.sessionTree.clear()
|
|
596
|
+
state.sessionModels.clear()
|
|
597
|
+
state.sessionTimings.clear()
|
|
598
|
+
state.userMessageIds.clear()
|
|
599
|
+
state.assistantMessageIds.clear()
|
|
600
|
+
state.partTokenEstimates.clear()
|
|
601
|
+
state.sessionAliases.clear()
|
|
602
|
+
state.seenEventKeys.clear()
|
|
603
|
+
state.seenEventOrder.length = 0
|
|
604
|
+
state.lastRequestSessionID = null
|
|
605
|
+
loggedFallbacks.clear()
|
|
606
|
+
hydratedSessions.clear()
|
|
607
|
+
hydratingSessions.clear()
|
|
608
|
+
hydrationRetryAfter.clear()
|
|
609
|
+
hydratedTreeRoots.clear()
|
|
610
|
+
hydratingTreeRoots.clear()
|
|
611
|
+
treeRetryAfter.clear()
|
|
612
|
+
listeners.clear()
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
}
|