This guide adds a model-neutral throughput indicator to
Pi. It was validated against
@earendil-works/pi-coding-agent 0.85.1.
The extension works with local and hosted models because it observes Pi's standard assistant-stream events. It does not read vLLM logs or metrics and does not depend on a particular model name.
During generation, the footer looks similar to:
⚡ TTFT: 1.24s · Decode: ~42.1 tok/s · ~312 tok
After generation, when the provider reports token usage, it becomes:
⚡ TTFT: 1.24s · Input/TTFT: ~1850 tok/s · Decode: 39.8 tok/s · 842 tok
- TTFT is measured from Pi's
before_provider_requestevent to the first output delta observed by the client. - Live decode TPS is a
characters / 4estimate because most providers do not report a cumulative token count on every stream chunk. - Final decode TPS uses the provider's reported output-token count and the client-observed interval from the first output delta to the last. The first token is excluded from the numerator because it defines the start boundary.
- Input/TTFT is only a client-side prompt-rate estimate. It divides uncached input tokens by TTFT, which also contains network, queue, scheduling, and stream-start overhead. It isnot authoritative server prefill throughput.
For true per-request prefill throughput, use metrics or timing emitted by the inference server. Pi's message events do not expose a server-only prefill duration.
Create ~/.pi/agent/extensions/live-throughput-status.ts with this content:
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
const STATUS_KEY = "live-throughput";
const CHARS_PER_TOKEN = 4;
const UPDATE_INTERVAL_MS = 200;
function setStatus(ctx: ExtensionContext, text?: string): void {
if (ctx.mode !== "tui") return;
ctx.ui.setStatus(
STATUS_KEY,
text === undefined ? undefined : ctx.ui.theme.fg("accent", `⚡ ${text}`),
);
}
function positiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: undefined;
}
function deltaChars(event: unknown): number {
if (!event || typeof event !== "object") return 0;
const streamEvent = event as { type?: string; delta?: unknown };
if (
streamEvent.type !== "text_delta" &&
streamEvent.type !== "thinking_delta" &&
streamEvent.type !== "toolcall_delta"
) {
return 0;
}
return typeof streamEvent.delta === "string" ? streamEvent.delta.length : 0;
}
function seconds(milliseconds: number): number {
return Math.max(0, milliseconds) / 1000;
}
function rate(value: number, durationSeconds: number): string {
return (value / Math.max(0.001, durationSeconds)).toFixed(1);
}
export default function (pi: ExtensionAPI) {
let requestStartedAt: number | undefined;
let firstOutputAt: number | undefined;
let lastOutputAt: number | undefined;
let ttftSeconds: number | undefined;
let streamedChars = 0;
let lastDisplayAt = 0;
function reset(ctx: ExtensionContext, waiting = true): void {
requestStartedAt = undefined;
firstOutputAt = undefined;
lastOutputAt = undefined;
ttftSeconds = undefined;
streamedChars = 0;
lastDisplayAt = 0;
setStatus(ctx, waiting ? "TTFT: waiting · Decode: waiting" : undefined);
}
pi.on("session_start", async (_event, ctx) => reset(ctx));
pi.on("model_select", async (_event, ctx) => reset(ctx));
// This event occurs immediately before Pi sends a provider payload. The
// handler observes time only and deliberately returns no payload rewrite.
pi.on("before_provider_request", async (_event, _ctx) => {
requestStartedAt = Date.now();
});
pi.on("message_start", async (event, ctx) => {
if (event.message.role !== "assistant") return;
firstOutputAt = undefined;
lastOutputAt = undefined;
ttftSeconds = undefined;
streamedChars = 0;
lastDisplayAt = 0;
// Fallback for a custom provider that does not emit the request hook.
requestStartedAt ??= Date.now();
setStatus(ctx, "TTFT: waiting · Decode: waiting for first token…");
});
pi.on("message_update", async (event, ctx) => {
if (event.message.role !== "assistant") return;
const chars = deltaChars(event.assistantMessageEvent);
if (chars <= 0) return;
const now = Date.now();
if (firstOutputAt === undefined) {
firstOutputAt = now;
ttftSeconds = seconds(now - (requestStartedAt ?? now));
}
lastOutputAt = now;
streamedChars += chars;
if (now - lastDisplayAt < UPDATE_INTERVAL_MS) return;
lastDisplayAt = now;
const decodeSeconds = seconds(now - firstOutputAt);
const estimatedTokens = streamedChars / CHARS_PER_TOKEN;
setStatus(
ctx,
`TTFT: ${ttftSeconds!.toFixed(2)}s · Decode: ~${rate(estimatedTokens, decodeSeconds)} tok/s · ~${Math.round(estimatedTokens)} tok`,
);
});
pi.on("message_end", async (event, ctx) => {
if (event.message.role !== "assistant") return;
const usage = event.message.usage;
const outputTokens = positiveNumber(usage?.output);
const uncachedInputTokens = positiveNumber(usage?.input);
const cacheWriteTokens = positiveNumber(usage?.cacheWrite) ?? 0;
const observedTtft = ttftSeconds;
const decodeSeconds =
firstOutputAt !== undefined && lastOutputAt !== undefined
? seconds(lastOutputAt - firstOutputAt)
: undefined;
const parts: string[] = [];
if (observedTtft !== undefined) {
parts.push(`TTFT: ${observedTtft.toFixed(2)}s`);
}
// Pi normalizes usage.input to uncached input for supported providers.
// Cache reads are excluded because they were not prefetched by the model.
const processedInputTokens = (uncachedInputTokens ?? 0) + cacheWriteTokens;
if (processedInputTokens > 0 && observedTtft !== undefined && observedTtft > 0) {
parts.push(`Input/TTFT: ~${rate(processedInputTokens, observedTtft)} tok/s`);
}
if (outputTokens !== undefined && outputTokens > 1 && decodeSeconds !== undefined && decodeSeconds > 0) {
parts.push(`Decode: ${rate(outputTokens - 1, decodeSeconds)} tok/s · ${outputTokens} tok`);
} else if (outputTokens !== undefined) {
parts.push(`Decode: ${outputTokens} tok · rate unavailable`);
} else if (streamedChars > 0 && decodeSeconds !== undefined && decodeSeconds > 0) {
const estimatedTokens = streamedChars / CHARS_PER_TOKEN;
parts.push(`Decode: ~${rate(estimatedTokens, decodeSeconds)} tok/s · ~${Math.round(estimatedTokens)} tok`);
} else {
parts.push("Decode: no output tokens");
}
setStatus(ctx, parts.join(" · "));
requestStartedAt = undefined;
});
}
Run /reload in Pi. The global extension directory is auto-discovered, so no
additional settings entry is required.
The extension itself is model-neutral. Accuracy depends on the provider:
| Provider behavior | Live display | Final display |
|---|---|---|
| Reports final output usage | Approximate TPS | Exact token count with client-timed TPS |
| Reports no output usage | Approximate TPS | Approximate TPS |
| Buffers output instead of streaming deltas | No useful live TPS | Token count; TPS may be unavailable |
For an OpenAI-compatible model, Pi requests streaming usage by default. Keep
supportsUsageInStreaming enabled only when the server accepts
stream_options: { "include_usage": true }:
{
"providers": {
"local-llm": {
"baseUrl": "http://HOST:PORT/v1",
"api": "openai-completions",
"apiKey": "local",
"compat": {
"supportsUsageInStreaming": true
},
"models": [
{
"id": "MODEL_ID",
"name": "Local model",
"contextWindow": 128000,
"maxTokens": 16384
}
]
}
}
}
If the server rejects stream_options, set supportsUsageInStreaming to
false; the extension will continue using its explicit approximate fallback.
OpenAI-compatible servers commonly send usage once, in a final usage-only SSE
chunk. Pi exposes that count on message_end, not necessarily as multiple
message_update samples. Therefore a design that waits for two increasing
mid-stream usage.output samples often never obtains an exact rate.
This implementation instead records the first and last output arrival times, then combines that interval with the final reported output-token count. It works with the normal one-sample behavior used by vLLM, llama.cpp, and other OpenAI-compatible servers.
- Run
/reloadand confirm no extension error appears. - Submit a prompt that generates at least 100 tokens.
- Confirm the footer first shows
TTFT: waiting. - Confirm live output contains
Decode: ~...with tildes. - Confirm the final output loses the tildes when usage is reported.
- Compare the final token count with the server response or request log.
- Test a tool-calling turn;
toolcall_deltais included in the live estimate. - Repeat with a second model. No filename, provider ID, or model ID is hard-coded in the extension.
- The live
characters / 4estimate varies with prose, code, JSON, and CJK. - Even with an exact final token count, client timing can be affected by stream buffering and network jitter.
- TTFT is end-to-end from Pi's provider hook, not GPU-only prefill latency.
Input/TTFTis a useful comparable client metric, not true server prefill TPS.- Very short outputs do not have enough first-to-last span for a stable rate.
Authoritative Pi references: