fix: clean up stage billing display

This commit is contained in:
Bryan Helmkamp 2026-07-28 14:57:56 -04:00
parent 7841a77f2c
commit 9641ecf818
No known key found for this signature in database
16 changed files with 266 additions and 302 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 { testBilledTokenCounts } 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: testBilledTokenCounts(),
...overrides,
};
}

View file

@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { testBilledTokenCounts } from "../../lib/test-fixtures";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import { FanInResults } from "./fan-in-results";
@ -20,8 +21,11 @@ const fanInStage: Stage = {
duration: "1s",
nodeId: "join",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: testBilledTokenCounts(),
};
function event(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {

View file

@ -3,6 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import { MemoryRouter } from "react-router";
import { testBilledTokenCounts } from "../../lib/test-fixtures";
import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils";
import type { Stage } from "../stage-sidebar";
import { ParallelChildren } from "./parallel-children";
@ -21,8 +22,11 @@ const parallelStage: Stage = {
duration: "12s",
nodeId: "fork",
visit: 1,
graphVisit: null,
resumedFromStageId: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: testBilledTokenCounts(),
};
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 { testBilledTokenCounts } 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: testBilledTokenCounts(),
...overrides,
};
}

View file

@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import {
billingTokenBuckets,
formatBillingTokenCount,
hasBillingUsage,
} from "./billing";
import { testBilledTokenCounts } from "./test-fixtures";
describe("billingTokenBuckets", () => {
test("returns the shared display order and folds reasoning into output", () => {
expect(
billingTokenBuckets(
testBilledTokenCounts({
input_tokens: 10,
output_tokens: 20,
reasoning_tokens: 5,
cache_read_tokens: 30,
cache_write_tokens: 40,
}),
),
).toEqual([
{ label: "Cache read", value: 30 },
{ label: "Cache creation", value: 40 },
{ label: "Uncached", value: 10 },
{ label: "Output", value: 25 },
]);
});
});
describe("hasBillingUsage", () => {
test("includes cache-only and cost-only usage", () => {
expect(hasBillingUsage(testBilledTokenCounts())).toBe(false);
expect(
hasBillingUsage(testBilledTokenCounts({ cache_read_tokens: 1 })),
).toBe(true);
expect(
hasBillingUsage(testBilledTokenCounts({ total_usd_micros: 1 })),
).toBe(true);
});
});
describe("formatBillingTokenCount", () => {
test("formats zero without a fractional suffix", () => {
expect(formatBillingTokenCount(0)).toBe("0");
expect(formatBillingTokenCount(1_200)).toBe("1.2k");
});
});

View file

@ -0,0 +1,39 @@
import type { BilledTokenCounts } from "@qltysh/fabro-api-client";
import { formatTokenCount } from "./format";
export interface BillingTokenBucket {
label: "Cache read" | "Cache creation" | "Uncached" | "Output";
value: number;
}
/**
* Return the disjoint billing buckets in their shared display order.
* Reasoning tokens are billed as output tokens.
*/
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: billing.output_tokens + billing.reasoning_tokens,
},
];
}
export function hasBillingUsage(billing: BilledTokenCounts): boolean {
return (
billing.input_tokens !== 0 ||
billing.output_tokens !== 0 ||
billing.reasoning_tokens !== 0 ||
billing.cache_read_tokens !== 0 ||
billing.cache_write_tokens !== 0 ||
(billing.total_usd_micros ?? 0) !== 0
);
}
export function formatBillingTokenCount(value: number): string {
return value === 0 ? "0" : formatTokenCount(value, { compactDecimal: true });
}

View file

@ -1,8 +1,23 @@
import { describe, expect, test } from "bun:test";
import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fabro-api-client";
import type {
PaginatedRunStageList,
RunStage,
StageHandler,
StageState,
} from "@qltysh/fabro-api-client";
import type { Stage } from "../components/stage-sidebar";
import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar";
import { testBilledTokenCounts } from "./test-fixtures";
function runStage(
stage: Omit<RunStage, "billing"> & Partial<Pick<RunStage, "billing">>,
): RunStage {
return {
billing: testBilledTokenCounts(),
...stage,
};
}
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
return {
@ -17,7 +32,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage {
duration: "--",
startedAt: null,
providerUsed: null,
billing: null,
billing: testBilledTokenCounts(),
};
}
@ -25,7 +40,7 @@ describe("mapRunStagesToSidebarStages", () => {
test("maps two visits of the same node to distinct sidebar entries", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "apply-changes@1",
name: "Apply Changes",
handler: "command",
@ -39,7 +54,7 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
},
billing: {
billing: testBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
total_tokens: 43_690,
@ -47,24 +62,16 @@ describe("mapRunStagesToSidebarStages", () => {
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_usd_micros: 720_000,
},
},
{
}),
}),
runStage({
id: "apply-changes@2",
name: "Apply Changes",
handler: "agent",
status: "running",
node_id: "apply",
visit: 2,
billing: {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
},
}),
],
meta: { has_more: false },
};
@ -84,8 +91,8 @@ describe("mapRunStagesToSidebarStages", () => {
});
// 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(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");
@ -98,30 +105,30 @@ describe("mapRunStagesToSidebarStages", () => {
test("filters by node_id (suffixed start@1 / exit@1 are still hidden)", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "start@1",
name: "start",
handler: "start",
status: "succeeded",
node_id: "start",
visit: 1,
},
{
}),
runStage({
id: "verify@1",
name: "verify",
handler: "human",
status: "succeeded",
node_id: "verify",
visit: 1,
},
{
}),
runStage({
id: "exit@1",
name: "exit",
handler: "exit",
status: "succeeded",
node_id: "exit",
visit: 1,
},
}),
],
meta: { has_more: false },
};
@ -133,14 +140,14 @@ describe("mapRunStagesToSidebarStages", () => {
test("missing duration renders as '--'", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "verify@1",
name: "verify",
handler: "wait",
status: "running",
node_id: "verify",
visit: 1,
},
}),
],
meta: { has_more: false },
};
@ -151,7 +158,7 @@ describe("mapRunStagesToSidebarStages", () => {
test("maps a resumed execution's identity fields and keeps both entries in order", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "work@1",
name: "work",
handler: "agent",
@ -159,8 +166,8 @@ describe("mapRunStagesToSidebarStages", () => {
node_id: "work",
visit: 1,
graph_visit: 1,
},
{
}),
runStage({
id: "work@2",
name: "work",
handler: "agent",
@ -169,7 +176,7 @@ describe("mapRunStagesToSidebarStages", () => {
visit: 2,
graph_visit: 1,
resumed_from_stage_id: "work@1",
},
}),
],
meta: { has_more: false },
};
@ -187,14 +194,14 @@ describe("mapRunStagesToSidebarStages", () => {
test("omits identity fields for stages recorded before execution tracking", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "verify@1",
name: "verify",
handler: "agent",
status: "succeeded",
node_id: "verify",
visit: 1,
},
}),
],
meta: { has_more: false },
};
@ -207,14 +214,14 @@ describe("mapRunStagesToSidebarStages", () => {
test("preserves the authoritative handler for renderer dispatch", () => {
const stages: PaginatedRunStageList = {
data: [
{
runStage({
id: "approval@1",
name: "approval",
handler: "human" satisfies StageHandler,
status: "pending",
node_id: "approval",
visit: 1,
},
}),
],
meta: { has_more: false },
};

View file

@ -32,7 +32,7 @@ export interface Stage {
* 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 | null;
billing: BilledTokenCounts;
}
export const ACTIVE_STAGE_STATES: ReadonlySet<StageState> = new Set([
@ -108,7 +108,7 @@ export function mapRunStagesToSidebarStages(
: "--",
startedAt: stage.started_at ?? null,
providerUsed: stage.provider_used ?? null,
billing: stage.billing ?? 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 testBilledTokenCounts(
overrides: Partial<BilledTokenCounts> = {},
): BilledTokenCounts {
return {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
...overrides,
};
}

View file

@ -1,11 +1,9 @@
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 type { RunBilling, StageTiming } from "@qltysh/fabro-api-client";
import { testBilledTokenCounts } from "../lib/test-fixtures";
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
return {
@ -24,25 +22,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(),
...testBilledTokenCounts(),
},
by_model: [],
...overrides,
@ -86,21 +71,21 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: testBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: zeroBilling(),
billing: testBilledTokenCounts(),
timing: stageTiming(61000),
state: "succeeded",
},
],
totals: {
timing: stageTiming(61000),
...zeroBilling(),
...testBilledTokenCounts(),
},
}),
);
@ -121,7 +106,7 @@ describe("RunBilling", () => {
{
stage: { id: "start", name: "start" },
model: null,
billing: zeroBilling(),
billing: testBilledTokenCounts(),
timing: stageTiming(),
state: "succeeded",
},
@ -131,7 +116,7 @@ describe("RunBilling", () => {
provider: "anthropic",
model_id: "claude-sonnet-4-5",
},
billing: zeroBilling({
billing: testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -143,7 +128,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(42000),
...zeroBilling({
...testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -157,7 +142,7 @@ describe("RunBilling", () => {
model_id: "claude-sonnet-4-5",
},
stages: 1,
billing: zeroBilling({
billing: testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -204,7 +189,7 @@ describe("RunBilling", () => {
model_id: "claude-opus-4-6",
speed: "fast",
},
billing: zeroBilling({
billing: testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -217,7 +202,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(),
...zeroBilling({
...testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -232,7 +217,7 @@ describe("RunBilling", () => {
speed: "fast",
},
stages: 1,
billing: zeroBilling({
billing: testBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
@ -268,4 +253,4 @@ describe("RunBilling", () => {
Date.now = originalNow;
}
});
});
});

View file

@ -3,14 +3,16 @@ import { Fragment, useMemo } from "react";
import { EmptyState } from "../components/state";
import { Tooltip } from "../components/ui";
import {
formatDurationMs,
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
billingTokenBuckets,
formatBillingTokenCount,
hasBillingUsage,
} from "../lib/billing";
import { formatDurationMs, formatUsdMicros } from "../lib/format";
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,
@ -20,7 +22,7 @@ const EMPTY_VALUE = "—";
function formatTokens(n: number | null | undefined) {
if (n == null) return EMPTY_VALUE;
return formatTokenCount(n, { compactDecimal: true });
return formatBillingTokenCount(n);
}
function formatUsdMicrosOrDash(usdMicros?: number | null): string {
@ -38,24 +40,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.inFlight || 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;
wallTimeMs: number;
inFlight: boolean;
}
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
@ -71,40 +64,18 @@ function liveWallTimeMs(stage: RunBillingStage, now: number): number {
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: stage.billing,
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 rows = 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">
@ -129,41 +100,22 @@ function TokenBreakdown({
* hovering the count reveals the cache breakdown.
*/
function TokensCell({
inputTokens,
outputTokens,
cacheReadTokens,
cacheWriteTokens,
billing,
}: {
inputTokens: number | null;
outputTokens: number | null;
cacheReadTokens: number | null;
cacheWriteTokens: number | null;
billing: BilledTokenCounts | null;
}) {
const inputTokens = billing?.input_tokens;
const outputTokens =
billing == null ? null : billing.output_tokens + billing.reasoning_tokens;
const display = (
<>
{formatTokens(inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(outputTokens)}
</>
);
if (
inputTokens == null ||
outputTokens == null ||
cacheReadTokens == null ||
cacheWriteTokens == null
) {
return display;
}
if (billing == null) 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 +141,15 @@ 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 +170,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 +211,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.model ? row.billing : null} />
</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 +227,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 +261,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 +276,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 { testBilledTokenCounts } 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: testBilledTokenCounts(),
...overrides,
};
}

View file

@ -7,6 +7,7 @@ import type {
StageModelUsage,
} from "@qltysh/fabro-api-client";
import { testBilledTokenCounts } from "../lib/test-fixtures";
import { EventDetails, ModelUsagePopover } from "./run-stages";
const RUN_START = "2026-04-09T12:00:00Z";
@ -84,19 +85,7 @@ const PROVIDER_USED: StageModelUsage = {
reasoning_effort: "max",
};
function billing(partial: Partial<BilledTokenCounts>): BilledTokenCounts {
return {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
...partial,
};
}
function popoverMarkup(counts: BilledTokenCounts | null): string {
function popoverMarkup(counts: BilledTokenCounts): string {
return renderToStaticMarkup(
<ModelUsagePopover providerUsed={PROVIDER_USED} billing={counts} />,
);
@ -105,7 +94,7 @@ function popoverMarkup(counts: BilledTokenCounts | null): string {
describe("ModelUsagePopover billing", () => {
test("shows the visit's token buckets and cost next to the model", () => {
const html = popoverMarkup(
billing({
testBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
reasoning_tokens: 1_200,
@ -131,7 +120,7 @@ describe("ModelUsagePopover billing", () => {
});
test("omits the token section for a stage that called no model", () => {
const html = popoverMarkup(billing({}));
const html = popoverMarkup(testBilledTokenCounts());
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
@ -140,7 +129,11 @@ describe("ModelUsagePopover billing", () => {
test("still shows tokens when nothing priced the stage", () => {
const html = popoverMarkup(
billing({ input_tokens: 1_000, output_tokens: 500, total_tokens: 1_500 }),
testBilledTokenCounts({
input_tokens: 1_000,
output_tokens: 500,
total_tokens: 1_500,
}),
);
expect(html).toContain("Uncached");
@ -148,10 +141,14 @@ describe("ModelUsagePopover billing", () => {
expect(html).not.toContain("Cost");
});
test("renders the model rows alone when the stage list carried no billing", () => {
const html = popoverMarkup(null);
test("shows a reported cost when token buckets are empty", () => {
const html = popoverMarkup(
testBilledTokenCounts({ total_usd_micros: 1_000_000 }),
);
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
expect(html).toContain("Tokens");
expect(html).toContain("Cost");
expect(html).toContain("$1.00");
});
});

View file

@ -62,6 +62,11 @@ import {
} from "../components/stage-renderers/primitives";
import { StageSummary } from "../components/stage-renderers/stage-summary";
import { WaitStatus } from "../components/stage-renderers/wait-status";
import {
billingTokenBuckets,
formatBillingTokenCount,
hasBillingUsage,
} from "../lib/billing";
import {
formatAbsoluteTs,
formatBytes,
@ -870,38 +875,25 @@ export function formatStageModelUsageLabel(
const POPOVER_NUMBER = "block text-right font-mono tabular-nums";
/**
* The disjoint token buckets behind a stage's usage, labelled and ordered to
* match the Billing tab's breakdown so the two views read the same. `Uncached`
* is input that missed the cache; `Output` folds in reasoning tokens.
*/
function stageTokenBuckets(billing: BilledTokenCounts) {
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: billing.output_tokens + billing.reasoning_tokens,
},
];
}
/** Tokens and cost for this stage visit alone. */
function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
const buckets = stageTokenBuckets(billing);
if (buckets.every((bucket) => bucket.value === 0)) return null;
function StageBillingRows({
billing,
className,
}: {
billing: BilledTokenCounts;
className?: string;
}) {
if (!hasBillingUsage(billing)) return null;
const buckets = billingTokenBuckets(billing);
const cost = formatUsdMicros(billing.total_usd_micros);
return (
<div className="mt-3">
<div className={className}>
<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 })}
{formatBillingTokenCount(bucket.value)}
</span>
</PopoverRow>
))}
@ -920,7 +912,7 @@ export function ModelUsagePopover({
billing,
}: {
providerUsed: StageModelUsage;
billing: BilledTokenCounts | null;
billing: BilledTokenCounts;
}) {
return (
<>
@ -943,7 +935,7 @@ export function ModelUsagePopover({
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
)}
</PopoverRows>
{billing && <StageBillingRows billing={billing} />}
<StageBillingRows className="mt-3" billing={billing} />
</>
);
}
@ -1977,7 +1969,7 @@ function EventsToolbar({
filteredCount: number;
totalCount: number;
providerUsed: StageModelUsage | null;
billing: BilledTokenCounts | null;
billing: BilledTokenCounts;
events: EventEnvelope[];
runId: string;
stageId: string;

View file

@ -5650,7 +5650,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() {
}
#[tokio::test]
async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
async fn billing_endpoints_report_retry_usage_per_node_and_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();
@ -5789,76 +5789,8 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
assert_eq!(old_model["billing"]["input_tokens"], 100);
assert_eq!(new_model["stages"], 1);
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. This exercises the same two-visit history as
/// `run_billing_sums_usage_across_retry_visits_and_uses_latest_model`.
#[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_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;
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")

View file

@ -544,11 +544,11 @@ impl StageProjection {
///
/// 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
/// untouched when there is no usage, catalog, model, or price for that
/// model, which leaves `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() {
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 {
@ -1052,4 +1052,17 @@ mod iter_stages_tests {
None
);
}
#[test]
fn billed_usage_leaves_an_unused_model_uncosted() {
let mut stage = priced_stage(None);
stage.usage = BilledTokenCounts::default();
assert_eq!(
stage
.billed_usage(Some(Catalog::builtin()))
.total_usd_micros,
None
);
}
}