Merge pull request #658 from fabro-sh/feat/stage-model-popover-billing

Show stage tokens and cost in the model popover
This commit is contained in:
Bryan Helmkamp 2026-07-28 14:58:51 -04:00 committed by GitHub
commit c083fa5209
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 518 additions and 242 deletions

View file

@ -9,6 +9,7 @@ import { StagePopover } from "./stage-popover";
import { deriveStageSummary } from "./stage-popover-summary";
import type { Stage } from "../lib/stage-sidebar";
import { generatedAxios } from "../lib/api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
function makeEvent(overrides: Partial<EventEnvelope>): EventEnvelope {
return {
@ -34,6 +35,7 @@ function makeStage(overrides: Partial<Stage> = {}): Stage {
duration: "1m 30s",
startedAt: "2026-05-24T11:58:30Z",
providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" },
billing: makeBilledTokenCounts(),
...overrides,
};
}

View file

@ -3,6 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import { makeBilledTokenCounts } from "../../lib/test-fixtures";
import type { Stage } from "../stage-sidebar";
import { FanInResults } from "./fan-in-results";
@ -22,6 +23,7 @@ const fanInStage: Stage = {
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
};
function event(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {

View file

@ -4,6 +4,7 @@ import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import { makeBilledTokenCounts } from "../../lib/test-fixtures";
import type { Stage } from "../stage-sidebar";
import { ParallelChildren } from "./parallel-children";
@ -23,6 +24,7 @@ const parallelStage: Stage = {
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
};
function event(partial: Partial<EventEnvelope>): EventEnvelope {

View file

@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { StageSidebar, type Stage } from "./stage-sidebar";
function makeStage(overrides: Partial<Stage> = {}): Stage {
@ -17,6 +18,7 @@ function makeStage(overrides: Partial<Stage> = {}): Stage {
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}

View file

@ -0,0 +1,28 @@
import type { BilledTokenCounts } from "@qltysh/fabro-api-client";
export interface BillingTokenBucket {
label: string;
value: number;
}
export function billableOutputTokens(billing: BilledTokenCounts): number {
return billing.output_tokens + billing.reasoning_tokens;
}
/** The disjoint token buckets shown in every billing breakdown. */
export function billingTokenBuckets(billing: BilledTokenCounts): BillingTokenBucket[] {
return [
{ label: "Cache read", value: billing.cache_read_tokens },
{ label: "Cache creation", value: billing.cache_write_tokens },
{ label: "Uncached", value: billing.input_tokens },
{ label: "Output", value: billableOutputTokens(billing) },
];
}
export function hasBillingUsage(billing: BilledTokenCounts): boolean {
return (
billing.total_tokens !== 0 ||
(billing.total_usd_micros ?? 0) !== 0 ||
billingTokenBuckets(billing).some((bucket) => bucket.value !== 0)
);
}

View file

@ -3,6 +3,7 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
@ -17,6 +18,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage {
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
};
}
@ -38,6 +40,15 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
},
billing: makeBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
total_tokens: 43_690,
reasoning_tokens: 1_200,
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_usd_micros: 720_000,
}),
},
{
id: "apply-changes@2",
@ -46,6 +57,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "apply",
visit: 2,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -64,6 +76,10 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
});
// Each visit keeps its own tokens and cost, so the stage popover never
// shows a sibling visit's usage.
expect(result[0].billing.total_usd_micros).toBe(720_000);
expect(result[1].billing.total_usd_micros).toBeUndefined();
expect(formatStageLabel(result[0])).toBe("Apply Changes");
expect(result[1].id).toBe("apply-changes@2");
@ -83,6 +99,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "start",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "verify@1",
@ -91,6 +108,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "exit@1",
@ -99,6 +117,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "exit",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -118,6 +137,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -137,6 +157,7 @@ describe("mapRunStagesToSidebarStages", () => {
node_id: "work",
visit: 1,
graph_visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "work@2",
@ -147,6 +168,7 @@ describe("mapRunStagesToSidebarStages", () => {
visit: 2,
graph_visit: 1,
resumed_from_stage_id: "work@1",
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -172,6 +194,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -192,6 +215,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "pending",
node_id: "approval",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },

View file

@ -1,5 +1,6 @@
import { StageState } from "@qltysh/fabro-api-client";
import type {
BilledTokenCounts,
PaginatedRunStageList,
StageHandler,
StageModelUsage,
@ -27,6 +28,11 @@ export interface Stage {
resumedFromStageId: string | null;
startedAt: string | null;
providerUsed: StageModelUsage | null;
/**
* Tokens and cost for this visit alone, priced the same way the Billing tab
* prices its per-node rows. All-zero counts mean the stage called no model.
*/
billing: BilledTokenCounts;
}
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
@ -102,6 +108,7 @@ export function mapRunStagesToSidebarStages(
: "--",
startedAt: stage.started_at ?? null,
providerUsed: stage.provider_used ?? null,
billing: stage.billing,
});
}
return stages;

View file

@ -1,4 +1,4 @@
import type { Principal } from "@qltysh/fabro-api-client";
import type { BilledTokenCounts, Principal } from "@qltysh/fabro-api-client";
export const TEST_PRINCIPAL: Principal = {
kind: "user",
@ -6,3 +6,17 @@ export const TEST_PRINCIPAL: Principal = {
login: "test",
auth_method: "dev_token",
};
export function makeBilledTokenCounts(
overrides: Partial<BilledTokenCounts> = {},
): BilledTokenCounts {
return {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
...overrides,
};
}

View file

@ -2,11 +2,12 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import type {
BilledTokenCounts,
RunBilling,
StageTiming,
} from "@qltysh/fabro-api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
return {
wall_time_ms,
@ -24,25 +25,12 @@ mock.module("../lib/queries", () => ({
const { default: RunBillingRoute } = await import("./run-billing");
function zeroBilling(overrides: Partial<BilledTokenCounts> = {}): BilledTokenCounts {
return {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
total_usd_micros: null,
...overrides,
};
}
function billing(overrides: Partial<RunBilling> = {}): RunBilling {
return {
stages: [],
totals: {
timing: stageTiming(),
...zeroBilling(),
...makeBilledTokenCounts(),
},
by_model: [],
...overrides,
@ -86,21 +74,21 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(61000),
state: "succeeded",
},
],
totals: {
timing: stageTiming(61000),
...zeroBilling(),
...makeBilledTokenCounts(),
},
}),
);
@ -121,7 +109,7 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: makeBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
@ -131,7 +119,7 @@ describe("RunBilling", () => {
provider: "anthropic",
model_id: "claude-sonnet-4-5",
},
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -143,7 +131,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(42000),
...zeroBilling({
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -157,7 +145,7 @@ describe("RunBilling", () => {
model_id: "claude-sonnet-4-5",
},
stages: 1,
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -204,7 +192,7 @@ describe("RunBilling", () => {
model_id: "claude-opus-4-6",
speed: "fast",
},
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -217,7 +205,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(),
...zeroBilling({
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -232,7 +220,7 @@ describe("RunBilling", () => {
speed: "fast",
},
stages: 1,
billing: zeroBilling({
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -268,4 +256,4 @@ describe("RunBilling", () => {
Date.now = originalNow;
}
});
});
});

View file

@ -2,6 +2,11 @@ import { Fragment, useMemo } from "react";
import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import {
billableOutputTokens,
billingTokenBuckets,
hasBillingUsage,
} from "../lib/billing";
import {
formatDurationMs,
formatTokenCount,
@ -11,6 +16,7 @@ import { useRunBilling } from "../lib/queries";
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
import type {
BilledTokenCounts,
BillingModelRef,
RunBilling,
RunBillingStage,
@ -39,23 +45,15 @@ function isInFlight(stage: RunBillingStage): boolean {
function isVisibleRow(row: MappedStageRow): boolean {
if (row.inFlight) return true;
return (
(row.inputTokens ?? 0) > 0 ||
(row.outputTokens ?? 0) > 0 ||
(row.totalUsdMicros ?? 0) > 0
);
return row.billing != null && hasBillingUsage(row.billing);
}
interface MappedStageRow {
stage: string;
model: string | null;
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
wallTimeMs: number;
totalUsdMicros: number | null | undefined;
inFlight: boolean;
stage: string;
model: string | null;
billing: BilledTokenCounts | null;
wallTimeMs: number;
inFlight: boolean;
}
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
@ -73,49 +71,28 @@ export const handle = { wide: true };
function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow {
const hasModel = stage.model != null;
return {
stage: stage.stage.name,
model: formatModelRef(stage.model),
inputTokens: hasModel ? stage.billing.input_tokens : null,
outputTokens: hasModel
? stage.billing.output_tokens + stage.billing.reasoning_tokens
: null,
cacheReadTokens: hasModel ? stage.billing.cache_read_tokens : null,
cacheWriteTokens: hasModel ? stage.billing.cache_write_tokens : null,
stage: stage.stage.name,
model: formatModelRef(stage.model),
billing: hasModel ? stage.billing : null,
wallTimeMs,
totalUsdMicros: stage.billing.total_usd_micros,
inFlight: isInFlight(stage),
inFlight: isInFlight(stage),
};
}
/** Hover breakdown of the disjoint token buckets behind an `in / out` count. */
function TokenBreakdown({
cacheReadTokens,
cacheWriteTokens,
inputTokens,
outputTokens,
}: {
cacheReadTokens: number;
cacheWriteTokens: number;
inputTokens: number;
outputTokens: number;
}) {
const rows = [
{ label: "Cache read", value: cacheReadTokens },
{ label: "Cache creation", value: cacheWriteTokens },
{ label: "Uncached", value: inputTokens },
{ label: "Output", value: outputTokens },
];
function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
const buckets = billingTokenBuckets(billing);
return (
<div className="min-w-44 py-0.5">
<div className="mb-1.5 border-b border-line pb-1 font-medium text-fg-2">
<div className="border-line text-fg-2 mb-1.5 border-b pb-1 font-medium">
Tokens in / out
</div>
<dl className="grid grid-cols-[1fr_auto] gap-x-6 gap-y-1">
{rows.map((row) => (
<Fragment key={row.label}>
<dt className="text-fg-3">{row.label}</dt>
<dd className="text-right font-mono tabular-nums text-fg">
{formatTokens(row.value)}
{buckets.map((bucket) => (
<Fragment key={bucket.label}>
<dt className="text-fg-3">{bucket.label}</dt>
<dd className="text-fg text-right font-mono tabular-nums">
{formatTokens(bucket.value)}
</dd>
</Fragment>
))}
@ -128,42 +105,16 @@ function TokenBreakdown({
* Renders an `input / output` token count. When the row has model usage,
* hovering the count reveals the cache breakdown.
*/
function TokensCell({
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
}: {
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
}) {
function TokensCell({ billing }: { billing: BilledTokenCounts | null }) {
const display = (
<>
{formatTokens(inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(outputTokens)}
{formatTokens(billing?.input_tokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(billing ? billableOutputTokens(billing) : null)}
</>
);
if (
inputTokens == null ||
outputTokens == null ||
cacheReadTokens == null ||
cacheWriteTokens == null
) {
return display;
}
if (!billing) return display;
return (
<Tooltip
label={
<TokenBreakdown
cacheReadTokens={cacheReadTokens}
cacheWriteTokens={cacheWriteTokens}
inputTokens={inputTokens}
outputTokens={outputTokens}
/>
}
>
<Tooltip label={<TokenBreakdown billing={billing} />}>
<span>{display}</span>
</Tooltip>
);
@ -189,15 +140,14 @@ export default function RunBilling({ params }: { params: { id: string } }) {
if (!billing) return [];
return billing.by_model
.map((entry) => ({
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
inputTokens: entry.billing.input_tokens,
outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens,
cacheReadTokens: entry.billing.cache_read_tokens,
cacheWriteTokens: entry.billing.cache_write_tokens,
totalUsdMicros: entry.billing.total_usd_micros,
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
billing: entry.billing,
}))
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
.sort(
(a, b) =>
(b.billing.total_usd_micros ?? -1) - (a.billing.total_usd_micros ?? -1),
);
}, [billing]);
// Re-derive only the in-flight rows on each tick; everything else stays put.
@ -218,16 +168,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
: (billing?.totals.timing.wall_time_ms ?? 0);
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null;
const totalOutput = hasLlmStages && billing
? billing.totals.output_tokens + billing.totals.reasoning_tokens
: null;
const totalCacheRead = hasLlmStages
? (billing?.totals.cache_read_tokens ?? null)
: null;
const totalCacheWrite = hasLlmStages
? (billing?.totals.cache_write_tokens ?? null)
: null;
const totalBilling = hasLlmStages && billing ? billing.totals : null;
const totalUsdMicros = billing?.totals.total_usd_micros;
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
const visibleRows = rows.filter(isVisibleRow);
@ -268,18 +209,13 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{row.model ?? EMPTY_VALUE}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
<TokensCell
inputTokens={row.inputTokens}
outputTokens={row.outputTokens}
cacheReadTokens={row.cacheReadTokens}
cacheWriteTokens={row.cacheWriteTokens}
/>
<TokensCell billing={row.billing} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatDurationMs(row.wallTimeMs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.billing?.total_usd_micros)}
</td>
</tr>
))}
@ -289,12 +225,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
<td className="px-4 py-3 font-medium text-fg">Total</td>
<td className="px-4 py-3 text-xs text-fg-muted">All models</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
<TokensCell
inputTokens={totalInput}
outputTokens={totalOutput}
cacheReadTokens={totalCacheRead}
cacheWriteTokens={totalCacheWrite}
/>
<TokensCell billing={totalBilling} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatDurationMs(totalWallTimeMs)}
@ -328,15 +259,10 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{row.stages}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
<TokensCell
inputTokens={row.inputTokens}
outputTokens={row.outputTokens}
cacheReadTokens={row.cacheReadTokens}
cacheWriteTokens={row.cacheWriteTokens}
/>
<TokensCell billing={row.billing} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.totalUsdMicros)}
{formatUsdMicrosOrDash(row.billing.total_usd_micros)}
</td>
</tr>
))}
@ -348,12 +274,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
{modelStageCount}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
<TokensCell
inputTokens={totalInput}
outputTokens={totalOutput}
cacheReadTokens={totalCacheRead}
cacheWriteTokens={totalCacheWrite}
/>
<TokensCell billing={totalBilling} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicrosOrDash(totalUsdMicros)}

View file

@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { StageState } from "@qltysh/fabro-api-client";
import type { Stage } from "../lib/stage-sidebar";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { StageChatView } from "./run-stages";
function stage(overrides: Partial<Stage> = {}): Stage {
@ -18,6 +19,7 @@ function stage(overrides: Partial<Stage> = {}): Stage {
resumedFromStageId: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
...overrides,
};
}

View file

@ -1,9 +1,14 @@
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReasoningOutput } from "@qltysh/fabro-api-client";
import type {
BilledTokenCounts,
ReasoningOutput,
StageModelUsage,
} from "@qltysh/fabro-api-client";
import { EventDetails } from "./run-stages";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { EventDetails, ModelUsagePopover } from "./run-stages";
const RUN_START = "2026-04-09T12:00:00Z";
@ -72,3 +77,77 @@ describe("EventDetails reasoning", () => {
expect(html).toContain(`${"x".repeat(280)}`);
});
});
const PROVIDER_USED: StageModelUsage = {
mode: "agent",
provider: "moonshot",
model: "kimi-k3",
reasoning_effort: "max",
};
function popoverMarkup(counts: BilledTokenCounts): string {
return renderToStaticMarkup(
<ModelUsagePopover providerUsed={PROVIDER_USED} billing={counts} />,
);
}
describe("ModelUsagePopover billing", () => {
test("shows the visit's token buckets and cost next to the model", () => {
const html = popoverMarkup(
makeBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
reasoning_tokens: 1_200,
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_tokens: 43_690,
total_usd_micros: 720_000,
}),
);
expect(html).toContain("kimi-k3");
expect(html).toContain("Cache read");
expect(html).toContain("4.8k");
expect(html).toContain("Cache creation");
expect(html).toContain("1.5k");
expect(html).toContain("Uncached");
expect(html).toContain("28.6k");
// Output folds in reasoning tokens, matching the Billing tab.
expect(html).toContain("Output");
expect(html).toContain("8.8k");
expect(html).toContain("Cost");
expect(html).toContain("$0.72");
});
test("omits the token section for a stage that called no model", () => {
const html = popoverMarkup(makeBilledTokenCounts());
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
expect(html).not.toContain("Cost");
});
test("still shows tokens when nothing priced the stage", () => {
const html = popoverMarkup(
makeBilledTokenCounts({
input_tokens: 1_000,
output_tokens: 500,
total_tokens: 1_500,
}),
);
expect(html).toContain("Uncached");
expect(html).toContain("1.0k");
expect(html).not.toContain("Cost");
});
test("shows a provider-reported cost when token counts are unavailable", () => {
const html = popoverMarkup(
makeBilledTokenCounts({ total_usd_micros: 720_000 }),
);
expect(html).toContain("kimi-k3");
expect(html).toContain("Cost");
expect(html).toContain("$0.72");
});
});

View file

@ -67,7 +67,9 @@ import {
formatBytes,
formatDurationMs,
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
import { billingTokenBuckets, hasBillingUsage } from "../lib/billing";
import { plural } from "../lib/plural";
import {
useRun,
@ -93,6 +95,7 @@ import {
type UnknownRecord,
} from "../lib/unknown";
import type {
BilledTokenCounts,
EventEnvelope,
ReasoningOutput,
StageHandler,
@ -866,10 +869,42 @@ export function formatStageModelUsageLabel(
return effort ? `${model}[${effort}]` : model;
}
function ModelUsagePopover({
const POPOVER_NUMBER = "block text-right font-mono tabular-nums";
/** Tokens and cost for this stage visit alone. */
function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
if (!hasBillingUsage(billing)) return null;
const buckets = billingTokenBuckets(billing);
const cost = formatUsdMicros(billing.total_usd_micros);
return (
<div className="mt-3">
<PopoverHeader>Tokens</PopoverHeader>
<PopoverRows>
{buckets.map((bucket) => (
<PopoverRow key={bucket.label} label={bucket.label}>
<span className={POPOVER_NUMBER}>
{bucket.value === 0
? "0"
: formatTokenCount(bucket.value, { compactDecimal: true })}
</span>
</PopoverRow>
))}
{cost && (
<PopoverRow label="Cost">
<span className={POPOVER_NUMBER}>{cost}</span>
</PopoverRow>
)}
</PopoverRows>
</div>
);
}
export function ModelUsagePopover({
providerUsed,
billing,
}: {
providerUsed: StageModelUsage;
billing: BilledTokenCounts;
}) {
return (
<>
@ -892,6 +927,7 @@ function ModelUsagePopover({
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
)}
</PopoverRows>
<StageBillingRows billing={billing} />
</>
);
}
@ -1905,6 +1941,7 @@ function EventsToolbar({
filteredCount,
totalCount,
providerUsed,
billing,
events,
runId,
stageId,
@ -1924,6 +1961,7 @@ function EventsToolbar({
filteredCount: number;
totalCount: number;
providerUsed: StageModelUsage | null;
billing: BilledTokenCounts;
events: EventEnvelope[];
runId: string;
stageId: string;
@ -2004,7 +2042,9 @@ function EventsToolbar({
className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${
showFilters ? "" : "ml-auto"
}`}
content={<ModelUsagePopover providerUsed={providerUsed} />}
content={
<ModelUsagePopover providerUsed={providerUsed} billing={billing} />
}
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
<span className="font-mono">{modelUsageLabel}</span>
@ -2359,6 +2399,7 @@ function RunStageActivityStage({
effectiveTab === "primary" ? turns.length : debugEvents.length
}
providerUsed={selectedStage.providerUsed}
billing={selectedStage.billing}
events={stageEventsQuery.data ?? []}
runId={runId}
stageId={selectedStageId}

View file

@ -12693,6 +12693,7 @@ components:
- status
- node_id
- visit
- billing
properties:
id:
$ref: "#/components/schemas/StageId"
@ -12752,6 +12753,15 @@ components:
format: date-time
description: Wall-clock time the latest attempt of this stage started, if known.
example: "2026-04-29T12:34:56Z"
billing:
$ref: "#/components/schemas/BilledTokenCounts"
description: >-
Token counts for this stage execution alone. `total_usd_micros` is
the provider-reported cost when there is one, otherwise the server
catalog's price for these tokens — the same pricing the
`/runs/{id}/billing` rows use. All-zero counts mean the stage made
no model calls. Unlike the billing rows, which sum every visit of a
node, this covers only this visit.
# ── File Diff Schemas ──────────────────────────────────────────────

View file

@ -1134,6 +1134,7 @@ mod runs {
id: stage_id.clone(),
name: name.to_owned(),
handler,
billing: BilledTokenCounts::default(),
status,
wall_time_ms,
node_id: stage_id.node_id().to_owned(),

View file

@ -2,6 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use fabro_model::Catalog;
use fabro_types::{
Graph, RunProjection, StageHandler, StageId, StageProjection, StageState, StageTiming,
};
@ -22,6 +23,7 @@ fn run_stage_from_projection(
stage_id: &StageId,
stage: &StageProjection,
graph: &Graph,
catalog: &Catalog,
now: DateTime<Utc>,
) -> RunStage {
let handler = stage.handler.unwrap_or_else(|| {
@ -36,6 +38,7 @@ fn run_stage_from_projection(
id: stage_id.clone(),
name: stage_id.node_id().to_owned(),
handler,
billing: stage.billed_usage(Some(catalog)).into_owned(),
status: stage.effective_state(),
wall_time_ms: stage.live_wall_time_ms(now),
node_id: stage_id.node_id().to_owned(),
@ -67,9 +70,10 @@ async fn list_run_stages(
let now = Utc::now();
let graph = projection.spec().graph();
let catalog = state.catalog();
let stages = projection
.iter_stages()
.map(|(stage_id, stage)| run_stage_from_projection(stage_id, stage, graph, now))
.map(|(stage_id, stage)| run_stage_from_projection(stage_id, stage, graph, &catalog, now))
.collect::<Vec<_>>();
(StatusCode::OK, Json(ListResponse::new(stages))).into_response()

View file

@ -5259,6 +5259,64 @@ fn test_billed_usage(
.unwrap()
}
async fn create_billed_retry_run(state: &Arc<AppState>, run_id: RunId) {
create_durable_run_with_events(state, run_id, &[
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
])
.await;
append_scoped_stage_event(
state,
run_id,
"verify",
1,
&workflow_event::Event::StageFailed {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(1200),
billing: Some(test_billed_usage("gpt-old", 100, 10)),
actor: None,
},
)
.await;
append_scoped_stage_event(
state,
run_id,
"verify",
2,
&workflow_event::Event::StageCompleted {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
timing: fabro_types::StageTiming::wall_only(800),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
billing: Some(test_billed_usage("gpt-new", 200, 20)),
failure: None,
notes: None,
files_touched: Vec::new(),
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 2,
max_attempts: 2,
},
)
.await;
}
#[tokio::test]
async fn list_run_stages_distinguishes_visits() {
let state = test_app_state_with_isolated_storage();
@ -5698,65 +5756,9 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
let state = test_app_state_with_isolated_storage();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
let failed_usage = test_billed_usage("gpt-old", 100, 10);
create_billed_retry_run(&state, run_id).await;
let success_usage = test_billed_usage("gpt-new", 200, 20);
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
])
.await;
append_scoped_stage_event(
&state,
run_id,
"verify",
1,
&workflow_event::Event::StageFailed {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
failure: FailureDetail::new("try again", FailureCategory::TransientInfra),
will_retry: true,
timing: fabro_types::StageTiming::wall_only(1200),
billing: Some(failed_usage),
actor: None,
},
)
.await;
append_scoped_stage_event(
&state,
run_id,
"verify",
2,
&workflow_event::Event::StageCompleted {
node_id: "verify".to_string(),
name: "Verify".to_string(),
index: 1,
timing: fabro_types::StageTiming::wall_only(800),
status: "succeeded".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
billing: Some(success_usage.clone()),
failure: None,
notes: None,
files_touched: Vec::new(),
context_updates: None,
jump_to_node: None,
context_values: None,
node_visits: None,
loop_failure_signatures: None,
restart_failure_signatures: None,
response: None,
attempt: 2,
max_attempts: 2,
},
)
.await;
let mut latest_outcome: Outcome<Option<fabro_model::BilledModelUsage>> = Outcome::success();
latest_outcome.usage = Some(success_usage);
latest_outcome.timing = Some(fabro_types::StageTiming::wall_only(800));
@ -5790,7 +5792,6 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
@ -5835,6 +5836,76 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
assert_eq!(new_model["billing"]["input_tokens"], 200);
}
/// The stage popover reads `billing` off the stages list, so it must be scoped
/// to one visit — unlike the Billing tab's rows, which sum every visit of a
/// node.
#[tokio::test]
async fn list_run_stages_reports_billing_per_visit() {
let state = test_app_state_with_isolated_storage();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_billed_retry_run(&state, run_id).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/stages")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
let first = stage_entry(&body, "verify@1");
assert_eq!(first["billing"]["input_tokens"], 100);
assert_eq!(first["billing"]["output_tokens"], 10);
assert_eq!(first["billing"]["total_usd_micros"], 110);
let second = stage_entry(&body, "verify@2");
assert_eq!(second["billing"]["input_tokens"], 200);
assert_eq!(second["billing"]["output_tokens"], 20);
assert_eq!(second["billing"]["total_usd_micros"], 220);
}
#[tokio::test]
async fn list_run_stages_reports_zero_billing_for_a_stage_that_called_no_model() {
let state = test_app_state_with_isolated_storage();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let run_id = RunId::new();
create_durable_run_with_events(&state, run_id, &[
workflow_event::Event::RunSubmitted {
definition_blob: None,
},
workflow_event::Event::RunStarting,
workflow_event::Event::RunRunning,
])
.await;
let started = stage_started_event("script", "command");
append_scoped_stage_event(&state, run_id, "script", 1, &started).await;
let response = app
.oneshot(
Request::builder()
.method("GET")
.uri(api(&format!("/runs/{run_id}/stages")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json!(response, StatusCode::OK).await;
let billing = &stage_entry(&body, "script@1")["billing"];
assert_eq!(billing["input_tokens"], 0);
assert_eq!(billing["output_tokens"], 0);
// No model ran, so there is nothing to price — not a $0.00 cost.
assert!(billing.get("total_usd_micros").is_none());
}
#[tokio::test]
async fn list_run_stages_shows_retrying_after_failed_event() {
let state = test_app_state_with_isolated_storage();

View file

@ -1,32 +1,7 @@
use std::borrow::Cow;
use std::collections::HashMap;
use fabro_model::Catalog;
use fabro_types::{
BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageProjection, StageTiming,
};
fn stage_usage_with_cost<'a>(
catalog: Option<&Catalog>,
stage: &'a StageProjection,
) -> Cow<'a, BilledTokenCounts> {
let Some(catalog) = catalog else {
return Cow::Borrowed(&stage.usage);
};
let Some(model) = stage.model.as_ref() else {
return Cow::Borrowed(&stage.usage);
};
if stage.usage.total_usd_micros.is_some() {
return Cow::Borrowed(&stage.usage);
}
let Some(total_usd_micros) = catalog.price_tokens(model, &stage.usage.token_counts()) else {
return Cow::Borrowed(&stage.usage);
};
let mut usage = stage.usage.clone();
usage.total_usd_micros = Some(total_usd_micros);
Cow::Owned(usage)
}
use fabro_types::{BilledTokenCounts, ModelRef, RunProjection, RunTiming, StageTiming};
#[derive(Debug, Clone, PartialEq)]
pub struct ProjectionBillingStage {
@ -80,7 +55,7 @@ pub fn billing_rollup_from_projection(
if is_boundary_stage(projection, stage_id.node_id()) {
continue;
}
let usage = stage_usage_with_cost(catalog, stage);
let usage = stage.billed_usage(catalog);
let usage = usage.as_ref();
if stage.completion.is_none() && stage.timing.is_none() && usage.is_zero() {
continue;

View file

@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::num::NonZeroU32;
use chrono::{DateTime, Utc};
use fabro_model::{ReasoningEffort, Speed};
use fabro_model::{Catalog, ReasoningEffort, Speed};
use strum::{Display, EnumString, IntoStaticStr};
use crate::run_event::{AgentSessionActivatedProps, StagePromptProps};
@ -599,6 +599,29 @@ impl StageProjection {
self.state
}
/// This stage's token counts with a cost attached.
///
/// A provider-reported cost always wins. Otherwise the catalog prices the
/// recorded tokens for the stage's model. The stored counts pass through
/// untouched when there is no catalog, no model, or no price for that
/// model. Empty usage also passes through untouched. These cases leave
/// `total_usd_micros` as `None` rather than zero.
#[must_use]
pub fn billed_usage(&self, catalog: Option<&Catalog>) -> Cow<'_, BilledTokenCounts> {
if self.usage.total_usd_micros.is_some() || self.usage.is_zero() {
return Cow::Borrowed(&self.usage);
}
let (Some(catalog), Some(model)) = (catalog, self.model.as_ref()) else {
return Cow::Borrowed(&self.usage);
};
let Some(total_usd_micros) = catalog.price_tokens(model, &self.usage.token_counts()) else {
return Cow::Borrowed(&self.usage);
};
let mut usage = self.usage.clone();
usage.total_usd_micros = Some(total_usd_micros);
Cow::Owned(usage)
}
/// Live wall-clock time in milliseconds.
///
/// While the stage is non-terminal (`Pending`, `Running`, or `Retrying`),
@ -1089,11 +1112,13 @@ mod iter_stages_tests {
use std::num::NonZeroU32;
use chrono::Utc;
use fabro_model::{Catalog, ModelRef, ProviderId};
use serde_json::json;
use super::RunProjection;
use crate::{
AgentControlState, Graph, RunId, RunSpec, StageProjection, WorkflowSettings, test_support,
AgentControlState, BilledTokenCounts, Graph, RunId, RunSpec, StageProjection,
WorkflowSettings, test_support,
};
fn seq(n: u32) -> NonZeroU32 {
@ -1215,6 +1240,77 @@ mod iter_stages_tests {
assert_eq!(order, vec!["build@1", "verify@1", "verify@2"]);
}
}
fn priced_stage(total_usd_micros: Option<i64>) -> StageProjection {
let mut stage = StageProjection::new(seq(1));
stage.usage = BilledTokenCounts {
input_tokens: 500_000,
output_tokens: 125_000,
total_tokens: 625_000,
total_usd_micros,
..BilledTokenCounts::default()
};
stage.model = Some(ModelRef {
provider: ProviderId::openai(),
model_id: "gpt-5.4".into(),
speed: None,
});
stage
}
#[test]
fn billed_usage_prices_uncosted_tokens_from_the_catalog() {
let stage = priced_stage(None);
assert_eq!(stage.billed_usage(None).total_usd_micros, None);
let priced = stage.billed_usage(Some(Catalog::builtin()));
assert!(
priced.total_usd_micros.is_some_and(|cost| cost > 0),
"expected a catalog price, got {:?}",
priced.total_usd_micros
);
// Pricing only fills in the cost; the token buckets pass through.
assert_eq!(priced.input_tokens, 500_000);
assert_eq!(priced.output_tokens, 125_000);
}
#[test]
fn billed_usage_keeps_a_provider_reported_cost_over_the_catalog_estimate() {
let stage = priced_stage(Some(42));
assert_eq!(
stage
.billed_usage(Some(Catalog::builtin()))
.total_usd_micros,
Some(42)
);
}
#[test]
fn billed_usage_leaves_a_modelless_stage_uncosted() {
let mut stage = priced_stage(None);
stage.model = None;
assert_eq!(
stage
.billed_usage(Some(Catalog::builtin()))
.total_usd_micros,
None
);
}
#[test]
fn billed_usage_leaves_zero_tokens_uncosted() {
let mut stage = priced_stage(None);
stage.usage = BilledTokenCounts::default();
assert_eq!(
stage
.billed_usage(Some(Catalog::builtin()))
.total_usd_micros,
None
);
}
}
#[cfg(test)]

View file

@ -13,6 +13,9 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { BilledTokenCounts } from './billed-token-counts';
// May contain unused imports in some cases
// @ts-ignore
import type { StageHandler } from './stage-handler';
@ -62,4 +65,8 @@ export interface RunStage {
* Wall-clock time the latest attempt of this stage started, if known.
*/
'started_at'?: string | null;
/**
* Token counts for this stage execution alone. `total_usd_micros` is the provider-reported cost when there is one, otherwise the server catalog\'s price for these tokens the same pricing the `/runs/{id}/billing` rows use. All-zero counts mean the stage made no model calls. Unlike the billing rows, which sum every visit of a node, this covers only this visit.
*/
'billing': BilledTokenCounts;
}