pi-show-token-rate 0.1.0 → 0.2.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/README.md +91 -57
- package/package.json +10 -1
- package/src/format.ts +47 -61
- package/src/index.ts +8 -23
- package/src/line.ts +96 -104
- package/src/rate/estimate.ts +97 -0
- package/src/rate/index.ts +44 -0
- package/src/rate/tracker.ts +106 -0
- package/src/rate/window.ts +200 -0
- package/src/rate.ts +0 -322
package/README.md
CHANGED
|
@@ -7,12 +7,10 @@ active model is streaming output tokens, in the bottom status bar of the TUI.
|
|
|
7
7
|
⚡ 62 tok/s
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
While the model streams, the number is a **live** trailing-window rate
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
⚡ 62 tok/s · avg 55
|
|
15
|
-
```
|
|
10
|
+
While the model streams, the number is a **live** trailing-window rate, coloured
|
|
11
|
+
by speed. When the stream ends the last reading keeps its value but is dimmed.
|
|
12
|
+
There is no session history and no settled average — the line only ever reports
|
|
13
|
+
a rate.
|
|
16
14
|
|
|
17
15
|
The line is provider-agnostic: it measures whatever model is active, so there is
|
|
18
16
|
no activation condition and nothing to configure per provider.
|
|
@@ -33,67 +31,100 @@ pi -e npm:pi-show-token-rate
|
|
|
33
31
|
|
|
34
32
|
## What the bar shows
|
|
35
33
|
|
|
36
|
-
| Segment
|
|
37
|
-
|
|
|
38
|
-
| `⚡`
|
|
39
|
-
|
|
|
40
|
-
| `
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
34
|
+
| Segment | Meaning |
|
|
35
|
+
| ------- | ---------------------------------------------------------------------------------- |
|
|
36
|
+
| `⚡` | Marker: green while streaming, `dim` once the stream ends |
|
|
37
|
+
| `62` | Output tokens per second, rounded, live while streaming, kept once the stream ends |
|
|
38
|
+
| `tok/s` | Unit, dim |
|
|
39
|
+
|
|
40
|
+
While the model streams, the rate is coloured by speed:
|
|
41
|
+
|
|
42
|
+
| Rate | Colour |
|
|
43
|
+
| -------------- | ------ |
|
|
44
|
+
| above 50 tok/s | green |
|
|
45
|
+
| 10–50 tok/s | yellow |
|
|
46
|
+
| below 10 tok/s | red |
|
|
47
|
+
|
|
48
|
+
The colour is chosen from the number actually displayed, so the label and its
|
|
49
|
+
colour never disagree (e.g. `50` is yellow, `51` is green). The `⚡` marker is
|
|
50
|
+
green for as long as the model is streaming — regardless of the rate's own
|
|
51
|
+
colour — so the bar reads as live at a glance; once the stream ends the marker
|
|
52
|
+
goes back to `dim`, and the last reading is dimmed too.
|
|
53
|
+
|
|
54
|
+
The row starts blank and stays blank until a response produces a measurable
|
|
55
|
+
rate: the first token of a stream cannot be timed on its own, and the character
|
|
56
|
+
estimate waits for a full second of history before it is believed. The `⚡`
|
|
57
|
+
marker still turns green as soon as a response starts streaming; until a rate is
|
|
58
|
+
measurable the row carries the previous reading over, dimmed (or shows
|
|
59
|
+
`⚡ tok/s —` when nothing has been measured yet). A response that ends without a
|
|
60
|
+
measurable rate — an empty reply, or an aborted one — leaves the previous
|
|
61
|
+
reading in place.
|
|
51
62
|
|
|
52
63
|
## How the rate is measured
|
|
53
64
|
|
|
54
|
-
- **
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
request take".
|
|
65
|
+
- **Streaming only.** The rate is measured while the model produces tokens, and
|
|
66
|
+
nothing is settled afterwards. A response that is aborted or errors out is not
|
|
67
|
+
frozen at all — the bar keeps the previous reading.
|
|
58
68
|
- **Trailing window while streaming.** Live updates average the last 3 seconds
|
|
59
69
|
of streaming. Providers emit deltas in bursts and stall while writing a
|
|
60
70
|
thinking block or tool-call JSON, so a per-event rate would flicker between 0
|
|
61
71
|
and several hundred tok/s.
|
|
62
|
-
- **
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
- **Two scales, two windows.** A provider's cumulative output-token count and
|
|
73
|
+
the character estimate are different units, so each gets its own trailing
|
|
74
|
+
window. Provider usage wins while its window is moving, fresh and plausible;
|
|
75
|
+
otherwise the estimate answers. A provider count that repeats a
|
|
76
|
+
constant, stops being reported, or resets cannot freeze the live number — its
|
|
77
|
+
window expires like any other.
|
|
78
|
+
- **Freeze, don't average.** When `message_end` arrives the current window value
|
|
79
|
+
is captured and the trackers are dropped; the bar keeps showing that reading,
|
|
80
|
+
dimmed.
|
|
65
81
|
|
|
66
82
|
## Accuracy
|
|
67
83
|
|
|
68
84
|
Not every provider reports token usage while a response is streaming:
|
|
69
85
|
|
|
70
|
-
- When
|
|
71
|
-
|
|
72
|
-
- When it
|
|
73
|
-
final chunk
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
86
|
+
- When the provider reports a cumulative count that keeps advancing mid-stream,
|
|
87
|
+
the extension uses it — the number is exact.
|
|
88
|
+
- When it does not — OpenAI-compatible endpoints typically send usage only on
|
|
89
|
+
the final chunk, and Anthropic reports a small count at the start and only
|
|
90
|
+
updates it in a terminal event pi does not surface — the live number falls
|
|
91
|
+
back to a **character estimate**: CJK, kana, Hangul, and fullwidth punctuation
|
|
92
|
+
count as roughly one token each, text as one per four characters, and
|
|
93
|
+
tool-call argument JSON as one per two and a half — JSON keys and punctuation
|
|
94
|
+
tokenize denser than prose. The bar does not label which scale answered: the
|
|
95
|
+
estimate exists only to keep the number moving while the provider stays quiet.
|
|
96
|
+
- The two scales are measured in parallel, and a window whose newest reading has
|
|
97
|
+
fallen outside the trailing 3 seconds counts as unmeasurable, so a provider
|
|
98
|
+
count that stops advancing — a repeated constant, a dropped field, or a reset
|
|
99
|
+
counter — hands the row back to the estimate until it moves again.
|
|
100
|
+
|
|
101
|
+
### Buffered tool calls
|
|
102
|
+
|
|
103
|
+
Characters say when text **arrived**, not when it was generated. Endpoints that
|
|
104
|
+
do not stream tool-call arguments token by token deliver a whole command — or a
|
|
105
|
+
whole file — in one delta, and crediting that at face value would print four
|
|
106
|
+
digits of tok/s. An estimated window therefore credits each interval with at
|
|
107
|
+
most **twice the pace it is already running at** (a floor of 100 tok/s covers
|
|
108
|
+
the first second, before the window has a pace of its own) and stays silent
|
|
109
|
+
until it has a full second of history; provider counts are never credited that
|
|
110
|
+
way. The ceiling follows the window's own **raw** pace, so a genuinely fast
|
|
111
|
+
stream still reaches its real rate within a second or two instead of being held
|
|
112
|
+
near the floor.
|
|
113
|
+
|
|
114
|
+
A reading above **5,000 tok/s** — faster than any production model streams today
|
|
115
|
+
— is refused for the same reason: providers that buffer a whole tool call and
|
|
116
|
+
flush it in one burst hand the extension thousands of tokens a few milliseconds
|
|
117
|
+
apart, and printing that window would show tens of thousands of tok/s. The line
|
|
118
|
+
keeps its previous value instead.
|
|
119
|
+
|
|
120
|
+
The wide/narrow weighting is a heuristic, not a tokenizer, so mixed scripts can
|
|
121
|
+
still read a few percent low or high.
|
|
83
122
|
|
|
84
123
|
## Slash command
|
|
85
124
|
|
|
86
|
-
| Command
|
|
87
|
-
|
|
|
88
|
-
| `/token-rate`
|
|
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
|
-
```
|
|
125
|
+
| Command | Effect |
|
|
126
|
+
| ------------- | -------------------------------- |
|
|
127
|
+
| `/token-rate` | Toggle the status line on or off |
|
|
97
128
|
|
|
98
129
|
## Development
|
|
99
130
|
|
|
@@ -107,11 +138,14 @@ pnpm test # node --test
|
|
|
107
138
|
pnpm typecheck # tsc --noEmit
|
|
108
139
|
```
|
|
109
140
|
|
|
110
|
-
Tests cover the character estimator, the
|
|
111
|
-
|
|
112
|
-
throttling and toggle behavior.
|
|
113
|
-
|
|
114
|
-
|
|
141
|
+
Tests cover the character estimator, the trailing window (monotonicity, pruning,
|
|
142
|
+
credit guards), the two-scale tracker, the exact status-line strings, and the
|
|
143
|
+
controller's throttling, freezing and toggle behavior. Two windowing tests are
|
|
144
|
+
differential: they replay thousands of irregularly-spaced observations and check
|
|
145
|
+
`rate(now)` against the trailing-window definition at every step, so the
|
|
146
|
+
incremental pruning cannot drift from it. The suite drives an injected clock, so
|
|
147
|
+
no test depends on wall-clock timing, and it uses a stub theme instead of the
|
|
148
|
+
real ANSI palette.
|
|
115
149
|
|
|
116
150
|
## License
|
|
117
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-show-token-rate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Shows the live output token rate (tok/s) of the current model in the pi TUI status bar.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
"tui"
|
|
12
12
|
],
|
|
13
13
|
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/luohuidong/pi-extensions.git",
|
|
17
|
+
"directory": "packages/pi-show-token-rate"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/luohuidong/pi-extensions/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/luohuidong/pi-extensions/tree/main/packages/pi-show-token-rate#readme",
|
|
14
23
|
"type": "module",
|
|
15
24
|
"files": [
|
|
16
25
|
"src/",
|
package/src/format.ts
CHANGED
|
@@ -5,60 +5,72 @@
|
|
|
5
5
|
* stats without crowding them:
|
|
6
6
|
*
|
|
7
7
|
* ⚡ 62 tok/s while the model streams (trailing-window rate)
|
|
8
|
-
* ⚡ 62 tok/s
|
|
9
|
-
* ⚡
|
|
10
|
-
*
|
|
8
|
+
* ⚡ 62 tok/s the last measured rate, dimmed, once the stream ends
|
|
9
|
+
* ⚡ tok/s — nothing measured yet (marker still green while streaming)
|
|
10
|
+
*
|
|
11
|
+
* While streaming the rate is coloured by the spec's buckets — green above
|
|
12
|
+
* 50 tok/s, yellow from 10 through 50, red below 10 — and the `⚡` marker is
|
|
13
|
+
* green. Once the stream ends the number keeps its value but is dimmed, and
|
|
14
|
+
* the marker goes back to dim.
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
18
|
|
|
15
|
-
import type { RateSummary } from "./rate.ts";
|
|
16
|
-
|
|
17
19
|
export type Theme = ExtensionContext["ui"]["theme"];
|
|
18
20
|
|
|
19
21
|
const LABEL = "⚡";
|
|
20
22
|
export const RATE_UNIT = "tok/s";
|
|
21
23
|
|
|
22
|
-
/**
|
|
23
|
-
const
|
|
24
|
-
/** At or above this many tok/s the rate is yellow; below
|
|
25
|
-
const SLOW_MIN =
|
|
24
|
+
/** Strictly above this many displayed tok/s the rate is green. */
|
|
25
|
+
const FAST_ABOVE = 50;
|
|
26
|
+
/** At or above this many displayed tok/s the rate is yellow; below it, red. */
|
|
27
|
+
const SLOW_MIN = 10;
|
|
26
28
|
|
|
27
29
|
/** Everything the status line can render. */
|
|
28
30
|
export interface RateView {
|
|
29
31
|
/** Tokens per second to show; omitted when nothing has been measured. */
|
|
30
32
|
rate?: number;
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
33
|
+
/** While true the `⚡` marker is green. */
|
|
34
|
+
streaming?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether `rate` is a live measurement. While streaming, a response that
|
|
37
|
+
* cannot be measured yet carries the last reading over under a green marker;
|
|
38
|
+
* `measured: false` dims that carried-over number so it is not read as live.
|
|
39
|
+
* Defaults to true whenever `streaming` is set.
|
|
40
|
+
*/
|
|
41
|
+
measured?: boolean;
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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.
|
|
45
|
+
* The numeric value the label shows: one decimal below 10 (a slow model is
|
|
46
|
+
* exactly where precision is interesting), whole tokens above it. Colour is
|
|
47
|
+
* picked from this same value, so the label and its bucket never disagree.
|
|
44
48
|
*/
|
|
49
|
+
function displayedValue(value: number): number {
|
|
50
|
+
return value < 10 ? Math.round(value * 10) / 10 : Math.round(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Rate → short display string. */
|
|
45
54
|
export function formatRate(value: number): string {
|
|
46
55
|
if (!Number.isFinite(value) || value <= 0) return "—";
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
return String(
|
|
56
|
+
const shown = displayedValue(value);
|
|
57
|
+
if (shown < 10) return shown.toFixed(1);
|
|
58
|
+
return String(shown);
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
function rateColor(theme: Theme, value: number): string {
|
|
53
62
|
const text = formatRate(value);
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
const shown = displayedValue(value);
|
|
64
|
+
if (shown > FAST_ABOVE) return theme.fg("success", text);
|
|
65
|
+
if (shown >= SLOW_MIN) return theme.fg("warning", text);
|
|
66
|
+
// Below the spec's slow threshold the rate reads as a problem, not as quiet.
|
|
67
|
+
return theme.fg("error", text);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
export function formatStatusLine(theme: Theme, view: RateView): string {
|
|
61
|
-
|
|
71
|
+
// The marker is green while the model streams — a live signal independent of
|
|
72
|
+
// the rate's own colour — and dim once the response is over.
|
|
73
|
+
const label = theme.fg(view.streaming ? "success" : "dim", LABEL);
|
|
62
74
|
if (
|
|
63
75
|
view.rate === undefined ||
|
|
64
76
|
!Number.isFinite(view.rate) ||
|
|
@@ -67,39 +79,13 @@ export function formatStatusLine(theme: Theme, view: RateView): string {
|
|
|
67
79
|
return [label, " ", theme.fg("dim", `${RATE_UNIT} —`)].join("");
|
|
68
80
|
}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
theme.fg("dim",
|
|
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
|
-
}
|
|
82
|
+
// Streaming is the only state where the number means "right now"; a
|
|
83
|
+
// carried-over reading (streaming but not measurable yet) and the frozen
|
|
84
|
+
// idle value are both shown dim.
|
|
85
|
+
const measured = view.streaming && view.measured !== false;
|
|
86
|
+
const rate = measured
|
|
87
|
+
? rateColor(theme, view.rate)
|
|
88
|
+
: theme.fg("dim", formatRate(view.rate));
|
|
90
89
|
|
|
91
|
-
|
|
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(" · ");
|
|
90
|
+
return [label, " ", rate, " ", theme.fg("dim", RATE_UNIT)].join("");
|
|
105
91
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Token rate for the pi TUI footer.
|
|
3
3
|
*
|
|
4
|
-
* Watches the assistant message stream and shows how fast the active model
|
|
5
|
-
*
|
|
4
|
+
* Watches the assistant message stream and shows how fast the active model is
|
|
5
|
+
* producing output tokens:
|
|
6
6
|
*
|
|
7
|
-
* ⚡ 62 tok/s
|
|
8
|
-
* ⚡ 62 tok/s · avg 55 the last response plus the session average
|
|
7
|
+
* ⚡ 62 tok/s live while the model streams, dimmed once the stream ends
|
|
9
8
|
*
|
|
10
|
-
* The rate is measured over
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* This file only wires events to the controller in `./line.ts`.
|
|
9
|
+
* The rate is measured over a trailing window while tokens arrive, so it stays
|
|
10
|
+
* readable between bursty deltas; there is no session history and no settled
|
|
11
|
+
* average. This file only wires events to the controller in `./line.ts`.
|
|
15
12
|
*/
|
|
16
13
|
|
|
17
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
18
15
|
|
|
19
|
-
import { formatSummary } from "./format.ts";
|
|
20
16
|
import { TokenRateLine } from "./line.ts";
|
|
21
17
|
|
|
22
|
-
const USAGE = "token-rate: /token-rate
|
|
18
|
+
const USAGE = "token-rate: /token-rate";
|
|
23
19
|
|
|
24
20
|
export default function (pi: ExtensionAPI) {
|
|
25
21
|
const line = new TokenRateLine();
|
|
@@ -43,22 +39,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
43
39
|
pi.registerCommand("token-rate", {
|
|
44
40
|
description: "Show or hide the tok/s line in the status bar",
|
|
45
41
|
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
42
|
if (!ctx.hasUI) {
|
|
53
43
|
ctx.ui.notify("token-rate: no UI in this mode", "warning");
|
|
54
44
|
return;
|
|
55
45
|
}
|
|
56
|
-
if (
|
|
57
|
-
line.reset(ctx);
|
|
58
|
-
ctx.ui.notify("token-rate: statistics cleared", "info");
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
61
|
-
if (sub !== "") {
|
|
46
|
+
if (args.trim() !== "") {
|
|
62
47
|
ctx.ui.notify(USAGE, "warning");
|
|
63
48
|
return;
|
|
64
49
|
}
|