Read Usage in the web app and rename the Billing tab to Usage

The run detail tab, route, query hook, and query key say usage. Every
read of input_tokens, output_tokens, total_tokens, reasoning_tokens,
cache_read_tokens, cache_write_tokens, and total_usd_micros moves to
usage.tokens and usage.cost, with lib/usage.ts replacing lib/billing.ts:
totalTokens sums the five buckets, and costSourceTag names a cost that
the provider reported or that was summed from differently sourced parts.
The Usage tab and the stage popover show that tag next to such a cost.

Test fixtures build a Usage through makeUsage; the failing set of the
web tests is unchanged from main (the same 13 environment failures).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-09-14 12:40:04 -06:00
parent aca9bbbf97
commit 1cdd78926e
No known key found for this signature in database
37 changed files with 378 additions and 316 deletions

View file

@ -6,7 +6,7 @@ import {
RunSummaryPanelView,
type RunSummaryPanelViewProps,
} from "./run-summary-panel";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
function instanceText(instance: TestRenderer.ReactTestInstance): string {
const parts: string[] = [];
@ -56,7 +56,7 @@ function makeRun(overrides: Record<string, any> = {}) {
id: "run_1",
created_by: TEST_PRINCIPAL,
diff: null,
billing: null,
usage: makeUsage(),
...overrides,
} as any;
}
@ -163,9 +163,9 @@ describe("RunSummaryPanelView", () => {
);
});
test("renders cost from total_usd_micros", () => {
test("renders cost from the run's usage", () => {
const tree = render({
run: makeRun({ billing: { total_usd_micros: 840_000 } }),
run: makeRun({ usage: makeUsage({}, 840_000) }),
});
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84");
});

View file

@ -128,7 +128,7 @@ export function RunSummaryPanelView({
artifactsLoading,
}: RunSummaryPanelViewProps) {
const diff = run?.diff ?? null;
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
const cost = formatUsdMicros(run?.usage.cost?.usd_micros);
const sandboxKind = sandboxLifecycleKind(run?.sandbox);
return (

View file

@ -45,7 +45,7 @@ describe("SizeChip", () => {
.toBe("Size M · $12.34");
});
test("omits the cost when the run has no billing yet", () => {
test("omits the cost when the run has no cost yet", () => {
expect(tooltipLabel(<SizeChip size="M" />)).toBe("Size M");
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={null} />)).toBe("Size M");
});

View file

@ -21,36 +21,31 @@ import type {
McpToolSummary,
StageContextWindow,
StageProjection,
TokenUsage,
Usage,
} from "@qltysh/fabro-api-client";
import { StageInsightsSidebar } from "./stage-insights-sidebar";
const NO_USAGE: Usage = {
tokens: { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 },
};
function makeStage(overrides: Partial<StageProjection> = {}): StageProjection {
return {
first_event_seq: 1,
state: "running",
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_create_tokens: 0,
total_tokens: 0,
} as StageProjection["usage"],
usage: NO_USAGE,
...overrides,
};
}
const NO_TOKENS: TokenUsage = { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };
/** The coding agent's fold of a stage that has seen nothing yet. */
function makeAgent(overrides: Partial<AgentSessionProjection> = {}): AgentSessionProjection {
return {
root_session_id: "ses_root",
route: { provider: "anthropic", model: "claude-opus-4-7" },
activity: AgentSessionActivity.RUNNING,
usage: NO_TOKENS,
cost_usd_micros: null,
usage: NO_USAGE,
messages: 0,
descendants: {},
context_window: null,
@ -67,8 +62,7 @@ function makeAgent(overrides: Partial<AgentSessionProjection> = {}): AgentSessio
prompts: 1,
prompt: {
completed: false,
usage: NO_TOKENS,
cost_usd_micros: null,
usage: NO_USAGE,
messages: 0,
context_window: null,
tool_calls: 0,
@ -386,7 +380,7 @@ describe("StageInsightsSidebar", () => {
to: "openai/gpt-5.4",
attempt: 1,
error: agentError("rate limited"),
usage: NO_TOKENS,
usage: NO_USAGE,
inference_ms: 120,
tool_ms: 30,
continuation: FailoverContinuation.CONTINUE_TURN,

View file

@ -33,7 +33,7 @@ export function deriveStageSummary(events: EventEnvelope[]): StageSummary {
}
case "stage.completed": {
readFailure(summary, getObject(props, "failure"));
readBilling(summary, getObject(props, "billing"));
readUsage(summary, getObject(props, "usage"));
readTermination(summary, getObject(props, "termination"));
const notes = getString(props, "notes");
if (notes !== undefined) summary.notes = notes;
@ -43,7 +43,7 @@ export function deriveStageSummary(events: EventEnvelope[]): StageSummary {
}
case "stage.failed": {
readFailure(summary, getObject(props, "failure"));
readBilling(summary, getObject(props, "billing"));
readUsage(summary, getObject(props, "usage"));
break;
}
}
@ -59,10 +59,13 @@ function readFailure(summary: StageSummary, failure: unknown) {
if (actor !== undefined) summary.systemActor = actor;
}
function readBilling(summary: StageSummary, billing: unknown) {
if (!billing) return;
const input = getNumber(billing, "input_tokens");
const output = getNumber(billing, "output_tokens");
/** `stage.completed.usage` is a `ModelUsage`: the model, then the usage. */
function readUsage(summary: StageSummary, modelUsage: unknown) {
if (!modelUsage) return;
const tokens = getObject(getObject(modelUsage, "usage"), "tokens");
if (!tokens) return;
const input = getNumber(tokens, "input");
const output = getNumber(tokens, "output");
if (input !== undefined) summary.inputTokens = input;
if (output !== undefined) summary.outputTokens = output;
}

View file

@ -58,11 +58,16 @@ describe("deriveStageSummary", () => {
expect(summary.systemActor).toBe("agent");
});
test("captures billing tokens from stage.completed", () => {
test("captures usage tokens from stage.completed", () => {
const summary = deriveStageSummary([
makeEvent({
event: "stage.completed",
properties: { billing: { input_tokens: 12400, output_tokens: 3120 } },
properties: {
usage: {
model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
usage: { tokens: { input: 12400, output: 3120 } },
},
},
}),
]);
expect(summary.inputTokens).toBe(12400);
@ -118,7 +123,7 @@ describe("deriveStageSummary", () => {
test("tolerates missing or non-numeric properties", () => {
const summary = deriveStageSummary([
makeEvent({ event: "stage.started", properties: {} }),
makeEvent({ event: "stage.completed", properties: { billing: null } }),
makeEvent({ event: "stage.completed", properties: { usage: null } }),
]);
expect(summary).toEqual({});
});
@ -177,7 +182,10 @@ describe("StagePopover rendering", () => {
makeEvent({
event: "stage.completed",
properties: {
billing: { input_tokens: 12400, output_tokens: 3120 },
usage: {
model: { provider: "anthropic", model_id: "claude-sonnet-4-6" },
usage: { tokens: { input: 12400, output: 3120 } },
},
files_touched: ["a.rs", "b.rs"],
},
}),

View file

@ -3,7 +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 { makeUsage } from "../../lib/test-fixtures";
import type { Stage } from "../stage-sidebar";
import { FanInResults } from "./fan-in-results";
@ -23,7 +23,7 @@ const fanInStage: Stage = {
visit: 1,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
};
function event(seq: number, partial: Partial<EventEnvelope>): EventEnvelope {
@ -63,7 +63,7 @@ describe("FanInResults", () => {
event: "prompt.completed",
properties: {
response: "All branch findings are now available.",
billing: { input_tokens: 1200, output_tokens: 340 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } },
},
}),
]);

View file

@ -269,7 +269,7 @@ describe("parseReducerTranscript", () => {
event: "prompt.completed",
properties: {
response: "The branch results are joined.",
billing: { input_tokens: 1200, output_tokens: 340 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } },
},
}),
];

View file

@ -240,9 +240,10 @@ export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscri
} else if (event.event === "prompt.completed" && hasReducer) {
response = getString(props, "response") ?? response;
model = getString(props, "model") ?? model;
const billing = getObject(props, "billing") ?? {};
inputTokens = getNumber(billing, "input_tokens") ?? inputTokens;
outputTokens = getNumber(billing, "output_tokens") ?? outputTokens;
// `prompt.completed.usage` is a `ModelUsage`: the model, then the usage.
const tokens = getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {};
inputTokens = getNumber(tokens, "input") ?? inputTokens;
outputTokens = getNumber(tokens, "output") ?? outputTokens;
}
}

View file

@ -8,7 +8,7 @@ import {
mapRunToRunItem,
runStatusDisplay,
} from "./runs";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
function makeRun(overrides: Partial<Run> = {}): Run {
return {
@ -45,7 +45,7 @@ function makeRun(overrides: Partial<Run> = {}): Run {
tool_time_ms: 0,
active_time_ms: 0,
},
billing: { total_usd_micros: 500000 },
usage: makeUsage({}, 500000),
size: "XS",
diff: null,
pull_request: null,
@ -108,10 +108,10 @@ describe("mapRunListItem", () => {
expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000);
});
test("leaves the billed total undefined for runs without terminal billing", () => {
expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined();
test("leaves the cost undefined for runs whose usage carries none", () => {
expect(mapRunListItem(makeRun({ usage: makeUsage() })).totalUsdMicros).toBeUndefined();
expect(
mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros,
mapRunListItem(makeRun({ usage: makeUsage({ input: 12 }) })).totalUsdMicros,
).toBeUndefined();
});
});
@ -154,7 +154,7 @@ describe("mapRunToRunItem", () => {
completed_at: null,
},
timing: null,
billing: null,
usage: makeUsage(),
});
const item = mapRunToRunItem(summary);
expect(item.id).toBe("01DEF");

View file

@ -120,7 +120,7 @@ export function mapRunListItem(item: Run): RunItem {
additions: item.diff?.additions,
deletions: item.diff?.deletions,
size: item.size,
totalUsdMicros: item.billing?.total_usd_micros ?? undefined,
totalUsdMicros: item.usage.cost?.usd_micros,
};
}

View file

@ -1,28 +0,0 @@
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

@ -122,7 +122,7 @@ function useLifecycleMutation(
// Keep the returned lifecycle state visible while revalidation
// observes the durable follow-up event (notably a 202 cancel).
void mutate(queryKeys.runs.detail(id), result.run, { revalidate: true });
void mutate(queryKeys.runs.billing(id));
void mutate(queryKeys.runs.usage(id));
}
mutateRunListCaches(mutate);
onSuccessExtra?.(result.run, mutate);

View file

@ -25,9 +25,9 @@ import type {
ProviderList,
PullRequestResponse,
RunArtifactListResponse,
RunBilling,
RunProjection,
Run,
RunUsage,
SandboxDetails,
SecretListResponse,
SandboxFileListResponse,
@ -286,10 +286,10 @@ export function useRunSettings<T = WorkflowSettings>(id: string | undefined) {
);
}
export function useRunBilling(id: string | undefined) {
return useSWR<RunBilling>(
id ? queryKeys.runs.billing(id) : null,
() => apiData(() => runOutputsApi.retrieveRunBilling(id!)),
export function useRunUsage(id: string | undefined) {
return useSWR<RunUsage>(
id ? queryKeys.runs.usage(id) : null,
() => apiData(() => runOutputsApi.retrieveRunUsage(id!)),
);
}

View file

@ -57,7 +57,7 @@ export const queryKeys = {
settings: (id: string) => ["runs", "settings", id] as const,
logs: (id: string) => ["runs", "logs", id] as const,
artifacts: (id: string) => ["runs", "artifacts", id] as const,
billing: (id: string) => ["runs", "billing", id] as const,
usage: (id: string) => ["runs", "usage", id] as const,
questions: (id: string, limit = 1, offset = 0) =>
["runs", "questions", id, limit, offset] as const,
events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const,

View file

@ -29,7 +29,7 @@ import {
unarchiveRuns,
} from "./run-actions";
import { generatedAxios } from "./api-client";
import { TEST_PRINCIPAL } from "./test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "./test-fixtures";
type StubResponseInit = {
status: number;
@ -74,7 +74,7 @@ function makeRun(status: RunStatus, archived = false): Run {
last_event_at: null,
completed_at: null,
},
billing: null,
usage: makeUsage(),
size: "XS",
diff: null,
pull_request: null,

View file

@ -46,7 +46,7 @@ describe("queryKeysForRunEvent", () => {
queryKeys.runs.state("run-1"),
...queryKeys.runs.filesAllScopes("run-1"),
queryKeys.runs.commits("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stages("run-1"),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
@ -56,7 +56,7 @@ describe("queryKeysForRunEvent", () => {
test("stage.retrying invalidates stage-scoped and run-scoped resources", () => {
expect(queryKeysForRunEvent("run-1", "stage.retrying", "verify@2")).toEqual([
queryKeys.runs.stages("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.graph("run-1", "LR"),
queryKeys.runs.graph("run-1", "TB"),
@ -86,7 +86,7 @@ describe("queryKeysForRunEvent", () => {
test("interrupt settlement invalidates projected control state and stage activity", () => {
expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.events("run-1", 1000),
queryKeys.runs.stageEvents("run-1", "nap@1"),
@ -166,7 +166,7 @@ describe("queryKeysForRunEvent", () => {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
@ -181,14 +181,14 @@ describe("queryKeysForRunEvent", () => {
).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
]);
});
@ -202,7 +202,7 @@ describe("queryKeysForRunEvent", () => {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
]);
}
@ -213,7 +213,7 @@ describe("queryKeysForRunEvent", () => {
expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([
queryKeys.runs.detail("run-1"),
queryKeys.runs.state("run-1"),
queryKeys.runs.billing("run-1"),
queryKeys.runs.usage("run-1"),
queryKeys.runs.stageEvents("run-1", "code@1"),
queryKeys.runs.stageContextWindow("run-1", "code@1"),
]);
@ -285,7 +285,7 @@ describe("subscribeToRunEvents", () => {
source.emit({ event: "run.failed", run_id: "run-terminal" });
expect(source.closed).toBe(false);
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal"));
keys.length = 0;
source.emit({ event: "run.archived", run_id: "run-terminal" });
@ -383,7 +383,7 @@ describe("subscribeToRunEvents", () => {
expect(source.closed).toBe(true);
expect(keys).toContainEqual(queryKeys.runs.files("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal"));
expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal"));
cleanup();
coordinator.close();

View file

@ -177,7 +177,7 @@ function liveTimingKeys(runId: string): Key[] {
return [
queryKeys.runs.detail(runId),
queryKeys.runs.state(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.usage(runId),
];
}
@ -199,7 +199,7 @@ export function queryKeysForRunEvent(
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
@ -220,7 +220,7 @@ export function queryKeysForRunEvent(
if (STAGE_EVENTS.has(event)) {
const keys: Key[] = [
queryKeys.runs.stages(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),
queryKeys.runs.graph(runId, "TB"),
@ -255,7 +255,7 @@ export function queryKeysForRunEvent(
if (event === "agent.round.interrupted") {
keys.unshift(
queryKeys.runs.detail(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.usage(runId),
);
}
if (stageId) {
@ -372,7 +372,7 @@ function resyncKeysForRun(runId: string) {
queryKeys.runs.state(runId),
...queryKeys.runs.filesAllScopes(runId),
queryKeys.runs.commits(runId),
queryKeys.runs.billing(runId),
queryKeys.runs.usage(runId),
queryKeys.runs.stages(runId),
queryKeys.runs.events(runId, 1000),
queryKeys.runs.graph(runId, "LR"),

View file

@ -3,7 +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";
import { makeUsage } from "./test-fixtures";
import { makeStage as baseMakeStage } from "./test-utils";
function makeStage(nodeId: string, visit: number, status: StageState): Stage {
@ -28,15 +28,16 @@ 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,
}),
usage: makeUsage(
{
input: 28_640,
output: 7_550,
reasoning: 1_200,
cache_read: 4_800,
cache_write: 1_500,
},
720_000,
),
},
{
id: "apply-changes@2",
@ -45,7 +46,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "apply",
visit: 2,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },
@ -66,8 +67,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].usage.cost?.usd_micros).toBe(720_000);
expect(result[1].usage.cost).toBeUndefined();
expect(formatStageLabel(result[0])).toBe("Apply Changes");
expect(result[1].id).toBe("apply-changes@2");
@ -87,7 +88,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "start",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
{
id: "verify@1",
@ -96,7 +97,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
{
id: "exit@1",
@ -105,7 +106,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "exit",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },
@ -125,7 +126,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },
@ -145,7 +146,7 @@ describe("mapRunStagesToSidebarStages", () => {
node_id: "work",
visit: 1,
graph_visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
{
id: "work@2",
@ -156,7 +157,7 @@ describe("mapRunStagesToSidebarStages", () => {
visit: 2,
graph_visit: 1,
resumed_from_stage_id: "work@1",
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },
@ -182,7 +183,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },
@ -225,7 +226,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "pending",
node_id: "approval",
visit: 1,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
},
],
meta: { has_more: false },

View file

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

View file

@ -1,4 +1,4 @@
import type { BilledTokenCounts, Principal } from "@qltysh/fabro-api-client";
import type { Cost, Principal, TokenCounts, Usage } from "@qltysh/fabro-api-client";
export const TEST_PRINCIPAL: Principal = {
kind: "user",
@ -7,16 +7,24 @@ export const TEST_PRINCIPAL: Principal = {
auth_method: "dev_token",
};
export function makeBilledTokenCounts(
overrides: Partial<BilledTokenCounts> = {},
): BilledTokenCounts {
export function makeTokenCounts(overrides: Partial<TokenCounts> = {}): TokenCounts {
return {
cache_read_tokens: 0,
cache_write_tokens: 0,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: 0,
total_tokens: 0,
input: 0,
output: 0,
reasoning: 0,
cache_read: 0,
cache_write: 0,
...overrides,
};
}
/** A usage with the given token buckets and, when `cost` is given, a catalog cost. */
export function makeUsage(
tokens: Partial<TokenCounts> = {},
cost?: number | Cost,
): Usage {
const usage: Usage = { tokens: makeTokenCounts(tokens) };
if (typeof cost === "number") usage.cost = { usd_micros: cost, source: "catalog" };
else if (cost) usage.cost = cost;
return usage;
}

View file

@ -3,7 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client";
import TestRenderer, { act } from "react-test-renderer";
import type { Stage } from "./stage-sidebar";
import { makeBilledTokenCounts } from "./test-fixtures";
import { makeUsage } from "./test-fixtures";
const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const;
@ -85,7 +85,7 @@ export function makeStage(overrides: Partial<Stage> = {}): Stage {
duration: "--",
startedAt: null,
providerUsed: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
...overrides,
};
}

View file

@ -0,0 +1,53 @@
import type { Cost, TokenCounts, Usage } from "@qltysh/fabro-api-client";
export interface UsageTokenBucket {
label: string;
value: number;
}
/** The sum of the five disjoint token buckets. */
export function totalTokens(usage: Usage): number {
const { input, output, reasoning, cache_read, cache_write } = usage.tokens;
return input + output + reasoning + cache_read + cache_write;
}
/** Output tokens as they are priced: completion plus reasoning. */
export function billableOutputTokens(tokens: TokenCounts): number {
return tokens.output + tokens.reasoning;
}
/** The disjoint token buckets shown in every usage breakdown. */
export function usageTokenBuckets(usage: Usage): UsageTokenBucket[] {
return [
{ label: "Cache read", value: usage.tokens.cache_read },
{ label: "Cache creation", value: usage.tokens.cache_write },
{ label: "Uncached", value: usage.tokens.input },
{ label: "Output", value: billableOutputTokens(usage.tokens) },
];
}
/** Whether the usage carries any tokens or a cost. */
export function hasUsage(usage: Usage): boolean {
return totalTokens(usage) !== 0 || (usage.cost?.usd_micros ?? 0) !== 0;
}
/** The cost in USD micros, when the usage carries one. */
export function costUsdMicros(usage: Usage | null | undefined): number | undefined {
return usage?.cost?.usd_micros;
}
/**
* A short tag for a cost that did not come from the catalog: `reported`
* when the provider gave the figure, `summed` when it was assembled from
* differently sourced parts. Catalog estimates carry no tag.
*/
export function costSourceTag(cost: Cost | null | undefined): string | null {
switch (cost?.source) {
case "provider":
return "reported";
case "application":
return "summed";
default:
return null;
}
}

View file

@ -27,7 +27,7 @@ import * as RunChildren from "./routes/run-children";
import * as RunFiles from "./routes/run-files";
import * as RunSandbox from "./routes/run-sandbox";
import * as RunTerminal from "./routes/run-terminal";
import * as RunBilling from "./routes/run-billing";
import * as RunUsage from "./routes/run-usage";
import * as Insights from "./routes/insights";
import * as InsightsEditor from "./routes/insights-editor";
import * as InsightsNew from "./routes/insights-new";
@ -136,7 +136,7 @@ export const routes: RouteObject[] = [
route("files", RunFiles),
route("children", RunChildren),
route("sandbox", RunSandbox),
route("billing", RunBilling),
route("usage", RunUsage),
],
}),
route("insights", Insights, {

View file

@ -4,7 +4,7 @@ import TestRenderer, { act } from "react-test-renderer";
import { createMemoryRouter, RouterProvider } from "react-router";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
let currentRun: any = null;
@ -176,7 +176,7 @@ function makeRun(overrides: Record<string, unknown> = {}) {
completed_at: null,
},
timing: null,
billing: null,
usage: makeUsage(),
size: "XS",
ask_fabro: {
available: false,

View file

@ -14,7 +14,7 @@ import {
import { ToastProvider } from "../components/toast";
import { DemoModeProvider } from "../lib/demo-mode";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
let currentRunSummary: any = null;
let currentRunState: any = null;
@ -267,7 +267,7 @@ function makeRunSummary({
completed_at: null,
},
timing: null,
billing: null,
usage: makeUsage(),
size: "XS",
diff: diffSummary,
pull_request: pullRequest,

View file

@ -93,7 +93,7 @@ export function RunDetailHeader({
</span>
);
const sizeChip = (
<SizeChip size={summary.size} totalUsdMicros={summary.billing?.total_usd_micros} />
<SizeChip size={summary.size} totalUsdMicros={summary.usage.cost?.usd_micros} />
);
return (

View file

@ -16,7 +16,7 @@ const allTabs: RunDetailTabDefinition[] = [
{ name: "Files Changed", path: "/files", count: null },
{ name: "Children", path: "/children", count: null },
{ name: "Sandbox", path: "/sandbox", count: null, requiresSandbox: true },
{ name: "Billing", path: "/billing", count: null },
{ name: "Usage", path: "/usage", count: null },
];
export type RunDetailTab = RunDetailTabDefinition;

View file

@ -5,7 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router";
import { toast as sonnerToast } from "sonner";
import { ToastProvider } from "../components/toast";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
let currentFilesPayload: any = null;
let currentCommitsPayload: any = null;
@ -73,7 +73,7 @@ mock.module("../lib/queries", () => ({
last_event_at: null,
completed_at: null,
},
billing: null,
usage: makeUsage(),
size: "XS",
diff: null,
pull_request: null,

View file

@ -3,7 +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 { makeUsage } from "../lib/test-fixtures";
import { StageChatView } from "./run-stages";
function stage(overrides: Partial<Stage> = {}): Stage {
@ -19,7 +19,7 @@ function stage(overrides: Partial<Stage> = {}): Stage {
resumedFromStageId: null,
startedAt: "2026-04-09T12:00:00Z",
providerUsed: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
...overrides,
};
}

View file

@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type {
BilledTokenCounts,
Usage,
ReasoningOutput,
StageModelUsage,
} from "@qltysh/fabro-api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { makeUsage } from "../lib/test-fixtures";
import { EventDetails, ModelUsagePopover } from "./run-stages";
const RUN_START = "2026-04-09T12:00:00Z";
@ -85,24 +85,25 @@ const PROVIDER_USED: StageModelUsage = {
reasoning_effort: "max",
};
function popoverMarkup(counts: BilledTokenCounts): string {
function popoverMarkup(usage: Usage): string {
return renderToStaticMarkup(
<ModelUsagePopover providerUsed={PROVIDER_USED} billing={counts} />,
<ModelUsagePopover providerUsed={PROVIDER_USED} usage={usage} />,
);
}
describe("ModelUsagePopover billing", () => {
describe("ModelUsagePopover usage", () => {
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,
}),
makeUsage(
{
input: 28_640,
output: 7_550,
reasoning: 1_200,
cache_read: 4_800,
cache_write: 1_500,
},
720_000,
),
);
expect(html).toContain("kimi-k3");
@ -112,7 +113,7 @@ describe("ModelUsagePopover billing", () => {
expect(html).toContain("1.5k");
expect(html).toContain("Uncached");
expect(html).toContain("28.6k");
// Output folds in reasoning tokens, matching the Billing tab.
// Output folds in reasoning tokens, matching the Usage tab.
expect(html).toContain("Output");
expect(html).toContain("8.8k");
expect(html).toContain("Cost");
@ -120,7 +121,7 @@ describe("ModelUsagePopover billing", () => {
});
test("omits the token section for a stage that called no model", () => {
const html = popoverMarkup(makeBilledTokenCounts());
const html = popoverMarkup(makeUsage());
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
@ -129,11 +130,7 @@ describe("ModelUsagePopover billing", () => {
test("still shows tokens when nothing priced the stage", () => {
const html = popoverMarkup(
makeBilledTokenCounts({
input_tokens: 1_000,
output_tokens: 500,
total_tokens: 1_500,
}),
makeUsage({ input: 1_000, output: 500 }),
);
expect(html).toContain("Uncached");
@ -143,7 +140,7 @@ describe("ModelUsagePopover billing", () => {
test("shows a provider-reported cost when token counts are unavailable", () => {
const html = popoverMarkup(
makeBilledTokenCounts({ total_usd_micros: 720_000 }),
makeUsage({}, { usd_micros: 720_000, source: "provider" }),
);
expect(html).toContain("kimi-k3");

View file

@ -382,7 +382,7 @@ describe("eventsToActivity", () => {
response: "Refactored auth module",
model: "claude-sonnet-4-6",
provider: "anthropic",
billing: { input_tokens: 120, output_tokens: 30 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 120, output: 30 } } },
},
}),
];
@ -430,7 +430,7 @@ describe("eventsToActivity", () => {
response: "Done.",
model: "claude-sonnet-4-6",
provider: "anthropic",
billing: { input_tokens: 10, output_tokens: 5 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 10, output: 5 } } },
},
}),
];
@ -464,7 +464,7 @@ describe("eventsToActivity", () => {
response: "All clear.",
model: "claude-sonnet-4-6",
provider: "anthropic",
billing: { input_tokens: 0, output_tokens: 4 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 0, output: 4 } } },
},
}),
];
@ -1263,7 +1263,7 @@ describe("buildThreadDnaItems", () => {
});
describe("tool-call-only agent responses", () => {
test("retains an empty agent.message with its timestamp, billing, and tool-call count", () => {
test("retains an empty agent.message with its timestamp, usage, and tool-call count", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "agent.message",
@ -1307,7 +1307,7 @@ describe("tool-call-only agent responses", () => {
node_id: "code",
properties: {
response: "",
billing: { input_tokens: 1, output_tokens: 2 },
usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1, output: 2 } } },
},
}),
];

View file

@ -69,7 +69,7 @@ import {
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
import { billingTokenBuckets, hasBillingUsage } from "../lib/billing";
import { costSourceTag, hasUsage, usageTokenBuckets } from "../lib/usage";
import { plural } from "../lib/plural";
import {
useRun,
@ -96,11 +96,11 @@ import {
type UnknownRecord,
} from "../lib/unknown";
import type {
BilledTokenCounts,
EventEnvelope,
ReasoningOutput,
StageHandler,
StageModelUsage,
Usage,
} from "@qltysh/fabro-api-client";
export const handle = { wide: true, fullHeight: true };
@ -370,13 +370,15 @@ export function buildStageActivity(
}
case "prompt.completed": {
if (!sawAssistantMessage) {
const billing = (props.billing ?? {}) as UnknownRecord;
// `prompt.completed.usage` is a `ModelUsage`: the model, then the usage.
const tokens =
getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {};
turns.push({
kind: "assistant",
ts: e.ts,
content: getString(props, "response") ?? "",
inputTokens: getNumber(billing, "input_tokens") ?? 0,
outputTokens: getNumber(billing, "output_tokens") ?? 0,
inputTokens: getNumber(tokens, "input") ?? 0,
outputTokens: getNumber(tokens, "output") ?? 0,
toolCallCount: null,
// Only agent.message carries reasoning; prompt stages have none.
reasoning: null,
@ -887,10 +889,11 @@ export function formatStageModelUsageLabel(
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);
function StageUsageRows({ usage }: { usage: Usage }) {
if (!hasUsage(usage)) return null;
const buckets = usageTokenBuckets(usage);
const cost = formatUsdMicros(usage.cost?.usd_micros);
const costTag = costSourceTag(usage.cost);
return (
<div className="mt-3">
<PopoverHeader>Tokens</PopoverHeader>
@ -905,7 +908,7 @@ function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
</PopoverRow>
))}
{cost && (
<PopoverRow label="Cost">
<PopoverRow label={costTag ? `Cost (${costTag})` : "Cost"}>
<span className={POPOVER_NUMBER}>{cost}</span>
</PopoverRow>
)}
@ -916,10 +919,10 @@ function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
export function ModelUsagePopover({
providerUsed,
billing,
usage,
}: {
providerUsed: StageModelUsage;
billing: BilledTokenCounts;
usage: Usage;
}) {
return (
<>
@ -942,7 +945,7 @@ export function ModelUsagePopover({
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
)}
</PopoverRows>
<StageBillingRows billing={billing} />
<StageUsageRows usage={usage} />
</>
);
}
@ -1956,7 +1959,7 @@ function EventsToolbar({
filteredCount,
totalCount,
providerUsed,
billing,
usage,
events,
runId,
stageId,
@ -1976,7 +1979,7 @@ function EventsToolbar({
filteredCount: number;
totalCount: number;
providerUsed: StageModelUsage | null;
billing: BilledTokenCounts;
usage: Usage;
events: EventEnvelope[];
runId: string;
stageId: string;
@ -2058,7 +2061,7 @@ function EventsToolbar({
showFilters ? "" : "ml-auto"
}`}
content={
<ModelUsagePopover providerUsed={providerUsed} billing={billing} />
<ModelUsagePopover providerUsed={providerUsed} usage={usage} />
}
>
<CpuChipIcon className="size-3.5" aria-hidden="true" />
@ -2414,7 +2417,7 @@ function RunStageActivityStage({
effectiveTab === "primary" ? turns.length : debugEvents.length
}
providerUsed={selectedStage.providerUsed}
billing={selectedStage.billing}
usage={selectedStage.usage}
events={stageEventsQuery.data ?? []}
runId={runId}
stageId={selectedStageId}

View file

@ -2,11 +2,11 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
import TestRenderer from "react-test-renderer";
import type {
RunBilling,
RunUsage,
StageTiming,
} from "@qltysh/fabro-api-client";
import { makeBilledTokenCounts } from "../lib/test-fixtures";
import { makeUsage } from "../lib/test-fixtures";
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
return {
@ -17,33 +17,33 @@ function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0):
};
}
let currentBilling: RunBilling | undefined;
let currentUsage: RunUsage | undefined;
mock.module("../lib/queries", () => ({
useRunBilling: () => ({ data: currentBilling }),
useRunUsage: () => ({ data: currentUsage }),
}));
const { default: RunBillingRoute } = await import("./run-billing");
const { default: RunUsageRoute } = await import("./run-usage");
function billing(overrides: Partial<RunBilling> = {}): RunBilling {
function runUsage(overrides: Partial<RunUsage> = {}): RunUsage {
return {
stages: [],
totals: {
timing: stageTiming(),
...makeBilledTokenCounts(),
usage: makeUsage(),
},
by_model: [],
...overrides,
};
}
function renderBilling(data: RunBilling): TestRenderer.ReactTestRenderer {
currentBilling = data;
function renderUsage(data: RunUsage): TestRenderer.ReactTestRenderer {
currentUsage = data;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let renderer: TestRenderer.ReactTestRenderer | undefined;
TestRenderer.act(() => {
renderer = TestRenderer.create(<RunBillingRoute params={{ id: "run_1" }} />);
renderer = TestRenderer.create(<RunUsageRoute params={{ id: "run_1" }} />);
});
return renderer!;
}
@ -61,34 +61,34 @@ function textFromInstance(node: TestRenderer.ReactTestInstance): string {
.join("");
}
describe("RunBilling", () => {
describe("RunUsage", () => {
afterEach(() => {
currentBilling = undefined;
currentUsage = undefined;
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
});
test("shows a no-model-usage empty state when every stage is non-billable", () => {
const renderer = renderBilling(
billing({
test("shows a no-model-usage empty state when every stage called no model", () => {
const renderer = renderUsage(
runUsage({
stages: [
{
stage: { id: "start", name: "start" },
model: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
timing: stageTiming(),
state: "succeeded",
},
{
stage: { id: "command", name: "command" },
model: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
timing: stageTiming(61000),
state: "succeeded",
},
],
totals: {
timing: stageTiming(61000),
...makeBilledTokenCounts(),
usage: makeUsage(),
},
}),
);
@ -103,13 +103,13 @@ describe("RunBilling", () => {
});
test("renders mixed LLM and non-LLM rows while counting only LLM rows by model", () => {
const renderer = renderBilling(
billing({
const renderer = renderUsage(
runUsage({
stages: [
{
stage: { id: "start", name: "start" },
model: null,
billing: makeBilledTokenCounts(),
usage: makeUsage(),
timing: stageTiming(),
state: "succeeded",
},
@ -119,24 +119,14 @@ describe("RunBilling", () => {
provider: "anthropic",
model_id: "claude-sonnet-4-5",
},
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
timing: stageTiming(42000),
state: "succeeded",
},
],
totals: {
timing: stageTiming(42000),
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
},
by_model: [
{
@ -145,12 +135,7 @@ describe("RunBilling", () => {
model_id: "claude-sonnet-4-5",
},
stages: 1,
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
},
],
}),
@ -160,21 +145,56 @@ describe("RunBilling", () => {
expect(text).not.toContain("start");
expect(text).toContain("agent");
expect(text).toContain("By model");
expect(text).toContain("$0.24");
// A catalog estimate carries no source tag.
expect(text).not.toContain("reported");
const footers = renderer.root.findAll((node) => node.type === "tfoot");
const byModelFooterCells = footers[1].findAll((node) => node.type === "td");
expect(textFromInstance(byModelFooterCells[1])).toBe("1");
});
test("tags a provider-reported cost with its source", () => {
const reported = makeUsage({ input: 1200, output: 300 }, {
usd_micros: 240000,
source: "provider",
});
const renderer = renderUsage(
runUsage({
stages: [
{
stage: { id: "agent", name: "agent" },
model: { provider: "openrouter", model_id: "kimi-k3" },
usage: reported,
timing: stageTiming(42000),
state: "succeeded",
},
],
totals: { timing: stageTiming(42000), usage: reported },
by_model: [
{
model: { provider: "openrouter", model_id: "kimi-k3" },
stages: 1,
usage: reported,
},
],
}),
);
const text = textFromNode(renderer.toJSON());
expect(text).toContain("$0.24");
expect(text).toContain("reported");
});
test("keeps the empty state for runs with no stages", () => {
const renderer = renderBilling(billing());
const renderer = renderUsage(runUsage());
const text = textFromNode(renderer.toJSON());
expect(text).toContain("No stages yet");
expect(text).toContain("Stages will appear as soon as the run starts executing.");
});
test("renders an in-flight row with live billing and includes its elapsed time in the footer", () => {
test("renders an in-flight row with live usage and includes its elapsed time in the footer", () => {
const originalNow = Date.now;
// Pin "now" to 30s after the in-flight row started.
const startedAt = "2026-04-29T12:00:00.000Z";
@ -182,8 +202,8 @@ describe("RunBilling", () => {
Date.now = () => fakeNow;
try {
const renderer = renderBilling(
billing({
const renderer = renderUsage(
runUsage({
stages: [
{
stage: { id: "in-flight", name: "in-flight" },
@ -192,12 +212,7 @@ describe("RunBilling", () => {
model_id: "claude-opus-4-6",
speed: "fast",
},
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
timing: stageTiming(),
started_at: startedAt,
state: "running",
@ -205,12 +220,7 @@ describe("RunBilling", () => {
],
totals: {
timing: stageTiming(),
...makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
},
by_model: [
{
@ -220,12 +230,7 @@ describe("RunBilling", () => {
speed: "fast",
},
stages: 1,
billing: makeBilledTokenCounts({
input_tokens: 1200,
output_tokens: 300,
total_tokens: 1500,
total_usd_micros: 240000,
}),
usage: makeUsage({ input: 1200, output: 300 }, 240000),
},
],
}),
@ -249,7 +254,7 @@ describe("RunBilling", () => {
const footers = renderer.root.findAll((node) => node.type === "tfoot");
const footerCells = footers[0].findAll((node) => node.type === "td");
// The Run time column in the footer is index 3 (Total / [empty Model] /
// Tokens / Run time / Billing).
// Tokens / Run time / Cost).
const footerRuntime = textFromInstance(footerCells[3]);
expect(footerRuntime).toContain("30s");
} finally {

View file

@ -2,24 +2,25 @@ 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,
formatUsdMicros,
} from "../lib/format";
import { useRunBilling } from "../lib/queries";
import { useRunUsage } from "../lib/queries";
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
import { useTickingNow } from "../lib/time";
import {
billableOutputTokens,
costSourceTag,
hasUsage,
usageTokenBuckets,
} from "../lib/usage";
import type {
BilledTokenCounts,
BillingModelRef,
RunBilling,
RunBillingStage,
RunUsage,
RunUsageStage,
Usage,
UsageModelRef,
} from "@qltysh/fabro-api-client";
const EMPTY_VALUE = "—";
@ -29,34 +30,30 @@ function formatTokens(n: number | null | undefined) {
return formatTokenCount(n, { compactDecimal: true });
}
function formatUsdMicrosOrDash(usdMicros?: number | null): string {
return formatUsdMicros(usdMicros) ?? EMPTY_VALUE;
}
function formatModelRef(model?: BillingModelRef | null): string | null {
function formatModelRef(model?: UsageModelRef | null): string | null {
if (!model) return null;
const speed = model.speed ? ` · ${model.speed}` : "";
return `${model.provider}:${model.model_id}${speed}`;
}
function isInFlight(stage: RunBillingStage): boolean {
function isInFlight(stage: RunUsageStage): boolean {
return stage.state != null && IN_FLIGHT_STAGE_STATES.has(stage.state);
}
function isVisibleRow(row: MappedStageRow): boolean {
if (row.inFlight) return true;
return row.billing != null && hasBillingUsage(row.billing);
return row.usage != null && hasUsage(row.usage);
}
interface MappedStageRow {
stage: string;
model: string | null;
billing: BilledTokenCounts | null;
usage: Usage | null;
wallTimeMs: number;
inFlight: boolean;
}
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
function liveWallTimeMs(stage: RunUsageStage, now: number): number {
if (stage.started_at) {
const startedMs = new Date(stage.started_at).getTime();
if (Number.isFinite(startedMs)) {
@ -68,20 +65,20 @@ function liveWallTimeMs(stage: RunBillingStage, now: number): number {
export const handle = { wide: true };
function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow {
function mapStageRow(stage: RunUsageStage, wallTimeMs: number): MappedStageRow {
const hasModel = stage.model != null;
return {
stage: stage.stage.name,
model: formatModelRef(stage.model),
billing: hasModel ? stage.billing : null,
usage: hasModel ? stage.usage : null,
wallTimeMs,
inFlight: isInFlight(stage),
};
}
/** Hover breakdown of the disjoint token buckets behind an `in / out` count. */
function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
const buckets = billingTokenBuckets(billing);
function TokenBreakdown({ usage }: { usage: Usage }) {
const buckets = usageTokenBuckets(usage);
return (
<div className="min-w-44 py-0.5">
<div className="border-line text-fg-2 mb-1.5 border-b pb-1 font-medium">
@ -108,71 +105,91 @@ function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
* Renders an `input / output` token count. When the row has model usage,
* hovering the count reveals the cache breakdown.
*/
function TokensCell({ billing }: { billing: BilledTokenCounts | null }) {
function TokensCell({ usage }: { usage: Usage | null }) {
const display = (
<>
{formatTokens(billing?.input_tokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(billing ? billableOutputTokens(billing) : null)}
{formatTokens(usage?.tokens.input)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(usage ? billableOutputTokens(usage.tokens) : null)}
</>
);
if (!billing) return display;
if (!usage) return display;
return (
<Tooltip label={<TokenBreakdown billing={billing} />}>
<Tooltip label={<TokenBreakdown usage={usage} />}>
<span>{display}</span>
</Tooltip>
);
}
export default function RunBilling({ params }: { params: { id: string } }) {
const billingQuery = useRunBilling(params.id);
const billing = billingQuery.data;
const hasInFlight = billing?.stages.some(isInFlight) ?? false;
/**
* Renders a cost, or a dash when the usage carries none. A cost that did not
* come from the catalog is tagged with where it came from.
*/
function CostCell({ usage }: { usage: Usage | null | undefined }) {
const cost = usage?.cost;
const formatted = formatUsdMicros(cost?.usd_micros);
if (formatted == null) return <>{EMPTY_VALUE}</>;
const tag = costSourceTag(cost);
return (
<>
{formatted}
{tag ? (
<span className="text-fg-muted ml-1.5 font-sans text-[10px] uppercase tracking-wide">
{tag}
</span>
) : null}
</>
);
}
export default function RunUsageRoute({ params }: { params: { id: string } }) {
const usageQuery = useRunUsage(params.id);
const runUsage: RunUsage | undefined = usageQuery.data;
const hasInFlight = runUsage?.stages.some(isInFlight) ?? false;
// Tick once per second only while a stage is in-flight.
const now = useTickingNow(hasInFlight);
// Completed rows don't depend on `now`; memoize them by `billing` so we
// Completed rows don't depend on `now`; memoize them by `runUsage` so we
// don't reallocate them every tick.
const completedRows = useMemo<MappedStageRow[]>(() => {
if (!billing) return [];
return billing.stages.map((stage) => mapStageRow(stage, stage.timing.wall_time_ms));
}, [billing]);
if (!runUsage) return [];
return runUsage.stages.map((stage) => mapStageRow(stage, stage.timing.wall_time_ms));
}, [runUsage]);
// The model breakdown is server-derived and stable across ticks too.
const modelBreakdown = useMemo(() => {
if (!billing) return [];
return billing.by_model
if (!runUsage) return [];
return runUsage.by_model
.map((entry) => ({
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
billing: entry.billing,
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
stages: entry.stages,
usage: entry.usage,
}))
.sort(
(a, b) =>
(b.billing.total_usd_micros ?? -1) - (a.billing.total_usd_micros ?? -1),
(b.usage.cost?.usd_micros ?? -1) - (a.usage.cost?.usd_micros ?? -1),
);
}, [billing]);
}, [runUsage]);
// Re-derive only the in-flight rows on each tick; everything else stays put.
const rows = useMemo<MappedStageRow[]>(() => {
if (!billing) return [];
if (!runUsage) return [];
if (!hasInFlight) return completedRows;
return billing.stages.map((stage, idx) =>
return runUsage.stages.map((stage, idx) =>
isInFlight(stage)
? mapStageRow(stage, liveWallTimeMs(stage, now))
: completedRows[idx],
);
}, [billing, completedRows, hasInFlight, now]);
}, [runUsage, completedRows, hasInFlight, now]);
// While ticking, sum the displayed row runtimes so the footer updates in
// lock-step. Otherwise trust the server's authoritative total.
const totalWallTimeMs = hasInFlight
? rows.reduce((sum, row) => sum + row.wallTimeMs, 0)
: (billing?.totals.timing.wall_time_ms ?? 0);
: (runUsage?.totals.timing.wall_time_ms ?? 0);
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
const totalBilling = hasLlmStages && billing ? billing.totals : null;
const totalUsdMicros = billing?.totals.total_usd_micros;
const hasLlmStages = (runUsage?.by_model.length ?? 0) > 0;
const totalUsage = hasLlmStages && runUsage ? runUsage.totals.usage : null;
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
const visibleRows = rows.filter(isVisibleRow);
@ -201,7 +218,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
</tr>
</thead>
<tbody>
@ -212,13 +229,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 billing={row.billing} />
<TokensCell usage={row.usage} />
</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.billing?.total_usd_micros)}
<CostCell usage={row.usage} />
</td>
</tr>
))}
@ -228,13 +245,13 @@ 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 billing={totalBilling} />
<TokensCell usage={totalUsage} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatDurationMs(totalWallTimeMs)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicrosOrDash(totalUsdMicros)}
<CostCell usage={totalUsage} />
</td>
</tr>
</tfoot>
@ -251,7 +268,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
</tr>
</thead>
<tbody>
@ -262,10 +279,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 billing={row.billing} />
<TokensCell usage={row.usage} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicrosOrDash(row.billing.total_usd_micros)}
<CostCell usage={row.usage} />
</td>
</tr>
))}
@ -277,10 +294,10 @@ 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 billing={totalBilling} />
<TokensCell usage={totalUsage} />
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicrosOrDash(totalUsdMicros)}
<CostCell usage={totalUsage} />
</td>
</tr>
</tfoot>

View file

@ -5,7 +5,7 @@ import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client";
import { ToastProvider } from "../components/toast";
import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
import { setupReactTestEnv } from "../lib/test-utils";
class MemoryStorage {
@ -57,7 +57,7 @@ function run(id: string, repo = "qlty/fabro", workflow = "release"): Run {
last_event_at: "2026-04-19T12:04:00Z",
completed_at: "2026-04-19T12:05:00Z",
},
billing: null,
usage: makeUsage(),
size: "XS",
diff: null,
pull_request: null,

View file

@ -13,7 +13,7 @@ import {
} from "./runs";
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
import { mapRunListItem } from "../data/runs";
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
const status =
@ -58,7 +58,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
last_event_at: null,
completed_at: null,
},
billing: null,
usage: makeUsage(),
size: "XS",
diff: null,
pull_request: null,