cd /news/ai-tools/model-neutral-pi-extension-for-live-… · home topics ai-tools article
[ARTICLE · art-128285] src=gist.github.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Model-neutral Pi extension for live decode TPS, TTFT, and prompt-rate estimates

A developer published a model-neutral Pi extension that displays live throughput metrics—time to first token (TTFT), decode tokens per second, and a client-side prompt-rate estimate—in the coding agent's status footer. The extension, validated against @earendil-works/pi-coding-agent 0.85.1, works with both local and hosted models by observing Pi's standard assistant-stream events rather than reading vLLM logs or metrics. It estimates live decode TPS using a characters-divided-by-four heuristic and notes that its input/TTFT figure is not authoritative server prefill throughput.

by read6 min views1 publishedSep 13, 2026

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'sbefore_provider_request event to the first output delta observed by the client.
  • Live decode TPS is acharacters / 4 estimate 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.

  1. Run /reload and confirm no extension error appears.
  2. Submit a prompt that generates at least 100 tokens.
  3. Confirm the footer first shows TTFT: waiting .
  4. Confirm live output contains Decode: ~... with tildes.
  5. Confirm the final output loses the tildes when usage is reported.
  6. Compare the final token count with the server response or request log.
  7. Test a tool-calling turn; toolcall_delta is included in the live estimate.
  8. Repeat with a second model. No filename, provider ID, or model ID is hard-coded in the extension.
  • The live characters / 4 estimate 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/TTFT is 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:

── more in #ai-tools 4 stories · sorted by recency
── more on @pi 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/model-neutral-pi-ext…] indexed:0 read:6min 2026-09-13 ·