oc-metricboard 0.1.18 → 0.1.19
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/dist/collector.js +31 -4
- package/dist/event-handlers.js +28 -1
- package/dist/metrics.js +16 -0
- package/dist/tui-v2.js +3 -0
- package/dist/turn-state.js +63 -0
- package/package.json +1 -1
- package/src/collector.ts +29 -4
- package/src/event-handlers.ts +28 -1
- package/src/metrics.ts +16 -0
- package/src/tui-v2.tsx +3 -0
- package/src/turn-state.ts +70 -0
- package/src/types.ts +7 -0
package/dist/collector.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics";
|
|
1
|
+
import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics";
|
|
2
2
|
import { registerEventHandlers } from "./event-handlers";
|
|
3
3
|
import { hydrateSession, isHydrationApi, callWithFallback } from "./session-hydration";
|
|
4
4
|
import { createSessionTree } from "./session-tree";
|
|
5
5
|
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing";
|
|
6
6
|
import { clearLiveSpeed, getLiveTps } from "./live-speed";
|
|
7
|
-
import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state";
|
|
7
|
+
import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state";
|
|
8
8
|
function isRecord(value) {
|
|
9
9
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
10
10
|
}
|
|
@@ -358,8 +358,21 @@ export function createCollector(api, config, log) {
|
|
|
358
358
|
: latest.isStreaming || latestLastActivity === null ? now : latestLastActivity;
|
|
359
359
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
360
360
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
|
|
361
|
+
let primary = null;
|
|
361
362
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
362
|
-
|
|
363
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
|
|
364
|
+
}
|
|
365
|
+
// Same turn-anchored fallback as the main aggregate, using the latest
|
|
366
|
+
// request's own turn.
|
|
367
|
+
const latestTurn = state.turns.get(latest.sessionID);
|
|
368
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
|
|
369
|
+
const turnEnd = completeTime ?? latestLastActivity ?? now;
|
|
370
|
+
const turnWindow = turnEnd - latestTurn.turnStartTime
|
|
371
|
+
- toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd);
|
|
372
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
displayTps = primary;
|
|
363
376
|
}
|
|
364
377
|
}
|
|
365
378
|
result.push({
|
|
@@ -506,8 +519,22 @@ export function createCollector(api, config, log) {
|
|
|
506
519
|
: foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity;
|
|
507
520
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
508
521
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000;
|
|
522
|
+
let primary = null;
|
|
509
523
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
510
|
-
|
|
524
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10;
|
|
525
|
+
}
|
|
526
|
+
// Fall back to the turn-anchored window when the per-step window
|
|
527
|
+
// implies a physically incredible rate: per-delta times may be
|
|
528
|
+
// delivery-stamped (batched flushes), while turn admission, step
|
|
529
|
+
// boundaries, and tool spans use discrete truthful anchors.
|
|
530
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
|
|
531
|
+
const turnEnd = completeTime ?? foregroundLastActivity ?? now;
|
|
532
|
+
const turnWindow = turnEnd - foregroundTurn.turnStartTime
|
|
533
|
+
- toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd);
|
|
534
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
displayTps = primary;
|
|
511
538
|
}
|
|
512
539
|
}
|
|
513
540
|
// NEW: Build per-model breakdown for tree scope
|
package/dist/event-handlers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, me
|
|
|
5
5
|
import { completeRequest, currentRequest } from "./request-state";
|
|
6
6
|
import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus";
|
|
7
7
|
import { clearLiveSpeed, resetLiveSpeed } from "./live-speed";
|
|
8
|
-
import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state";
|
|
8
|
+
import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state";
|
|
9
9
|
export function registerEventHandlers(ctx) {
|
|
10
10
|
const { api, config, log, state, actions } = ctx;
|
|
11
11
|
const disposers = [];
|
|
@@ -157,6 +157,33 @@ export function registerEventHandlers(ctx) {
|
|
|
157
157
|
});
|
|
158
158
|
on("session.next.text.delta", (event) => applyAssistantDelta(ctx, event));
|
|
159
159
|
on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event));
|
|
160
|
+
// ── tool execution spans (V2 tool.* events; V1 has none) ──
|
|
161
|
+
// Feeds per-turn tool intervals so frozen TPS can subtract tool time from
|
|
162
|
+
// the turn-anchored window. Unknown shapes are ignored defensively.
|
|
163
|
+
on("session.tool.called", (event) => {
|
|
164
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
165
|
+
if (sessionID.length === 0)
|
|
166
|
+
return;
|
|
167
|
+
recordEventAlias(event, sessionID);
|
|
168
|
+
recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
169
|
+
actions.notify();
|
|
170
|
+
});
|
|
171
|
+
on("session.tool.success", (event) => {
|
|
172
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
173
|
+
if (sessionID.length === 0)
|
|
174
|
+
return;
|
|
175
|
+
recordEventAlias(event, sessionID);
|
|
176
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
177
|
+
actions.notify();
|
|
178
|
+
});
|
|
179
|
+
on("session.tool.failed", (event) => {
|
|
180
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
181
|
+
if (sessionID.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
recordEventAlias(event, sessionID);
|
|
184
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
185
|
+
actions.notify();
|
|
186
|
+
});
|
|
160
187
|
on("session.next.step.ended", (event) => {
|
|
161
188
|
const sessionID = stringEventProperty(event, "sessionID");
|
|
162
189
|
const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event);
|
package/dist/metrics.js
CHANGED
|
@@ -113,6 +113,22 @@ export function getTtft(m) {
|
|
|
113
113
|
return null;
|
|
114
114
|
return gateTtft(m.firstTokenTime - m.requestStartTime);
|
|
115
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
|
|
118
|
+
* per-delta times may be delivery-stamped (batched flushes), collapsing the
|
|
119
|
+
* generation window to milliseconds and inflating the quotient absurdly
|
|
120
|
+
* (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
|
|
121
|
+
* Windows implying a faster rate fall back to turn-anchored timing.
|
|
122
|
+
*/
|
|
123
|
+
export const MAX_CREDIBLE_TPS = 300;
|
|
124
|
+
export function credibleTps(tokens, windowMs) {
|
|
125
|
+
if (!(tokens > 0) || !(windowMs > 0))
|
|
126
|
+
return null;
|
|
127
|
+
const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10;
|
|
128
|
+
if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS)
|
|
129
|
+
return null;
|
|
130
|
+
return tps;
|
|
131
|
+
}
|
|
116
132
|
/**
|
|
117
133
|
* Calculate average token speed (tokens/second).
|
|
118
134
|
* Based on cumulative streaming time (now - firstTokenTime).
|
package/dist/tui-v2.js
CHANGED
|
@@ -64,6 +64,9 @@ const V2_EVENT_MAP = {
|
|
|
64
64
|
"session.next.reasoning.delta": "session.reasoning.delta",
|
|
65
65
|
"session.next.step.started": "session.step.started",
|
|
66
66
|
"session.next.step.ended": "session.step.ended",
|
|
67
|
+
"session.tool.called": "session.tool.called",
|
|
68
|
+
"session.tool.success": "session.tool.success",
|
|
69
|
+
"session.tool.failed": "session.tool.failed",
|
|
67
70
|
};
|
|
68
71
|
function isRecord(value) {
|
|
69
72
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
package/dist/turn-state.js
CHANGED
|
@@ -12,6 +12,7 @@ export function createTurnMetrics(sessionID, now) {
|
|
|
12
12
|
hasStickyContextTokens: false,
|
|
13
13
|
stickyCacheReadTokens: 0,
|
|
14
14
|
hasStickyCacheReadTokens: false,
|
|
15
|
+
toolIntervals: [],
|
|
15
16
|
isComplete: false,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
@@ -78,6 +79,68 @@ export function recordTurnFirstToken(turn, now) {
|
|
|
78
79
|
if (turn.firstTokenTime === null)
|
|
79
80
|
turn.firstTokenTime = now;
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Record a tool execution span on the session's turn. Tools in one turn may
|
|
84
|
+
* overlap (parallel calls), so every span is kept and the union is computed
|
|
85
|
+
* at query time — a plain sum would double-count overlaps.
|
|
86
|
+
*/
|
|
87
|
+
export function recordToolCalled(turns, sessionID, toolID, now) {
|
|
88
|
+
const turn = ensureTurn(turns, sessionID, now);
|
|
89
|
+
turn.toolIntervals.push({ id: toolID, start: now, end: null });
|
|
90
|
+
}
|
|
91
|
+
export function recordToolSettled(turns, sessionID, toolID, now) {
|
|
92
|
+
const turn = turns.get(sessionID);
|
|
93
|
+
if (!turn)
|
|
94
|
+
return;
|
|
95
|
+
if (toolID) {
|
|
96
|
+
const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null);
|
|
97
|
+
if (open) {
|
|
98
|
+
open.end = Math.max(now, open.start);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Unknown id (or unsettled shape): close the most recent open span so a
|
|
103
|
+
// lost settle event cannot leak an infinite interval.
|
|
104
|
+
for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
|
|
105
|
+
if (turn.toolIntervals[i].end === null) {
|
|
106
|
+
turn.toolIntervals[i].end = Math.max(now, turn.toolIntervals[i].start);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Milliseconds of tool execution overlapping [from, to], unioned so parallel
|
|
113
|
+
* tool calls count once. Open spans are clamped at `to`.
|
|
114
|
+
*/
|
|
115
|
+
export function toolOverlapMs(turn, from, to) {
|
|
116
|
+
if (!turn || to <= from)
|
|
117
|
+
return 0;
|
|
118
|
+
const spans = [];
|
|
119
|
+
for (const interval of turn.toolIntervals) {
|
|
120
|
+
const start = Math.max(interval.start, from);
|
|
121
|
+
const end = Math.min(interval.end ?? to, to);
|
|
122
|
+
if (end > start)
|
|
123
|
+
spans.push({ start, end });
|
|
124
|
+
}
|
|
125
|
+
spans.sort((a, b) => a.start - b.start);
|
|
126
|
+
let total = 0;
|
|
127
|
+
let cursor = null;
|
|
128
|
+
let cursorEnd = 0;
|
|
129
|
+
for (const span of spans) {
|
|
130
|
+
if (cursor === null || span.start > cursorEnd) {
|
|
131
|
+
if (cursor !== null)
|
|
132
|
+
total += cursorEnd - cursor;
|
|
133
|
+
cursor = span.start;
|
|
134
|
+
cursorEnd = span.end;
|
|
135
|
+
}
|
|
136
|
+
else if (span.end > cursorEnd) {
|
|
137
|
+
cursorEnd = span.end;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (cursor !== null)
|
|
141
|
+
total += cursorEnd - cursor;
|
|
142
|
+
return total;
|
|
143
|
+
}
|
|
81
144
|
/**
|
|
82
145
|
* Turn-level TTFT (user message → first token), gated. Falls back to the
|
|
83
146
|
* supplied per-request TTFT when the turn has no first-token timing. This is
|
package/package.json
CHANGED
package/src/collector.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
2
2
|
import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
|
|
3
|
-
import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
|
|
3
|
+
import { credibleTps, getDisplayInputTokens, getDisplayOutputTokens, getTtft, MAX_CREDIBLE_TPS } from "./metrics"
|
|
4
4
|
import { registerEventHandlers } from "./event-handlers"
|
|
5
5
|
import type { CollectorState } from "./collector-state"
|
|
6
6
|
import type { MetricsEventApi } from "./event-bus"
|
|
@@ -8,7 +8,7 @@ import { hydrateSession, isHydrationApi, callWithFallback, type HydrationApi } f
|
|
|
8
8
|
import { createSessionTree } from "./session-tree"
|
|
9
9
|
import { getScopeElapsedMs, getSessionElapsedMs, startSessionTiming, stopSessionTiming } from "./session-timing"
|
|
10
10
|
import { clearLiveSpeed, getLiveTps } from "./live-speed"
|
|
11
|
-
import { getTurnTtft, liveRequestOutput, turnInputTokens } from "./turn-state"
|
|
11
|
+
import { getTurnTtft, liveRequestOutput, toolOverlapMs, turnInputTokens } from "./turn-state"
|
|
12
12
|
|
|
13
13
|
export type MetricsListener = () => void
|
|
14
14
|
type MetricsHydrationApi = MetricsEventApi & HydrationApi
|
|
@@ -426,8 +426,20 @@ export function createCollector(
|
|
|
426
426
|
: latest.isStreaming || latestLastActivity === null ? now : latestLastActivity
|
|
427
427
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
428
428
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
429
|
+
let primary: number | null = null
|
|
429
430
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
430
|
-
|
|
431
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
432
|
+
}
|
|
433
|
+
// Same turn-anchored fallback as the main aggregate, using the latest
|
|
434
|
+
// request's own turn.
|
|
435
|
+
const latestTurn = state.turns.get(latest.sessionID)
|
|
436
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
|
|
437
|
+
const turnEnd = completeTime ?? latestLastActivity ?? now
|
|
438
|
+
const turnWindow = turnEnd - latestTurn.turnStartTime
|
|
439
|
+
- toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd)
|
|
440
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary
|
|
441
|
+
} else {
|
|
442
|
+
displayTps = primary
|
|
431
443
|
}
|
|
432
444
|
}
|
|
433
445
|
|
|
@@ -580,8 +592,21 @@ export function createCollector(
|
|
|
580
592
|
: foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity
|
|
581
593
|
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now
|
|
582
594
|
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 60_000
|
|
595
|
+
let primary: number | null = null
|
|
583
596
|
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
584
|
-
|
|
597
|
+
primary = Math.round((frTokens / ((genEnd - genStart) / 1000)) * 10) / 10
|
|
598
|
+
}
|
|
599
|
+
// Fall back to the turn-anchored window when the per-step window
|
|
600
|
+
// implies a physically incredible rate: per-delta times may be
|
|
601
|
+
// delivery-stamped (batched flushes), while turn admission, step
|
|
602
|
+
// boundaries, and tool spans use discrete truthful anchors.
|
|
603
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
|
|
604
|
+
const turnEnd = completeTime ?? foregroundLastActivity ?? now
|
|
605
|
+
const turnWindow = turnEnd - foregroundTurn.turnStartTime
|
|
606
|
+
- toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd)
|
|
607
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary
|
|
608
|
+
} else {
|
|
609
|
+
displayTps = primary
|
|
585
610
|
}
|
|
586
611
|
}
|
|
587
612
|
|
package/src/event-handlers.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { completeRequest, currentRequest } from "./request-state"
|
|
|
14
14
|
import { eventAggregateID, eventID, eventPerfTime, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
|
|
15
15
|
import type { EventHandlerContext } from "./collector-state"
|
|
16
16
|
import { clearLiveSpeed, resetLiveSpeed } from "./live-speed"
|
|
17
|
-
import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
|
|
17
|
+
import { ensureTurn, recordToolCalled, recordToolSettled, retireRequestIntoTurn, startTurn } from "./turn-state"
|
|
18
18
|
|
|
19
19
|
export function registerEventHandlers(ctx: EventHandlerContext): Array<() => void> {
|
|
20
20
|
const { api, config, log, state, actions } = ctx
|
|
@@ -169,6 +169,33 @@ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => voi
|
|
|
169
169
|
|
|
170
170
|
on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event))
|
|
171
171
|
|
|
172
|
+
// ── tool execution spans (V2 tool.* events; V1 has none) ──
|
|
173
|
+
// Feeds per-turn tool intervals so frozen TPS can subtract tool time from
|
|
174
|
+
// the turn-anchored window. Unknown shapes are ignored defensively.
|
|
175
|
+
on("session.tool.called", (event) => {
|
|
176
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
177
|
+
if (sessionID.length === 0) return
|
|
178
|
+
recordEventAlias(event, sessionID)
|
|
179
|
+
recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
180
|
+
actions.notify()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
on("session.tool.success", (event) => {
|
|
184
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
185
|
+
if (sessionID.length === 0) return
|
|
186
|
+
recordEventAlias(event, sessionID)
|
|
187
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
188
|
+
actions.notify()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
on("session.tool.failed", (event) => {
|
|
192
|
+
const sessionID = stringEventProperty(event, "sessionID")
|
|
193
|
+
if (sessionID.length === 0) return
|
|
194
|
+
recordEventAlias(event, sessionID)
|
|
195
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()))
|
|
196
|
+
actions.notify()
|
|
197
|
+
})
|
|
198
|
+
|
|
172
199
|
on("session.next.step.ended", (event) => {
|
|
173
200
|
const sessionID = stringEventProperty(event, "sessionID")
|
|
174
201
|
const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event)
|
package/src/metrics.ts
CHANGED
|
@@ -127,6 +127,22 @@ export function getTtft(m: RequestMetrics): number | null {
|
|
|
127
127
|
return gateTtft(m.firstTokenTime - m.requestStartTime)
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Upper bound for a believable token rate. Mirrors the live-TPS sanity gate:
|
|
132
|
+
* per-delta times may be delivery-stamped (batched flushes), collapsing the
|
|
133
|
+
* generation window to milliseconds and inflating the quotient absurdly
|
|
134
|
+
* (e.g. 61 tokens / 42ms = 1450 t/s — physically impossible decoding).
|
|
135
|
+
* Windows implying a faster rate fall back to turn-anchored timing.
|
|
136
|
+
*/
|
|
137
|
+
export const MAX_CREDIBLE_TPS = 300
|
|
138
|
+
|
|
139
|
+
export function credibleTps(tokens: number, windowMs: number): number | null {
|
|
140
|
+
if (!(tokens > 0) || !(windowMs > 0)) return null
|
|
141
|
+
const tps = Math.round((tokens / (windowMs / 1000)) * 10) / 10
|
|
142
|
+
if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS) return null
|
|
143
|
+
return tps
|
|
144
|
+
}
|
|
145
|
+
|
|
130
146
|
/**
|
|
131
147
|
* Calculate average token speed (tokens/second).
|
|
132
148
|
* Based on cumulative streaming time (now - firstTokenTime).
|
package/src/tui-v2.tsx
CHANGED
|
@@ -114,6 +114,9 @@ const V2_EVENT_MAP: Readonly<Record<string, string | null>> = {
|
|
|
114
114
|
"session.next.reasoning.delta": "session.reasoning.delta",
|
|
115
115
|
"session.next.step.started": "session.step.started",
|
|
116
116
|
"session.next.step.ended": "session.step.ended",
|
|
117
|
+
"session.tool.called": "session.tool.called",
|
|
118
|
+
"session.tool.success": "session.tool.success",
|
|
119
|
+
"session.tool.failed": "session.tool.failed",
|
|
117
120
|
}
|
|
118
121
|
|
|
119
122
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
package/src/turn-state.ts
CHANGED
|
@@ -14,6 +14,7 @@ export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
|
|
|
14
14
|
hasStickyContextTokens: false,
|
|
15
15
|
stickyCacheReadTokens: 0,
|
|
16
16
|
hasStickyCacheReadTokens: false,
|
|
17
|
+
toolIntervals: [],
|
|
17
18
|
isComplete: false,
|
|
18
19
|
}
|
|
19
20
|
}
|
|
@@ -95,6 +96,75 @@ export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
|
|
|
95
96
|
if (turn.firstTokenTime === null) turn.firstTokenTime = now
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Record a tool execution span on the session's turn. Tools in one turn may
|
|
101
|
+
* overlap (parallel calls), so every span is kept and the union is computed
|
|
102
|
+
* at query time — a plain sum would double-count overlaps.
|
|
103
|
+
*/
|
|
104
|
+
export function recordToolCalled(
|
|
105
|
+
turns: Map<string, TurnMetrics>,
|
|
106
|
+
sessionID: string,
|
|
107
|
+
toolID: string,
|
|
108
|
+
now: number,
|
|
109
|
+
): void {
|
|
110
|
+
const turn = ensureTurn(turns, sessionID, now)
|
|
111
|
+
turn.toolIntervals.push({ id: toolID, start: now, end: null })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function recordToolSettled(
|
|
115
|
+
turns: Map<string, TurnMetrics>,
|
|
116
|
+
sessionID: string,
|
|
117
|
+
toolID: string,
|
|
118
|
+
now: number,
|
|
119
|
+
): void {
|
|
120
|
+
const turn = turns.get(sessionID)
|
|
121
|
+
if (!turn) return
|
|
122
|
+
if (toolID) {
|
|
123
|
+
const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null)
|
|
124
|
+
if (open) {
|
|
125
|
+
open.end = Math.max(now, open.start)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Unknown id (or unsettled shape): close the most recent open span so a
|
|
130
|
+
// lost settle event cannot leak an infinite interval.
|
|
131
|
+
for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
|
|
132
|
+
if (turn.toolIntervals[i]!.end === null) {
|
|
133
|
+
turn.toolIntervals[i]!.end = Math.max(now, turn.toolIntervals[i]!.start)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Milliseconds of tool execution overlapping [from, to], unioned so parallel
|
|
141
|
+
* tool calls count once. Open spans are clamped at `to`.
|
|
142
|
+
*/
|
|
143
|
+
export function toolOverlapMs(turn: TurnMetrics | undefined, from: number, to: number): number {
|
|
144
|
+
if (!turn || to <= from) return 0
|
|
145
|
+
const spans: Array<{ start: number; end: number }> = []
|
|
146
|
+
for (const interval of turn.toolIntervals) {
|
|
147
|
+
const start = Math.max(interval.start, from)
|
|
148
|
+
const end = Math.min(interval.end ?? to, to)
|
|
149
|
+
if (end > start) spans.push({ start, end })
|
|
150
|
+
}
|
|
151
|
+
spans.sort((a, b) => a.start - b.start)
|
|
152
|
+
let total = 0
|
|
153
|
+
let cursor: number | null = null
|
|
154
|
+
let cursorEnd = 0
|
|
155
|
+
for (const span of spans) {
|
|
156
|
+
if (cursor === null || span.start > cursorEnd) {
|
|
157
|
+
if (cursor !== null) total += cursorEnd - cursor
|
|
158
|
+
cursor = span.start
|
|
159
|
+
cursorEnd = span.end
|
|
160
|
+
} else if (span.end > cursorEnd) {
|
|
161
|
+
cursorEnd = span.end
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (cursor !== null) total += cursorEnd - cursor
|
|
165
|
+
return total
|
|
166
|
+
}
|
|
167
|
+
|
|
98
168
|
/**
|
|
99
169
|
* Turn-level TTFT (user message → first token), gated. Falls back to the
|
|
100
170
|
* supplied per-request TTFT when the turn has no first-token timing. This is
|
package/src/types.ts
CHANGED
|
@@ -34,6 +34,12 @@ export interface FinalizedStepUsage {
|
|
|
34
34
|
exact: boolean
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
export interface ToolInterval {
|
|
38
|
+
readonly id: string
|
|
39
|
+
readonly start: number
|
|
40
|
+
end: number | null // null = still running
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
export interface TurnMetrics {
|
|
38
44
|
sessionID: string
|
|
39
45
|
turnStartTime: number
|
|
@@ -46,6 +52,7 @@ export interface TurnMetrics {
|
|
|
46
52
|
hasStickyContextTokens: boolean
|
|
47
53
|
stickyCacheReadTokens: number
|
|
48
54
|
hasStickyCacheReadTokens: boolean
|
|
55
|
+
toolIntervals: ToolInterval[] // Tool execution spans (V2 tool.* events; empty on V1)
|
|
49
56
|
isComplete: boolean
|
|
50
57
|
}
|
|
51
58
|
|