pi-show-token-rate 0.0.0-stage → 0.1.0
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/LICENSE +9 -0
- package/README.md +117 -2
- package/package.json +34 -4
- package/src/format.ts +105 -0
- package/src/index.ts +74 -0
- package/src/line.ts +233 -0
- package/src/rate.ts +322 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
The MIT License
|
|
2
|
+
|
|
3
|
+
Copyright 2026-present Luo Huidong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,118 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Show Token Rate For Pi Coding Agent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A [pi](https://github.com/earendil-works/pi) extension that shows how fast the
|
|
4
|
+
active model is streaming output tokens, in the bottom status bar of the TUI.
|
|
5
|
+
|
|
6
|
+
```plaintext
|
|
7
|
+
⚡ 62 tok/s
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
While the model streams, the number is a **live** trailing-window rate. Once the
|
|
11
|
+
response settles, the line keeps the last measured rate plus a session average:
|
|
12
|
+
|
|
13
|
+
```plaintext
|
|
14
|
+
⚡ 62 tok/s · avg 55
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The line is provider-agnostic: it measures whatever model is active, so there is
|
|
18
|
+
no activation condition and nothing to configure per provider.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Install from npm:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pi install npm:pi-show-token-rate
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
For a one-off test run without installing:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pi -e npm:pi-show-token-rate
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What the bar shows
|
|
35
|
+
|
|
36
|
+
| Segment | Meaning |
|
|
37
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------- |
|
|
38
|
+
| `⚡` | Label, always dim |
|
|
39
|
+
| `~` | Present only when the number is estimated rather than provider-reported (see **Accuracy** below) |
|
|
40
|
+
| `62` | Output tokens per second: live while streaming, the last response's rate once settled, rounded to whole tok/s |
|
|
41
|
+
| `tok/s` | Unit, dim |
|
|
42
|
+
| `· avg 55` | Duration-weighted average across completed responses; hidden until a second response finishes |
|
|
43
|
+
|
|
44
|
+
The rate is colored green at **50 tok/s** and above, yellow at **20 tok/s** and
|
|
45
|
+
above, and dim below that — a slow model reads as quiet, not as an error.
|
|
46
|
+
|
|
47
|
+
The row stays blank until a response produces a measurable rate: the first
|
|
48
|
+
token of a stream cannot be timed on its own. A response that ends without a
|
|
49
|
+
measurable rate — an empty reply, or `/token-rate reset` — shows
|
|
50
|
+
`⚡ tok/s —` instead.
|
|
51
|
+
|
|
52
|
+
## How the rate is measured
|
|
53
|
+
|
|
54
|
+
- **Generation window only.** The clock starts at the first streamed token, not
|
|
55
|
+
at the request. Prompt prefill and time-to-first-token are excluded, so the
|
|
56
|
+
number answers "how fast does this model generate", not "how long did the
|
|
57
|
+
request take".
|
|
58
|
+
- **Trailing window while streaming.** Live updates average the last 3 seconds
|
|
59
|
+
of streaming. Providers emit deltas in bursts and stall while writing a
|
|
60
|
+
thinking block or tool-call JSON, so a per-event rate would flicker between 0
|
|
61
|
+
and several hundred tok/s.
|
|
62
|
+
- **Whole-response average once settled.** When `message_end` arrives, the
|
|
63
|
+
response is scored over its full generation window and folded into the
|
|
64
|
+
session average.
|
|
65
|
+
|
|
66
|
+
## Accuracy
|
|
67
|
+
|
|
68
|
+
Not every provider reports token usage while a response is streaming:
|
|
69
|
+
|
|
70
|
+
- When `usage.output` is available (Anthropic and other providers that stream
|
|
71
|
+
usage), the extension uses it — the number is exact.
|
|
72
|
+
- When it is not (OpenAI-compatible endpoints typically send usage only on the
|
|
73
|
+
final chunk), the extension estimates tokens as **streamed characters ÷ 4**,
|
|
74
|
+
the same heuristic pi itself uses for context estimation. Estimated numbers
|
|
75
|
+
are marked with a leading `~` so they are never mistaken for reported ones.
|
|
76
|
+
- If usage only starts arriving halfway through a stream, the live number keeps
|
|
77
|
+
its `~` and its scale until the response ends — mixing a reported count into
|
|
78
|
+
a chars ÷ 4 window would spike the rate. The settled number is the exact
|
|
79
|
+
one, and the `~` disappears with it.
|
|
80
|
+
|
|
81
|
+
The same fallback is used to keep counting when a tool call is streamed as
|
|
82
|
+
JSON, so tool-heavy turns still measure sensibly.
|
|
83
|
+
|
|
84
|
+
## Slash command
|
|
85
|
+
|
|
86
|
+
| Command | Effect |
|
|
87
|
+
| ------------------- | --------------------------------------------------------------------------- |
|
|
88
|
+
| `/token-rate` | Toggle the status line on or off |
|
|
89
|
+
| `/token-rate stats` | Notify with the last / average / peak rate and how many responses were seen |
|
|
90
|
+
| `/token-rate reset` | Drop collected statistics, keeping the current visibility |
|
|
91
|
+
|
|
92
|
+
`stats` output looks like:
|
|
93
|
+
|
|
94
|
+
```plaintext
|
|
95
|
+
last 62 tok/s · avg 55 tok/s · peak 91 tok/s · 12 responses · 3 estimated
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
Requires the workspace's Node version (`>=26`): the suite is plain
|
|
101
|
+
`node --test`, which relies on Node running `.ts` files through its built-in
|
|
102
|
+
type stripping.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pnpm install
|
|
106
|
+
pnpm test # node --test
|
|
107
|
+
pnpm typecheck # tsc --noEmit
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Tests cover the character estimator, the tracker (windowing, monotonicity,
|
|
111
|
+
pruning), the summary math, the exact status-line strings, and the controller's
|
|
112
|
+
throttling and toggle behavior. The suite drives an injected clock, so no test
|
|
113
|
+
depends on wall-clock timing, and it uses a stub theme instead of the real ANSI
|
|
114
|
+
palette.
|
|
115
|
+
|
|
116
|
+
## License
|
|
117
|
+
|
|
118
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-show-token-rate",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shows the live output token rate (tok/s) of the current model in the pi TUI status bar.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"tokens",
|
|
9
|
+
"token-rate",
|
|
10
|
+
"tps",
|
|
11
|
+
"tui"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"type": "module",
|
|
15
|
+
"files": [
|
|
16
|
+
"src/",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"pi": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./src/index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "node --test",
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"prepublishOnly": "pnpm typecheck && pnpm test"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "^0.85.1"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI formatting — pure functions, no side effects, no I/O.
|
|
3
|
+
*
|
|
4
|
+
* The status line is intentionally terse so it fits next to pi's own footer
|
|
5
|
+
* stats without crowding them:
|
|
6
|
+
*
|
|
7
|
+
* ⚡ 62 tok/s while the model streams (trailing-window rate)
|
|
8
|
+
* ⚡ 62 tok/s · avg 55 once the response settles
|
|
9
|
+
* ⚡ ~62 tok/s a `~` marks a chars ÷ 4 estimate instead of real usage
|
|
10
|
+
* ⚡ tok/s — nothing measured yet
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
|
|
15
|
+
import type { RateSummary } from "./rate.ts";
|
|
16
|
+
|
|
17
|
+
export type Theme = ExtensionContext["ui"]["theme"];
|
|
18
|
+
|
|
19
|
+
const LABEL = "⚡";
|
|
20
|
+
export const RATE_UNIT = "tok/s";
|
|
21
|
+
|
|
22
|
+
/** At or above this many tok/s the rate is green. */
|
|
23
|
+
const FAST_MIN = 50;
|
|
24
|
+
/** At or above this many tok/s the rate is yellow; below that it is dim. */
|
|
25
|
+
const SLOW_MIN = 20;
|
|
26
|
+
|
|
27
|
+
/** Everything the status line can render. */
|
|
28
|
+
export interface RateView {
|
|
29
|
+
/** Tokens per second to show; omitted when nothing has been measured. */
|
|
30
|
+
rate?: number;
|
|
31
|
+
/** Mark the rate as estimated (`~` prefix) rather than provider-reported. */
|
|
32
|
+
estimated?: boolean;
|
|
33
|
+
/** Session average, appended as `· avg NN` when present. */
|
|
34
|
+
average?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rate → short display string. Sub-10 values keep one decimal (a slow model
|
|
39
|
+
* is exactly where precision is interesting); everything above is rounded to
|
|
40
|
+
* whole tokens so the footer does not jitter.
|
|
41
|
+
*
|
|
42
|
+
* The value is rounded to one decimal *first*, so 9.99 renders as `10`
|
|
43
|
+
* instead of `10.0` — the same shape 10.0 gets.
|
|
44
|
+
*/
|
|
45
|
+
export function formatRate(value: number): string {
|
|
46
|
+
if (!Number.isFinite(value) || value <= 0) return "—";
|
|
47
|
+
const rounded = Math.round(value * 10) / 10;
|
|
48
|
+
if (rounded < 10) return rounded.toFixed(1);
|
|
49
|
+
return String(Math.round(rounded));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function rateColor(theme: Theme, value: number): string {
|
|
53
|
+
const text = formatRate(value);
|
|
54
|
+
if (value >= FAST_MIN) return theme.fg("success", text);
|
|
55
|
+
if (value >= SLOW_MIN) return theme.fg("warning", text);
|
|
56
|
+
// Slow is not an error — keep it readable but out of the way.
|
|
57
|
+
return theme.fg("dim", text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatStatusLine(theme: Theme, view: RateView): string {
|
|
61
|
+
const label = theme.fg("dim", LABEL);
|
|
62
|
+
if (
|
|
63
|
+
view.rate === undefined ||
|
|
64
|
+
!Number.isFinite(view.rate) ||
|
|
65
|
+
view.rate <= 0
|
|
66
|
+
) {
|
|
67
|
+
return [label, " ", theme.fg("dim", `${RATE_UNIT} —`)].join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parts = [
|
|
71
|
+
label,
|
|
72
|
+
" ",
|
|
73
|
+
view.estimated ? theme.fg("dim", "~") : "",
|
|
74
|
+
rateColor(theme, view.rate),
|
|
75
|
+
" ",
|
|
76
|
+
theme.fg("dim", RATE_UNIT),
|
|
77
|
+
];
|
|
78
|
+
if (
|
|
79
|
+
view.average !== undefined &&
|
|
80
|
+
Number.isFinite(view.average) &&
|
|
81
|
+
view.average > 0
|
|
82
|
+
) {
|
|
83
|
+
parts.push(
|
|
84
|
+
theme.fg("dim", " · avg "),
|
|
85
|
+
theme.fg("dim", formatRate(view.average)),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return parts.join("");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One-line summary for the `/token-rate stats` notification. */
|
|
92
|
+
export function formatSummary(summary: RateSummary): string {
|
|
93
|
+
if (summary.count === 0 || !summary.last) return "no completed responses yet";
|
|
94
|
+
|
|
95
|
+
const parts = [
|
|
96
|
+
`last ${formatRate(summary.last.tokPerSec)} ${RATE_UNIT}`,
|
|
97
|
+
`avg ${formatRate(summary.average ?? 0)} ${RATE_UNIT}`,
|
|
98
|
+
`peak ${formatRate(summary.peak ?? 0)} ${RATE_UNIT}`,
|
|
99
|
+
`${summary.count} ${summary.count === 1 ? "response" : "responses"}`,
|
|
100
|
+
];
|
|
101
|
+
if (summary.estimated > 0) {
|
|
102
|
+
parts.push(`${summary.estimated} estimated`);
|
|
103
|
+
}
|
|
104
|
+
return parts.join(" · ");
|
|
105
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token rate for the pi TUI footer.
|
|
3
|
+
*
|
|
4
|
+
* Watches the assistant message stream and shows how fast the active model
|
|
5
|
+
* is producing output tokens:
|
|
6
|
+
*
|
|
7
|
+
* ⚡ 62 tok/s live, while the model streams
|
|
8
|
+
* ⚡ 62 tok/s · avg 55 the last response plus the session average
|
|
9
|
+
*
|
|
10
|
+
* The rate is measured over the generation window (first token → last token)
|
|
11
|
+
* so prompt prefill does not drag it down, and the live number is a
|
|
12
|
+
* trailing-window average so it stays readable between bursty deltas.
|
|
13
|
+
*
|
|
14
|
+
* This file only wires events to the controller in `./line.ts`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
|
|
19
|
+
import { formatSummary } from "./format.ts";
|
|
20
|
+
import { TokenRateLine } from "./line.ts";
|
|
21
|
+
|
|
22
|
+
const USAGE = "token-rate: /token-rate [stats|reset]";
|
|
23
|
+
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
const line = new TokenRateLine();
|
|
26
|
+
|
|
27
|
+
pi.on("session_start", (_event, ctx) => {
|
|
28
|
+
line.start(ctx);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
pi.on("message_start", (event, ctx) => {
|
|
32
|
+
line.onMessageStart(ctx, event.message);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
pi.on("message_update", (event, ctx) => {
|
|
36
|
+
line.onUpdate(ctx, event);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
pi.on("message_end", (event, ctx) => {
|
|
40
|
+
line.onMessageEnd(ctx, event.message);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
pi.registerCommand("token-rate", {
|
|
44
|
+
description: "Show or hide the tok/s line in the status bar",
|
|
45
|
+
handler: async (args, ctx) => {
|
|
46
|
+
const sub = args.trim().toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (sub === "stats") {
|
|
49
|
+
ctx.ui.notify(`token-rate: ${formatSummary(line.summary())}`, "info");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!ctx.hasUI) {
|
|
53
|
+
ctx.ui.notify("token-rate: no UI in this mode", "warning");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (sub === "reset") {
|
|
57
|
+
line.reset(ctx);
|
|
58
|
+
ctx.ui.notify("token-rate: statistics cleared", "info");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (sub !== "") {
|
|
62
|
+
ctx.ui.notify(USAGE, "warning");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ctx.ui.notify(
|
|
67
|
+
line.toggle(ctx)
|
|
68
|
+
? "token-rate: status line on"
|
|
69
|
+
: "token-rate: status line off",
|
|
70
|
+
"info",
|
|
71
|
+
);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
package/src/line.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-rate status line controller.
|
|
3
|
+
*
|
|
4
|
+
* Owns the footer entry: it observes assistant stream events, turns them
|
|
5
|
+
* into a rate, throttles the writes so a 100 tok/s stream does not ask the
|
|
6
|
+
* TUI to re-render 100 times a second, and keeps the session statistics the
|
|
7
|
+
* line and `/token-rate stats` read from.
|
|
8
|
+
*
|
|
9
|
+
* The controller holds no timers and no handles — pi delivers stream events
|
|
10
|
+
* for the lifetime of a response, and `message_end` is the last one. That
|
|
11
|
+
* keeps `session_shutdown` a no-op and avoids the "start resources in
|
|
12
|
+
* `session_start`" trap the extension API warns about.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
ExtensionContext,
|
|
17
|
+
MessageEndEvent,
|
|
18
|
+
MessageUpdateEvent,
|
|
19
|
+
} from "@earendil-works/pi-coding-agent";
|
|
20
|
+
|
|
21
|
+
import { formatStatusLine } from "./format.ts";
|
|
22
|
+
import {
|
|
23
|
+
countContentChars,
|
|
24
|
+
estimateTokensFromChars,
|
|
25
|
+
type RateSample,
|
|
26
|
+
type RateSummary,
|
|
27
|
+
RateTracker,
|
|
28
|
+
summarize,
|
|
29
|
+
} from "./rate.ts";
|
|
30
|
+
|
|
31
|
+
/** Footer row identifier. Locked: other extensions may look the row up. */
|
|
32
|
+
const STATUS_KEY = "token-rate";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Minimum gap between footer writes while streaming. 150ms is ~6 updates per
|
|
36
|
+
* second — fast enough to look live, slow enough to stay off the render path
|
|
37
|
+
* of every token delta.
|
|
38
|
+
*/
|
|
39
|
+
const WRITE_INTERVAL_MS = 150;
|
|
40
|
+
|
|
41
|
+
/** Cap on retained samples, so a very long session cannot grow unbounded. */
|
|
42
|
+
const MAX_SAMPLES = 200;
|
|
43
|
+
|
|
44
|
+
type StreamEvent = MessageUpdateEvent["assistantMessageEvent"];
|
|
45
|
+
type AssistantMessage = Extract<
|
|
46
|
+
MessageEndEvent["message"],
|
|
47
|
+
{ role: "assistant" }
|
|
48
|
+
>;
|
|
49
|
+
|
|
50
|
+
/** Characters carried by a token-bearing stream event; 0 for lifecycle events. */
|
|
51
|
+
function deltaChars(event: StreamEvent): number {
|
|
52
|
+
switch (event.type) {
|
|
53
|
+
case "text_delta":
|
|
54
|
+
case "thinking_delta":
|
|
55
|
+
case "toolcall_delta":
|
|
56
|
+
return event.delta.length;
|
|
57
|
+
default:
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Stop reasons that end a response without finishing it. pi still emits
|
|
64
|
+
* `message_end` for these, and the window they produce is a truncated sample
|
|
65
|
+
* of a model that was cut off — folding it into `avg` / `peak` would bias both.
|
|
66
|
+
*/
|
|
67
|
+
function isDroppedResponse(message: AssistantMessage): boolean {
|
|
68
|
+
return message.stopReason === "aborted" || message.stopReason === "error";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class TokenRateLine {
|
|
72
|
+
#tracker = new RateTracker();
|
|
73
|
+
#samples: RateSample[] = [];
|
|
74
|
+
#chars = 0;
|
|
75
|
+
#visible = true;
|
|
76
|
+
#lastWriteAt = Number.NEGATIVE_INFINITY;
|
|
77
|
+
#rendered: string | undefined;
|
|
78
|
+
readonly #now: () => number;
|
|
79
|
+
|
|
80
|
+
/** `now` is injectable so tests can drive the throttle deterministically. */
|
|
81
|
+
constructor(now: () => number = Date.now) {
|
|
82
|
+
this.#now = now;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Session start: forget everything, including any line a reload left behind. */
|
|
86
|
+
start(ctx: ExtensionContext): void {
|
|
87
|
+
this.#tracker.abort();
|
|
88
|
+
this.#samples = [];
|
|
89
|
+
this.#chars = 0;
|
|
90
|
+
this.#visible = true;
|
|
91
|
+
this.#lastWriteAt = Number.NEGATIVE_INFINITY;
|
|
92
|
+
this.#rendered = undefined;
|
|
93
|
+
this.#write(ctx, undefined);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A new assistant message begins; the generation window reopens here. */
|
|
97
|
+
onMessageStart(
|
|
98
|
+
ctx: ExtensionContext,
|
|
99
|
+
message: MessageEndEvent["message"],
|
|
100
|
+
): void {
|
|
101
|
+
if (!ctx.hasUI || message.role !== "assistant") return;
|
|
102
|
+
this.#tracker.arm();
|
|
103
|
+
this.#chars = 0;
|
|
104
|
+
// The next delta writes immediately instead of waiting out an old throttle.
|
|
105
|
+
this.#lastWriteAt = Number.NEGATIVE_INFINITY;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Stream progress. Throttled: the tracker always sees every observation. */
|
|
109
|
+
onUpdate(ctx: ExtensionContext, event: MessageUpdateEvent): void {
|
|
110
|
+
if (!ctx.hasUI) return;
|
|
111
|
+
const message = event.message;
|
|
112
|
+
if (message.role !== "assistant") return;
|
|
113
|
+
|
|
114
|
+
const chars = deltaChars(event.assistantMessageEvent);
|
|
115
|
+
if (chars === 0) return;
|
|
116
|
+
|
|
117
|
+
const now = this.#now();
|
|
118
|
+
this.#chars += chars;
|
|
119
|
+
const usage = message.usage.output;
|
|
120
|
+
const reported = Number.isFinite(usage) && usage > 0;
|
|
121
|
+
this.#tracker.observe(
|
|
122
|
+
now,
|
|
123
|
+
reported ? usage : estimateTokensFromChars(this.#chars),
|
|
124
|
+
reported ? "usage" : "estimate",
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
if (now - this.#lastWriteAt < WRITE_INTERVAL_MS) return;
|
|
128
|
+
this.#lastWriteAt = now;
|
|
129
|
+
this.#render(ctx, true);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The response is done: record the sample and repaint the settled rate. */
|
|
133
|
+
onMessageEnd(
|
|
134
|
+
ctx: ExtensionContext,
|
|
135
|
+
message: MessageEndEvent["message"],
|
|
136
|
+
): void {
|
|
137
|
+
if (!ctx.hasUI || message.role !== "assistant") return;
|
|
138
|
+
|
|
139
|
+
if (isDroppedResponse(message)) {
|
|
140
|
+
// Interrupted or failed: keep the previous settled line instead of
|
|
141
|
+
// showing a rate that was never really measured.
|
|
142
|
+
this.#tracker.abort();
|
|
143
|
+
this.#lastWriteAt = Number.NEGATIVE_INFINITY;
|
|
144
|
+
this.#render(ctx, false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const usage = message.usage.output;
|
|
149
|
+
const reported = Number.isFinite(usage) && usage > 0;
|
|
150
|
+
// Without provider usage, prefer the richest estimate available: chars
|
|
151
|
+
// accumulated from deltas, or the finished content if it holds more.
|
|
152
|
+
// Counting the content is the expensive half (it stringifies tool-call
|
|
153
|
+
// arguments), so it stays behind the `reported` check.
|
|
154
|
+
const tokens = reported
|
|
155
|
+
? usage
|
|
156
|
+
: Math.max(
|
|
157
|
+
estimateTokensFromChars(this.#chars),
|
|
158
|
+
estimateTokensFromChars(countContentChars(message.content)),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const sample = this.#tracker.finish(
|
|
162
|
+
this.#now(),
|
|
163
|
+
tokens,
|
|
164
|
+
reported ? "usage" : "estimate",
|
|
165
|
+
);
|
|
166
|
+
if (sample) {
|
|
167
|
+
this.#samples.push(sample);
|
|
168
|
+
if (this.#samples.length > MAX_SAMPLES) this.#samples.shift();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
this.#lastWriteAt = Number.NEGATIVE_INFINITY;
|
|
172
|
+
this.#render(ctx, false);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Flip the line on or off. Returns the new visibility. */
|
|
176
|
+
toggle(ctx: ExtensionContext): boolean {
|
|
177
|
+
this.#visible = !this.#visible;
|
|
178
|
+
this.#render(ctx, this.#tracker.streaming);
|
|
179
|
+
return this.#visible;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Forget collected samples, keep the current visibility. */
|
|
183
|
+
reset(ctx: ExtensionContext): void {
|
|
184
|
+
this.#samples = [];
|
|
185
|
+
this.#render(ctx, this.#tracker.streaming);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
summary(): RateSummary {
|
|
189
|
+
return summarize(this.#samples);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Live rate while streaming, last response's rate once settled. */
|
|
193
|
+
#render(ctx: ExtensionContext, streaming: boolean): void {
|
|
194
|
+
if (!this.#visible) {
|
|
195
|
+
// Forget the cached line so showing it again always repaints — but
|
|
196
|
+
// don't re-clear a row that is already blank on every stream tick.
|
|
197
|
+
if (this.#rendered === undefined) return;
|
|
198
|
+
this.#rendered = undefined;
|
|
199
|
+
this.#write(ctx, undefined);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const live = streaming ? this.#tracker.liveRate() : null;
|
|
204
|
+
// Nothing measurable yet for this response (no token inside the window) —
|
|
205
|
+
// keep the previous line instead of blanking it out mid-stream.
|
|
206
|
+
if (streaming && live === null) return;
|
|
207
|
+
|
|
208
|
+
const summary = this.summary();
|
|
209
|
+
const rate = live ?? summary.last?.tokPerSec;
|
|
210
|
+
// The live rate is a difference of the window's checkpoints, so it counts
|
|
211
|
+
// as estimated exactly when the window opened on estimates — a provider
|
|
212
|
+
// that starts reporting usage mid-stream keeps the `~` until the response
|
|
213
|
+
// settles on the reported count.
|
|
214
|
+
const source =
|
|
215
|
+
live !== null ? this.#tracker.windowSource : summary.last?.source;
|
|
216
|
+
|
|
217
|
+
const text = formatStatusLine(ctx.ui.theme, {
|
|
218
|
+
rate,
|
|
219
|
+
estimated: source === "estimate",
|
|
220
|
+
// One response has no "average" — it would just repeat the last value.
|
|
221
|
+
average: summary.count >= 2 ? summary.average : undefined,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
if (text === this.#rendered) return;
|
|
225
|
+
this.#rendered = text;
|
|
226
|
+
this.#write(ctx, text);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#write(ctx: ExtensionContext, text: string | undefined): void {
|
|
230
|
+
if (!ctx.hasUI) return;
|
|
231
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/rate.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-rate math.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions plus one small per-response tracker. Nothing here knows
|
|
5
|
+
* about pi, the terminal, or the network: the caller feeds it `(timestamp,
|
|
6
|
+
* output tokens)` observations and gets rates back, which keeps every rule
|
|
7
|
+
* below unit-testable in isolation.
|
|
8
|
+
*
|
|
9
|
+
* Two rules drive the whole module:
|
|
10
|
+
*
|
|
11
|
+
* 1. **Rate is measured over the generation window** — from the first
|
|
12
|
+
* observed token to the last one. Prompt prefill / time-to-first-token
|
|
13
|
+
* is deliberately excluded, because "how fast does this model stream"
|
|
14
|
+
* means generation speed, not request latency.
|
|
15
|
+
* 2. **The live number is a trailing-window rate**, not an instantaneous
|
|
16
|
+
* one. Providers emit token deltas in bursts (and stall while writing a
|
|
17
|
+
* tool call or a thinking block), so a per-event rate flickers between
|
|
18
|
+
* 0 and several hundred tok/s. Averaging over the last few seconds
|
|
19
|
+
* smooths that out without lagging far behind reality.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Chars per token for the fallback estimator. Matches pi's own
|
|
24
|
+
* `estimateTokens` heuristic (content length ÷ 4), which is what the TUI
|
|
25
|
+
* uses when a provider has not reported usage yet.
|
|
26
|
+
*/
|
|
27
|
+
const CHARS_PER_TOKEN = 4;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Trailing window for the live rate. Long enough to smooth bursty deltas,
|
|
31
|
+
* short enough that a real slowdown (thinking, tool-call JSON) still shows up
|
|
32
|
+
* within a couple of seconds.
|
|
33
|
+
*/
|
|
34
|
+
export const LIVE_WINDOW_MS = 3_000;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Where a token count came from:
|
|
38
|
+
*
|
|
39
|
+
* - `usage` — the provider reported real output tokens (authoritative)
|
|
40
|
+
* - `estimate` — derived from streamed characters ÷ 4 (providers such as
|
|
41
|
+
* OpenAI-compatible endpoints only send usage on the final chunk)
|
|
42
|
+
*/
|
|
43
|
+
export type TokenSource = "usage" | "estimate";
|
|
44
|
+
|
|
45
|
+
export interface RateSample {
|
|
46
|
+
/** Output tokens produced inside the measured window. */
|
|
47
|
+
tokens: number;
|
|
48
|
+
/** Generation window in milliseconds (first token → last token). */
|
|
49
|
+
elapsedMs: number;
|
|
50
|
+
/** Tokens per second over that window. */
|
|
51
|
+
tokPerSec: number;
|
|
52
|
+
/** Whether `tokens` is a real count or a chars ÷ 4 estimate. */
|
|
53
|
+
source: TokenSource;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Checkpoint {
|
|
57
|
+
/** Wall-clock stamp in milliseconds. */
|
|
58
|
+
at: number;
|
|
59
|
+
/** Output tokens produced so far (monotonic). */
|
|
60
|
+
tokens: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The first checkpoint of a generation window, plus the scale its counter is
|
|
65
|
+
* in. A chars ÷ 4 estimate and a provider usage count are not interchangeable,
|
|
66
|
+
* so `finish()` has to know which one the window opened with.
|
|
67
|
+
*/
|
|
68
|
+
interface WindowStart extends Checkpoint {
|
|
69
|
+
source: TokenSource;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Structural subset of an assistant content block. Only the fields the
|
|
74
|
+
* estimator reads are declared, so a real `AssistantMessage["content"]` is
|
|
75
|
+
* assignable without an index signature.
|
|
76
|
+
*/
|
|
77
|
+
export interface ContentBlock {
|
|
78
|
+
type: string;
|
|
79
|
+
text?: string;
|
|
80
|
+
thinking?: string;
|
|
81
|
+
arguments?: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Chars ÷ 4 → tokens, rounded up. Non-finite or negative input → 0, so a
|
|
86
|
+
* malformed event can never poison the rate with NaN.
|
|
87
|
+
*/
|
|
88
|
+
export function estimateTokensFromChars(chars: number): number {
|
|
89
|
+
if (!Number.isFinite(chars) || chars <= 0) return 0;
|
|
90
|
+
return Math.ceil(chars / CHARS_PER_TOKEN);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Total characters of an assistant message's content: answer text, thinking
|
|
95
|
+
* text, and tool-call argument JSON. Mirrors what a tokenizer would see,
|
|
96
|
+
* which is all the fallback estimator needs.
|
|
97
|
+
*/
|
|
98
|
+
export function countContentChars(content: readonly ContentBlock[]): number {
|
|
99
|
+
let chars = 0;
|
|
100
|
+
for (const block of content) {
|
|
101
|
+
if (block.type === "toolCall") {
|
|
102
|
+
chars += argumentsChars(block.arguments);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
chars += block.text?.length ?? block.thinking?.length ?? 0;
|
|
106
|
+
}
|
|
107
|
+
return chars;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function argumentsChars(args: unknown): number {
|
|
111
|
+
if (args === undefined) return 0;
|
|
112
|
+
try {
|
|
113
|
+
return JSON.stringify(args)?.length ?? 0;
|
|
114
|
+
} catch {
|
|
115
|
+
// Cyclic or otherwise unserializable arguments — count as zero rather
|
|
116
|
+
// than tearing down the stream handler.
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Per-response rate tracker.
|
|
123
|
+
*
|
|
124
|
+
* Lifecycle: `arm()` on `message_start`, `observe()` on every token-bearing
|
|
125
|
+
* stream event, then exactly one `finish()` on `message_end` (or `abort()`
|
|
126
|
+
* when the response is dropped). The first `observe()` opens the generation
|
|
127
|
+
* window, so prefill never counts against the rate.
|
|
128
|
+
*
|
|
129
|
+
* Two checkpoint sets are kept: `#first` pins the start of the generation
|
|
130
|
+
* window so `finish()` can still average the whole response after pruning,
|
|
131
|
+
* and `#checkpoints` holds only the trailing window that `liveRate()`
|
|
132
|
+
* reads.
|
|
133
|
+
*/
|
|
134
|
+
export class RateTracker {
|
|
135
|
+
#checkpoints: Checkpoint[] = [];
|
|
136
|
+
#first: WindowStart | undefined;
|
|
137
|
+
#armed = false;
|
|
138
|
+
|
|
139
|
+
/** True between `arm()` and `finish()` / `abort()`. */
|
|
140
|
+
get armed(): boolean {
|
|
141
|
+
return this.#armed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** True once at least one token has been observed for this response. */
|
|
145
|
+
get streaming(): boolean {
|
|
146
|
+
return this.#checkpoints.length > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Scale the current window's checkpoints are counted in — the scale the
|
|
151
|
+
* window *opened* with, not the scale of the newest observation. `undefined`
|
|
152
|
+
* while nothing has been observed.
|
|
153
|
+
*
|
|
154
|
+
* Callers label the live rate from this: a provider that starts reporting
|
|
155
|
+
* usage mid-stream does not upgrade the in-flight number to exact, it just
|
|
156
|
+
* stops being believed for the rest of the window.
|
|
157
|
+
*/
|
|
158
|
+
get windowSource(): TokenSource | undefined {
|
|
159
|
+
return this.#first?.source;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Mark a response as in flight. Drops any state from the previous one. */
|
|
163
|
+
arm(): void {
|
|
164
|
+
this.#checkpoints = [];
|
|
165
|
+
this.#first = undefined;
|
|
166
|
+
this.#armed = true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Record progress. The first call opens the generation window; later calls
|
|
171
|
+
* append. Non-monotonic stamps, token counts that go backwards, and
|
|
172
|
+
* observations on a different scale than the window opened with are ignored,
|
|
173
|
+
* so a provider that resets (or reorders, or starts reporting usage halfway
|
|
174
|
+
* through) its usage counter cannot produce a spike.
|
|
175
|
+
*
|
|
176
|
+
* `source` is the scale `tokens` is counted in. Checkpoints inside one
|
|
177
|
+
* window must share it — the counters are cumulative, so mixing them would
|
|
178
|
+
* compare two different units.
|
|
179
|
+
*/
|
|
180
|
+
observe(at: number, tokens: number, source: TokenSource): void {
|
|
181
|
+
if (!this.#armed) this.arm();
|
|
182
|
+
|
|
183
|
+
const last = this.#checkpoints.at(-1);
|
|
184
|
+
if (last) {
|
|
185
|
+
if (at <= last.at || tokens < last.tokens) return;
|
|
186
|
+
// Stay on the scale the window opened with. `finish()` is where a
|
|
187
|
+
// reported count is allowed to replace an estimated one.
|
|
188
|
+
if (this.#first?.source !== source) return;
|
|
189
|
+
} else {
|
|
190
|
+
this.#first = { at, tokens, source };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
this.#checkpoints.push({ at, tokens });
|
|
194
|
+
this.#prune(at);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Rate over the trailing {@link LIVE_WINDOW_MS}, in tokens per second.
|
|
199
|
+
* `null` when nothing was observed yet, when the window holds a single
|
|
200
|
+
* point, or when no new token arrived inside it.
|
|
201
|
+
*
|
|
202
|
+
* The window length is not a parameter: `#prune` already discards anything
|
|
203
|
+
* older than `LIVE_WINDOW_MS`, so a wider window could only ever read from
|
|
204
|
+
* checkpoints that no longer exist.
|
|
205
|
+
*/
|
|
206
|
+
liveRate(): number | null {
|
|
207
|
+
const current = this.#checkpoints.at(-1);
|
|
208
|
+
if (!current) return null;
|
|
209
|
+
|
|
210
|
+
const baseline = this.#baselineAt(current.at);
|
|
211
|
+
const elapsedMs = current.at - baseline.at;
|
|
212
|
+
const tokens = current.tokens - baseline.tokens;
|
|
213
|
+
if (elapsedMs <= 0 || tokens <= 0) return null;
|
|
214
|
+
|
|
215
|
+
return (tokens * 1000) / elapsedMs;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Close the window and return the response's overall rate, or `null` when
|
|
220
|
+
* the response produced no measurable tokens (empty reply, instant abort).
|
|
221
|
+
* The rate covers the whole generation window, which is what pruning the
|
|
222
|
+
* live checkpoints would otherwise throw away.
|
|
223
|
+
*/
|
|
224
|
+
finish(at: number, tokens: number, source: TokenSource): RateSample | null {
|
|
225
|
+
const first = this.#first;
|
|
226
|
+
this.#checkpoints = [];
|
|
227
|
+
this.#first = undefined;
|
|
228
|
+
this.#armed = false;
|
|
229
|
+
|
|
230
|
+
if (!first) return null;
|
|
231
|
+
const elapsedMs = at - first.at;
|
|
232
|
+
// Tokens that were already on the counter when the window opened are
|
|
233
|
+
// excluded — they were produced during prefill. That subtraction is only
|
|
234
|
+
// meaningful when both numbers use the same scale: a provider that only
|
|
235
|
+
// reports usage on its final chunk opened the window on chars ÷ 4
|
|
236
|
+
// estimates, so there the full count is the closer approximation.
|
|
237
|
+
const produced = first.source === source ? tokens - first.tokens : tokens;
|
|
238
|
+
if (elapsedMs <= 0 || produced <= 0) return null;
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
tokens: produced,
|
|
242
|
+
elapsedMs,
|
|
243
|
+
tokPerSec: (produced * 1000) / elapsedMs,
|
|
244
|
+
source,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Drop the in-flight response without producing a sample. */
|
|
249
|
+
abort(): void {
|
|
250
|
+
this.#checkpoints = [];
|
|
251
|
+
this.#first = undefined;
|
|
252
|
+
this.#armed = false;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Newest checkpoint at/before the window start; falls back to the oldest. */
|
|
256
|
+
#baselineAt(now: number): Checkpoint {
|
|
257
|
+
const cutoff = now - LIVE_WINDOW_MS;
|
|
258
|
+
let baseline = this.#checkpoints[0];
|
|
259
|
+
for (const checkpoint of this.#checkpoints) {
|
|
260
|
+
if (checkpoint.at <= cutoff) baseline = checkpoint;
|
|
261
|
+
else break;
|
|
262
|
+
}
|
|
263
|
+
return baseline;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Keep the window's tail plus the single baseline in front of it. */
|
|
267
|
+
#prune(now: number): void {
|
|
268
|
+
const cutoff = now - LIVE_WINDOW_MS;
|
|
269
|
+
let baseline: Checkpoint | undefined;
|
|
270
|
+
const kept: Checkpoint[] = [];
|
|
271
|
+
for (const checkpoint of this.#checkpoints) {
|
|
272
|
+
if (checkpoint.at <= cutoff) baseline = checkpoint;
|
|
273
|
+
else kept.push(checkpoint);
|
|
274
|
+
}
|
|
275
|
+
this.#checkpoints = baseline ? [baseline, ...kept] : kept;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export interface RateSummary {
|
|
280
|
+
/** Number of completed responses. */
|
|
281
|
+
count: number;
|
|
282
|
+
/** Most recent completed response. */
|
|
283
|
+
last: RateSample | undefined;
|
|
284
|
+
/** Duration-weighted mean rate across completed responses. */
|
|
285
|
+
average: number | undefined;
|
|
286
|
+
/** Fastest completed response. */
|
|
287
|
+
peak: number | undefined;
|
|
288
|
+
/** How many of the samples came from the chars ÷ 4 fallback. */
|
|
289
|
+
estimated: number;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Aggregate completed samples into the numbers the status line and `/token-rate stats` show. */
|
|
293
|
+
export function summarize(samples: readonly RateSample[]): RateSummary {
|
|
294
|
+
if (samples.length === 0) {
|
|
295
|
+
return {
|
|
296
|
+
count: 0,
|
|
297
|
+
last: undefined,
|
|
298
|
+
average: undefined,
|
|
299
|
+
peak: undefined,
|
|
300
|
+
estimated: 0,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
let tokens = 0;
|
|
305
|
+
let elapsedMs = 0;
|
|
306
|
+
let peak = Number.NEGATIVE_INFINITY;
|
|
307
|
+
let estimated = 0;
|
|
308
|
+
for (const sample of samples) {
|
|
309
|
+
tokens += sample.tokens;
|
|
310
|
+
elapsedMs += sample.elapsedMs;
|
|
311
|
+
if (sample.tokPerSec > peak) peak = sample.tokPerSec;
|
|
312
|
+
if (sample.source === "estimate") estimated += 1;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
count: samples.length,
|
|
317
|
+
last: samples[samples.length - 1],
|
|
318
|
+
average: elapsedMs > 0 ? (tokens * 1000) / elapsedMs : undefined,
|
|
319
|
+
peak: Number.isFinite(peak) ? peak : undefined,
|
|
320
|
+
estimated,
|
|
321
|
+
};
|
|
322
|
+
}
|