From d9fd9b03ec098e9d6e07389d28d0f8f762dd9128 Mon Sep 17 00:00:00 2001 From: shendayong Date: Wed, 14 Oct 2026 09:03:43 +0800 Subject: [PATCH 1/2] Show cache hit rate in the summary, turn table, and in-flight line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cacheRatio` (cache reads over the whole prompt) has existed in shared/pricing.ts since v0.6.0, but the only place it reached the UI was the CACHE cell's bottom line, and only when the turn also had a known price — so turns from unpriced models hid the ratio entirely and the session/overview summary never showed it. Render it in three places instead: - the session and overview summary rows, aggregated over turns, as a "cache hit" stat next to the raw cache token count; - each finished turn's CACHE cell, independent of whether a price is known (the cost keeps precedence when there is one); - the in-progress turn line, which already carried the token counts. Input is the provider-normalized fresh-token count, so the ratio stays comparable between exclusive-input (Claude) and inclusive-input (Codex) adapters. No data-layer change: the ratio is derived at render time, so existing ledger records are reinterpreted correctly. --- CHANGELOG.md | 1 + README.md | 10 ++++++---- client/panel.tsx | 4 +--- client/ui.tsx | 6 +++++- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab508e..4468b49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Add an overview screenshot (demo data, with the sidebar footer row) to the README and as the first manifest media image. +- Show the cache hit rate — cache reads over the whole prompt — in the session/overview summary, in each turn's CACHE column, and in the in-progress turn line. The value was already computed by `cacheRatio` but never rendered outside the priced cost cell, so turns without a known price hid it entirely. ## v0.7.0 (2026-10-09) — Paseo 0.11 screens and sidebar diff --git a/README.md b/README.md index 7b5e990..1828645 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ Per-turn LLM token usage and cost for [Paseo](https://paseo.sh) agents — an ag **Per-agent panel** (workspace/explorer, bound to one session — the header names the agent and model it is tracking): -- **Session summary** — total turns, input / cache / output tokens, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly. -- **Turn in progress** — elapsed time, running token counts and estimated cost when available, and a context-window bar. When idle, the last known context-window usage is still shown. -- **Turn history** — one row per finished turn: status, time, duration, tokens, cost, and a data-quality mark. +- **Session summary** — total turns, input / cache / output tokens, cache hit rate, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly. The hit rate is cache reads over the whole prompt (fresh input + reads + known writes; see [how turns are measured](#how-turns-are-measured)); it is blank when the provider reports no cache reads. +- **Turn in progress** — elapsed time, running token counts (with cache hit rate) and estimated cost when available, and a context-window bar. When idle, the last known context-window usage is still shown. +- **Turn history** — one row per finished turn: status, time, duration, tokens, cache hit rate, cost, and a data-quality mark. **Composer pill** — every live agent gets a compact pill next to its composer (`$0.43 · ctx 47%`, with an activity icon while a turn is running). The total includes the current turn's estimated cost when available and uses `≈` whenever estimates contribute. Pressing it opens that agent's panel. With split views, each session carries its own pill, so the session↔usage binding is always visible. @@ -32,7 +32,7 @@ Per-turn LLM token usage and cost for [Paseo](https://paseo.sh) agents — an ag **Sidebar** — a **TokenLedger** row in the sidebar footer shows today's cost (or tokens when no price is known), with an activity icon while a turn is running. "Today" starts at local midnight on the device that shows it. -**Overview** (sidebar footer → TokenLedger, or the command center) — all sessions in one place: grand totals, then per-agent rows grouped by workspace with live-turn indicator, last activity, cost/tokens, and turn count. Pick **Today**, **Last 7 days**, **Last 30 days**, or **All time**; the range is part of the screen link and its title. A range lists agents with turns that ended in it, plus agents with a turn in flight. Tapping a row jumps to that agent. +**Overview** (sidebar footer → TokenLedger, or the command center) — all sessions in one place: grand totals (including cache hit rate), then per-agent rows grouped by workspace with live-turn indicator, last activity, cost/tokens, and turn count. Pick **Today**, **Last 7 days**, **Last 30 days**, or **All time**; the range is part of the screen link and its title. A range lists agents with turns that ended in it, plus agents with a turn in flight. Tapping a row jumps to that agent.

TokenLedger overview on Last 7 days with demo data: range buttons, totals for all sessions, and agent rows grouped by workspace; the TokenLedger row in the sidebar footer shows today's cost @@ -115,6 +115,8 @@ Estimated costs are marked `≈`. OpenRouter prices are references and may diffe Paseo 0.9.1 still does not expose cache-write token counts through its public usage API. TokenLedger can retain and price them separately when supplied, but the current Claude and Codex adapters omit them. Missing counts stay unknown; historical writes are not inferred. Claude's reported total can include charges absent from the token counts, producing a positive residual under COST. Codex input includes cache reads (and cache writes when supplied); the UI separates known cache counts while the ledger retains raw counts. +The **cache hit rate** is cache reads ÷ the whole prompt (`cached / (input + cached + known writes)`), where `input` is the provider-normalized fresh-token count — for Codex and other OpenAI-style adapters that report inclusive input, the cached portion is subtracted first, so the ratio is comparable across providers. It reflects prompt caching only, not output or cost savings, and it is blank when the provider reports no cache reads. The same figure appears in the session and overview summaries (aggregated over turns) and per turn. + Provider-internal subagents are not ordinary Paseo catalog agents. The public plugin API does not expose their usage; the Codex adapter discards child usage notifications. Session and overview totals therefore **may omit descendant usage**. TokenLedger uses only public Paseo APIs; it does not inspect provider logs or gateway billing endpoints. The [upstream API requirements](docs/paseo-usage-api.md) describe the missing fields, lineage, stable request IDs, and accounting scopes needed to close these gaps without double billing. Identical consecutive usage snapshots cannot be distinguished from separate equal-size model calls, so Codex totals remain `partial`. Open-turn observations, session identity and billing baselines are checkpointed locally, and graceful plugin reloads preserve them. A crash can lose observations since the last checkpoint; events missed while the plugin was offline cannot be reconstructed. Interrupted recovered turns are marked as partial/unavailable rather than claiming an exact offline outcome. `exact` describes the reported turn aggregate, not proof that every descendant or billing category was included. diff --git a/client/panel.tsx b/client/panel.tsx index 072e07b..0c719e1 100644 --- a/client/panel.tsx +++ b/client/panel.tsx @@ -58,7 +58,6 @@ function CtxBar({ ctx, theme }: { ctx: Ctx; theme: PluginTheme }) { function InFlightCard({ inFlight, seq, theme }: { inFlight: InFlight; seq: number; theme: PluginTheme }) { const elapsed = useElapsed(inFlight.startedAt); const tokens = tokensLine(inFlight.input, inFlight.cached, inFlight.output, inFlight.cacheWrite); - const pct = fmtPct(cacheRatio(inFlight.input, inFlight.cached, inFlight.cacheWrite)); const ctx = inFlight.ctxUsed !== null && inFlight.ctxMax !== null ? { used: inFlight.ctxUsed, max: inFlight.ctxMax } : null; return ( {tokens} - {pct ? ` (${pct} cached)` : ""} {inFlight.modelCalls > 0 ? ` · ${inFlight.modelCalls} call${inFlight.modelCalls > 1 ? "s" : ""}` : ""} ) : ( @@ -187,7 +185,7 @@ function RecordRow({ record, theme, dense }: { record: TurnRow; theme: PluginThe diff --git a/client/ui.tsx b/client/ui.tsx index f04096b..4b2744c 100644 --- a/client/ui.tsx +++ b/client/ui.tsx @@ -1,6 +1,7 @@ import type { PluginTheme } from "@getpaseo/plugin"; import { Text, View } from "react-native"; import type { Summary, TurnRecord } from "../shared/ledger.ts"; +import { cacheRatio } from "../shared/pricing.ts"; export function fmtTokens(value: number | null): string { if (value === null) return "–"; @@ -72,7 +73,8 @@ export function statusColor(status: TurnRecord["status"], theme: PluginTheme): s export function tokensLine(input: number | null, cached: number | null, output: number | null, cacheWrite?: number | null): string | null { if (input === null && cached === null && output === null && cacheWrite == null) return null; - return `in ${fmtTokens(input)} · cache ${fmtTokens(cached)}${cacheWrite != null ? ` · write ${fmtTokens(cacheWrite)}` : ''} · out ${fmtTokens(output)}`; + const pct = fmtPct(cacheRatio(input, cached, cacheWrite)); + return `in ${fmtTokens(input)} · cache ${fmtTokens(cached)}${pct ? ` (${pct})` : ''}${cacheWrite != null ? ` · write ${fmtTokens(cacheWrite)}` : ''} · out ${fmtTokens(output)}`; } /** Column widths for the TURNS table; shared by the header and data rows. */ @@ -122,11 +124,13 @@ export function SummaryRow({ summary, theme, dense }: { summary: Summary; theme: const cost = fmtCost(summary.effectiveCostUsd); const costValue = cost ? `${summary.estimatedTurns > 0 ? "≈" : ""}${cost}` : null; const costLabel = summary.unpricedTurns > 0 ? `cost · ${summary.unpricedTurns} unknown` : summary.estimatedTurns > 0 ? "cost · estimated" : "cost"; + const hit = fmtPct(cacheRatio(summary.input, summary.cached, summary.cacheWrite)); return ( + {hit ? : null} {summary.cacheWrite != null ? : null} {costValue ? : null} From bfde7fd75e59479b2f5b8da2b5de86e0560c9468 Mon Sep 17 00:00:00 2001 From: shendayong Date: Wed, 14 Oct 2026 09:54:36 +0800 Subject: [PATCH 2/2] Add average output throughput, and lead the composer pill with tokens and hit rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Paseo exposes no per-token or first-token timing, so the only speed signal available to a plugin is the span between a turn's start and end. Summing that span per summary (and per overview range) gives an average output throughput: output tokens over wall clock. Wall clock includes thinking, tool calls, and waiting on the provider, so this is a floor on generation speed rather than the model's token rate — the README and the code comments say so, and the label is "avg out" rather than "speed". Summary.durationMs is added as a non-negative field that defaults to 0, so a client reading summaries from a server that predates it still parses. It is summed alongside the token counts in both summaryFromRows and the overview accumulator; rows without a timing contribute nothing instead of dropping the turn. The composer pill then gains the throughput, and gains the cache hit rate that was previously panel-only. The host caps the pill's button at 160px, and the icon, gaps and padding take 50px, so the label has about 110px before the host ellipsizes it. Measured widths per glyph (12px system-ui, tabular numerals) show `%` at 11.1px against `1` at 5.6px, so the label is assembled against a measured advance-width table instead of a character count, and drops the throughput (then the hit rate) rather than letting the host clip it. Context-window fill is dropped from the pill: the chat surface already shows it next to the composer, so spending pill width on it duplicated the host at the expense of the number that cannot be seen there. Formatting helpers move to shared/format.ts so the pill, panel and overview render from one implementation, and the pill label becomes a pure function with its own tests. --- CHANGELOG.md | 2 ++ README.md | 10 +++++--- client/pill.tsx | 30 ++++++++++++++-------- client/ui.tsx | 19 ++++---------- server/read-model.test.ts | 45 +++++++++++++++++++++++++++++++++ server/read-model.ts | 2 ++ server/tracker.ts | 1 + shared/format.test.ts | 43 +++++++++++++++++++++++++++++++ shared/format.ts | 41 ++++++++++++++++++++++++++++++ shared/ledger.ts | 7 ++++++ shared/pill-label.test.ts | 30 ++++++++++++++++++++++ shared/pill-label.ts | 53 +++++++++++++++++++++++++++++++++++++++ 12 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 server/read-model.test.ts create mode 100644 shared/format.test.ts create mode 100644 shared/format.ts create mode 100644 shared/pill-label.test.ts create mode 100644 shared/pill-label.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4468b49..23f944a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add an overview screenshot (demo data, with the sidebar footer row) to the README and as the first manifest media image. - Show the cache hit rate — cache reads over the whole prompt — in the session/overview summary, in each turn's CACHE column, and in the in-progress turn line. The value was already computed by `cacheRatio` but never rendered outside the priced cost cell, so turns without a known price hid it entirely. +- Show the **average output throughput** (`output ÷` summed turn wall clock) in the session/overview summary, and put it in the composer pill next to the cache hit rate. Paseo reports no per-token timing, so this is a throughput floor that includes thinking and tooling; the label is documented as such. +- Rework the composer pill label: it now carries tokens with the hit rate (`6.5M·95%`) and the throughput, and no longer repeats the context-window fill that the chat surface already shows. The host caps the pill at 160px, so the label is assembled against a measured advance-width table and drops the throughput (then the hit rate) rather than letting the host clip it. ## v0.7.0 (2026-10-09) — Paseo 0.11 screens and sidebar diff --git a/README.md b/README.md index 1828645..0aed40e 100644 --- a/README.md +++ b/README.md @@ -13,21 +13,21 @@ Per-turn LLM token usage and cost for [Paseo](https://paseo.sh) agents — an ag

- TokenLedger per-agent panel: session summary (turns, input/cache/output tokens, cost), context-window bar, and a per-turn history table + TokenLedger per-agent panel: session summary (turns, input/cache/output tokens, cache hit rate, average output throughput, cost), context-window bar, and a per-turn history table

## What it shows **Per-agent panel** (workspace/explorer, bound to one session — the header names the agent and model it is tracking): -- **Session summary** — total turns, input / cache / output tokens, cache hit rate, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly. The hit rate is cache reads over the whole prompt (fresh input + reads + known writes; see [how turns are measured](#how-turns-are-measured)); it is blank when the provider reports no cache reads. +- **Session summary** — total turns, input / cache / output tokens, cache hit rate, average output throughput, and reported or estimated cost. Estimated totals use `≈`; missing-price turns are counted explicitly. The hit rate is cache reads over the whole prompt (fresh input + reads + known writes; see [how turns are measured](#how-turns-are-measured)) and is blank when the provider reports no cache reads. - **Turn in progress** — elapsed time, running token counts (with cache hit rate) and estimated cost when available, and a context-window bar. When idle, the last known context-window usage is still shown. - **Turn history** — one row per finished turn: status, time, duration, tokens, cache hit rate, cost, and a data-quality mark. -**Composer pill** — every live agent gets a compact pill next to its composer (`$0.43 · ctx 47%`, with an activity icon while a turn is running). The total includes the current turn's estimated cost when available and uses `≈` whenever estimates contribute. Pressing it opens that agent's panel. With split views, each session carries its own pill, so the session↔usage binding is always visible. +**Composer pill** — every live agent gets a compact pill next to its composer (`6.5M·95%·74 t/s`, or `$0.43·90%` when the model has a price; an activity icon shows while a turn is running). The token count carries the session's cache hit rate in the same segment, followed by the average output throughput. The pill is capped by the host, so it holds only what fits: the throughput is dropped first, then the hit rate, never the count itself. Context-window fill is deliberately not repeated here — the chat surface already shows it next to the pill. The cost total includes the current turn's estimate when available and uses `≈` whenever estimates contribute. Pressing it opens that agent's panel. With split views, each session carries its own pill, so the session↔usage binding is always visible.

- TokenLedger composer pill showing cost and context usage next to the composer; pressing it opens the panel + TokenLedger composer pill showing the session token count with its cache hit rate and average output throughput next to the composer; pressing it opens the panel

**Sidebar** — a **TokenLedger** row in the sidebar footer shows today's cost (or tokens when no price is known), with an activity icon while a turn is running. "Today" starts at local midnight on the device that shows it. @@ -115,6 +115,8 @@ Estimated costs are marked `≈`. OpenRouter prices are references and may diffe Paseo 0.9.1 still does not expose cache-write token counts through its public usage API. TokenLedger can retain and price them separately when supplied, but the current Claude and Codex adapters omit them. Missing counts stay unknown; historical writes are not inferred. Claude's reported total can include charges absent from the token counts, producing a positive residual under COST. Codex input includes cache reads (and cache writes when supplied); the UI separates known cache counts while the ledger retains raw counts. +The **average output throughput** (`avg out` in the panel, `74 t/s` in the pill) is output tokens ÷ the summed wall-clock duration of the turns behind it. Paseo's usage API exposes no per-token or first-token timing, so this is derived from each turn's start and end; that span includes thinking, tool calls, and waiting on the provider. Read it as a floor on generation speed and as a way to compare sessions and models — not as the model's token rate. + The **cache hit rate** is cache reads ÷ the whole prompt (`cached / (input + cached + known writes)`), where `input` is the provider-normalized fresh-token count — for Codex and other OpenAI-style adapters that report inclusive input, the cached portion is subtracted first, so the ratio is comparable across providers. It reflects prompt caching only, not output or cost savings, and it is blank when the provider reports no cache reads. The same figure appears in the session and overview summaries (aggregated over turns) and per turn. Provider-internal subagents are not ordinary Paseo catalog agents. The public plugin API does not expose their usage; the Codex adapter discards child usage notifications. Session and overview totals therefore **may omit descendant usage**. TokenLedger uses only public Paseo APIs; it does not inspect provider logs or gateway billing endpoints. The [upstream API requirements](docs/paseo-usage-api.md) describe the missing fields, lineage, stable request IDs, and accounting scopes needed to close these gaps without double billing. diff --git a/client/pill.tsx b/client/pill.tsx index efc785d..e2cfa14 100644 --- a/client/pill.tsx +++ b/client/pill.tsx @@ -5,23 +5,33 @@ import { useLedger } from "./data.ts"; import { useEffect } from "react"; import { subscribeAgents } from "../shared/agents.ts"; import { ledgerEnsure, type SyncResult } from "../shared/ledger.ts"; -import { fmtCost, fmtTokens } from "./ui.tsx"; +import { fmtCost } from "./ui.tsx"; +import { compactTokens, fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts"; +import { cacheRatio } from "../shared/pricing.ts"; +import { pillLabel as assemblePillLabel } from "../shared/pill-label.ts"; import type { PanelPlacement } from "./layout.ts"; -function pillLabel({ summary, inFlight, ctx }: SyncResult): string { +function composePillLabel({ summary, inFlight }: SyncResult): string { const combinedCost = summary.effectiveCostUsd === null && inFlight?.effectiveCostUsd == null ? null : (summary.effectiveCostUsd ?? 0) + (inFlight?.effectiveCostUsd ?? 0); const cost = fmtCost(combinedCost); const estimated = summary.estimatedTurns > 0 || inFlight?.effectiveCostUsd != null; - const parts = [cost - ? `${estimated ? "≈" : ""}${cost}` - : `${fmtTokens(summary.input + summary.cached + (summary.cacheWrite ?? 0) + summary.output)} tok`]; - const liveCtx = inFlight?.ctxUsed != null && inFlight.ctxMax != null - ? { used: inFlight.ctxUsed, max: inFlight.ctxMax } : ctx; - if (liveCtx && liveCtx.max > 0) parts.push(`ctx ${Math.round(liveCtx.used / liveCtx.max * 100)}%`); - return parts.join(" · "); + const hit = fmtPct(cacheRatio(summary.input, summary.cached, summary.cacheWrite)); + const total = summary.input + summary.cached + (summary.cacheWrite ?? 0) + summary.output; + + // With a known price the pill leads with money; otherwise it leads with the + // token count. Either way the hit rate rides in the same segment as the + // number it qualifies, so no extra separator is spent on it. + const usage = cost + ? `${estimated ? "≈" : ""}${cost}${hit ? `·${hit}` : ""}` + : hit ? `${compactTokens(total)}·${hit}` : `${fmtTokens(total)} tok`; + + return assemblePillLabel({ + usage, + throughput: fmtThroughput(summary.output, summary.durationMs, "t/s"), + }); } export function contributePills(client: PluginClientContext, placement: PanelPlacement): () => Promise { @@ -44,7 +54,7 @@ export function contributePills(client: PluginClientContext, placement: PanelPla useEffect(() => placement.observe(agent.id, props.layout.compact), [props.layout.compact]); const { data, error } = useLedger(agent.id); useEffect(() => { - handle.update({ label: error ? "Usage unavailable" : data ? pillLabel(data) : "…" }); + handle.update({ label: error ? "Usage unavailable" : data ? composePillLabel(data) : "…" }); }, [data, error]); return ; } diff --git a/client/ui.tsx b/client/ui.tsx index 4b2744c..e156242 100644 --- a/client/ui.tsx +++ b/client/ui.tsx @@ -2,13 +2,9 @@ import type { PluginTheme } from "@getpaseo/plugin"; import { Text, View } from "react-native"; import type { Summary, TurnRecord } from "../shared/ledger.ts"; import { cacheRatio } from "../shared/pricing.ts"; - -export function fmtTokens(value: number | null): string { - if (value === null) return "–"; - if (value < 1000) return String(value); - if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`; - return `${(value / 1_000_000).toFixed(2)}M`; -} +// Formatting lives in shared/ so the pill, panel, and overview render identically. +import { fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts"; +export { fmtPct, fmtThroughput, fmtTokens } from "../shared/format.ts"; export function fmtCost(value: number | null): string | null { if (value === null) return null; @@ -41,13 +37,6 @@ export function fmtResidual(value: number | null): string { return `${value > 0 ? "+" : "−"}${fmtCostSmall(Math.abs(value))}`; } -export function fmtPct(ratio: number | null): string | null { - if (ratio === null) return null; - const pct = ratio * 100; - if (pct > 0 && pct < 1) return "<1%"; - return `${Math.round(pct)}%`; -} - export function fmtDuration(ms: number | null): string { if (ms === null) return "–"; const seconds = Math.round(ms / 1000); @@ -125,6 +114,7 @@ export function SummaryRow({ summary, theme, dense }: { summary: Summary; theme: const costValue = cost ? `${summary.estimatedTurns > 0 ? "≈" : ""}${cost}` : null; const costLabel = summary.unpricedTurns > 0 ? `cost · ${summary.unpricedTurns} unknown` : summary.estimatedTurns > 0 ? "cost · estimated" : "cost"; const hit = fmtPct(cacheRatio(summary.input, summary.cached, summary.cacheWrite)); + const rate = fmtThroughput(summary.output, summary.durationMs); return ( @@ -133,6 +123,7 @@ export function SummaryRow({ summary, theme, dense }: { summary: Summary; theme: {hit ? : null} {summary.cacheWrite != null ? : null} + {rate ? : null} {costValue ? : null} ); diff --git a/server/read-model.test.ts b/server/read-model.test.ts new file mode 100644 index 0000000..65f6cc6 --- /dev/null +++ b/server/read-model.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { finalizeTurn } from '../shared/aggregate.ts'; +import type { TurnRow } from '../shared/ledger.ts'; + +function row(startedAt: string | null, endedAt: string, output: number): TurnRow { + const record = finalizeTurn({ + agentId: 'a', turnId: 't', provider: 'codex', model: 'gpt-test', + startedAt, endedAt, status: 'completed', + observations: [], finalUsage: { inputTokens: 100, cachedInputTokens: 40, outputTokens: output }, + prevSessionCostUsd: null, + }); + return { ...record, seq: 1, effectiveCostUsd: null, costSource: null, costBreakdown: null }; +} + +/** + * `durationMs` exists so the composer pill and the panel can show an average + * throughput. It must be summed like the token counts: a row with no timing + * contributes nothing instead of dropping or double-counting a turn. + */ +test('summary duration sums per-turn wall clock and tolerates missing timings', async () => { + const home = await mkdtemp(join(tmpdir(), 'token-ledger-read-model-test-')); + const previousHome = process.env.PASEO_HOME; + process.env.PASEO_HOME = home; + try { + const { summaryFromRows } = await import('./read-model.ts'); + const summary = summaryFromRows([ + row('2026-09-14T00:00:00.000Z', '2026-09-14T00:00:01.000Z', 10), + row(null, '2026-09-14T00:00:05.000Z', 20), + row('2026-09-14T00:00:10.000Z', '2026-09-14T00:00:13.000Z', 30), + ]); + assert.equal(summary.turns, 3); + assert.equal(summary.output, 60); + assert.equal(summary.durationMs, 4000); + + assert.equal(summaryFromRows([]).durationMs, 0); + } finally { + if (previousHome === undefined) delete process.env.PASEO_HOME; + else process.env.PASEO_HOME = previousHome; + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/server/read-model.ts b/server/read-model.ts index ff9287b..b287323 100644 --- a/server/read-model.ts +++ b/server/read-model.ts @@ -6,6 +6,7 @@ export const EMPTY_SUMMARY: Summary = { input: 0, cached: 0, output: 0, + durationMs: 0, costUsd: null, effectiveCostUsd: null, estimatedTurns: 0, @@ -21,6 +22,7 @@ export function summaryFromRows(rows: readonly TurnRow[]): Summary { summary.cached += row.cached ?? 0; if (row.cacheWrite != null) summary.cacheWrite = (summary.cacheWrite ?? 0) + row.cacheWrite; summary.output += row.output ?? 0; + summary.durationMs += row.durationMs ?? 0; if (row.costUsd !== null) summary.costUsd = (summary.costUsd ?? 0) + row.costUsd; if (row.effectiveCostUsd !== null) { summary.effectiveCostUsd = (summary.effectiveCostUsd ?? 0) + row.effectiveCostUsd; diff --git a/server/tracker.ts b/server/tracker.ts index c2565d9..41eb197 100644 --- a/server/tracker.ts +++ b/server/tracker.ts @@ -344,6 +344,7 @@ export async function handleOverview(input: { since?: string }, context: { paseo totals.cached += summary.cached; if (summary.cacheWrite != null) totals.cacheWrite = (totals.cacheWrite ?? 0) + summary.cacheWrite; totals.output += summary.output; + totals.durationMs += summary.durationMs; if (summary.costUsd !== null) totals.costUsd = (totals.costUsd ?? 0) + summary.costUsd; if (summary.effectiveCostUsd !== null) totals.effectiveCostUsd = (totals.effectiveCostUsd ?? 0) + summary.effectiveCostUsd; totals.estimatedTurns += summary.estimatedTurns; diff --git a/shared/format.test.ts b/shared/format.test.ts new file mode 100644 index 0000000..5a969e3 --- /dev/null +++ b/shared/format.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { compactTokens, fmtPct, fmtThroughput, fmtTokens } from "./format.ts"; + +test("token counts keep the panel's magnitude formatting", () => { + assert.equal(fmtTokens(null), "–"); + assert.equal(fmtTokens(999), "999"); + assert.equal(fmtTokens(12_400), "12.4k"); + assert.equal(fmtTokens(6_500_000), "6.50M"); +}); + +test("compactTokens drops only trailing zeros, keeping other precision", () => { + assert.equal(compactTokens(6_500_000), "6.5M"); + assert.equal(compactTokens(1_000_000), "1M"); + assert.equal(compactTokens(12_340_000), "12.34M"); + assert.equal(compactTokens(12_400), "12.4k"); + assert.equal(compactTokens(1_000), "1k"); + assert.equal(compactTokens(999), "999"); + assert.equal(compactTokens(null), "–"); +}); + +test("percentages round and keep sub-1% visible", () => { + assert.equal(fmtPct(null), null); + assert.equal(fmtPct(0), "0%"); + assert.equal(fmtPct(0.004), "<1%"); + assert.equal(fmtPct(0.474), "47%"); + assert.equal(fmtPct(1), "100%"); +}); + +test("throughput is output over measured wall clock", () => { + assert.equal(fmtThroughput(10_000, 10_000), "1000 tok/s"); + assert.equal(fmtThroughput(45, 1000), "45 tok/s"); + // Sub-10 values keep a decimal: whole numbers would round 3.6 and 4.4 alike. + assert.equal(fmtThroughput(3_600, 1_000_000), "3.6 tok/s"); +}); + +test("throughput uses the requested unit and stays silent without both inputs", () => { + assert.equal(fmtThroughput(44, 1000, "t/s"), "44 t/s"); + assert.equal(fmtThroughput(null, 1000), null); + assert.equal(fmtThroughput(44, null), null); + assert.equal(fmtThroughput(0, 1000), null); + assert.equal(fmtThroughput(44, 0), null); +}); diff --git a/shared/format.ts b/shared/format.ts new file mode 100644 index 0000000..0919376 --- /dev/null +++ b/shared/format.ts @@ -0,0 +1,41 @@ +/** + * Numeric formatting shared by the server-derived read model and the client + * views. Kept free of React and Paseo imports so the same code renders in the + * panel, the overview, and the composer pill — and can be unit tested directly. + */ + +export function fmtTokens(value: number | null): string { + if (value === null) return "–"; + if (value < 1000) return String(value); + if (value < 1_000_000) return `${(value / 1000).toFixed(1)}k`; + return `${(value / 1_000_000).toFixed(2)}M`; +} + +/** `fmtTokens` without trailing zeros: `6.50M` becomes `6.5M`, `1.00M` `1M`. */ +export function compactTokens(value: number | null): string { + return fmtTokens(value).replace(/\.?0+([kM])$/, "$1"); +} + +export function fmtPct(ratio: number | null): string | null { + if (ratio === null) return null; + const pct = ratio * 100; + if (pct > 0 && pct < 1) return "<1%"; + return `${Math.round(pct)}%`; +} + +/** + * Average output throughput: output tokens per second of wall clock. + * + * Paseo exposes no per-token or first-token timing, so this is derived from a + * turn's `startedAt`/`endedAt` span. That span covers thinking, tool calls, and + * waiting for the provider, so the value is a floor on generation speed rather + * than the model's token rate — it should never be presented as the latter. + * Null without both an output count and a measured duration. + */ +export function fmtThroughput(output: number | null, durationMs: number | null, unit = "tok/s"): string | null { + if (output === null || durationMs === null || output <= 0 || durationMs <= 0) return null; + const perSecond = output / (durationMs / 1000); + if (!Number.isFinite(perSecond) || perSecond <= 0) return null; + // Below 10 the integer part is too coarse; above it the decimal is noise. + return `${perSecond < 10 ? perSecond.toFixed(1) : Math.round(perSecond)} ${unit}`; +} diff --git a/shared/ledger.ts b/shared/ledger.ts index 038e84f..0635fc5 100644 --- a/shared/ledger.ts +++ b/shared/ledger.ts @@ -90,6 +90,13 @@ export const SummarySchema = z.object({ cached: z.number(), cacheWrite: z.number().optional(), output: z.number(), + /** + * Summed wall-clock duration of the turns behind this summary. Wall clock + * includes thinking, tool calls, and waiting, so output/duration is an + * average throughput, not the model's token generation rate. Defaults to 0 + * so a client can read summaries produced before this field existed. + */ + durationMs: z.number().nonnegative().default(0), costUsd: z.number().nullable(), /** Reported + estimated costs for priced turns. */ effectiveCostUsd: z.number().nullable(), diff --git a/shared/pill-label.test.ts b/shared/pill-label.test.ts new file mode 100644 index 0000000..d2e9dd9 --- /dev/null +++ b/shared/pill-label.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { estimateTextWidth, pillLabel, PILL_LABEL_WIDTH } from "./pill-label.ts"; + +test("usage and throughput share the label while it fits", () => { + assert.equal(pillLabel({ usage: "6.5M·47%", throughput: null }), "6.5M·47%"); + assert.equal(pillLabel({ usage: "6.5M·47%", throughput: "74 t/s" }), "6.5M·47%·74 t/s"); + assert.equal(pillLabel({ usage: "26.77M·95%", throughput: "74 t/s" }), "26.77M·95%·74 t/s"); + assert.equal(pillLabel({ usage: "$0.43·90%", throughput: "123 t/s" }), "$0.43·90%·123 t/s"); +}); + +test("a wide usage figure drops the throughput rather than clipping the label", () => { + const label = pillLabel({ usage: "999.9M·100%", throughput: "999 t/s" }); + assert.equal(label, "999.9M·100%"); + assert.ok(estimateTextWidth(label) <= PILL_LABEL_WIDTH); +}); + +test("every kept label estimates within the pill's text budget", () => { + for (const usage of ["6.5M·47%", "123.4M·100%", "12.3k·83%", "999 tok", "≈$0.43·90%", "1M·100%"]) { + for (const throughput of [null, "3.6 t/s", "74 t/s", "999 t/s"]) { + const label = pillLabel({ usage, throughput }); + if (label === usage) continue; // mandatory segment may be wide on its own + assert.ok(estimateTextWidth(label) <= PILL_LABEL_WIDTH, `${label} = ${estimateTextWidth(label)}px`); + } + } +}); + +test("a mandatory usage segment survives on its own", () => { + assert.equal(pillLabel({ usage: "999.9M·100%", throughput: null }), "999.9M·100%"); +}); diff --git a/shared/pill-label.ts b/shared/pill-label.ts new file mode 100644 index 0000000..cc7eca2 --- /dev/null +++ b/shared/pill-label.ts @@ -0,0 +1,53 @@ +/** + * Composer pill label assembly, kept in shared/ so the exact segmentation is + * unit-testable without a host to render into. + * + * The host caps the composer pill's button at 160px. Measured on the desktop + * client (12px system-ui, tabular numerals), the icon, gaps and padding take + * 50px of that, leaving 110px for the label before the host ellipsizes it. + * + * A measured advance-width table is used instead of a character count because + * the glyphs differ by up to 3x (`%` is 11.1px, `1` is 5.6px). The numbers are + * averaged per glyph from a live probe; the digits are tabular but not equal + * width, so each is kept separate rather than approximated by an average. + * + * The label carries usage and throughput only. Context-window fill is already + * visible in the chat surface next to the pill, so spending pill width on it + * would duplicate the host at the expense of the number that cannot be seen + * there — the average throughput. + */ +const CHAR_WIDTH: Record = { + "0": 7.57, "1": 5.57, "2": 7.24, "3": 7.53, "4": 7.73, "5": 7.42, "6": 7.64, "7": 6.96, "8": 7.67, "9": 7.64, + M: 10.49, k: 6.52, t: 4.36, s: 6.23, "%": 11.11, ".": 3.57, "/": 3.66, "·": 3.57, " ": 3.38, $: 7.57, "≈": 7.57, +}; +/** Fallback for glyphs measured elsewhere (CJK, other units). */ +const DEFAULT_WIDTH = 7.6; +export const PILL_LABEL_WIDTH = 110; + +/** + * Estimated render width, rounded down so the estimate stays conservative: + * keeping a segment the host would clip is worse than dropping one that fits. + */ +export function estimateTextWidth(text: string): number { + let width = 0; + for (const char of text) width += CHAR_WIDTH[char] ?? DEFAULT_WIDTH; + return Math.floor(width); +} + +export type PillSegments = { + /** Usage: the token count with its cache hit rate, or the cost. Mandatory. */ + usage: string; + /** Average throughput, e.g. `74 t/s`. Dropped when the label would overflow. */ + throughput: string | null; +}; + +/** + * Drops the optional throughput when the whole label would not fit. The usage + * segment is mandatory: when it alone is too wide it is returned anyway, since + * a clipped number still beats an empty pill. + */ +export function pillLabel({ usage, throughput }: PillSegments): string { + if (!throughput) return usage; + const label = `${usage}·${throughput}`; + return estimateTextWidth(label) <= PILL_LABEL_WIDTH ? label : usage; +}