refactor: simplify stage billing reuse

This commit is contained in:
Release Repro 2026-07-28 14:51:47 -04:00
parent 7841a77f2c
commit acff084afd
No known key found for this signature in database
15 changed files with 228 additions and 341 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,7 +18,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage {
duration: "--",
startedAt: null,
providerUsed: null,
billing: null,
billing: makeBilledTokenCounts(),
};
}
@ -39,7 +40,7 @@ describe("mapRunStagesToSidebarStages", () => {
model: "gpt-5.5",
reasoning_effort: "high",
},
billing: {
billing: makeBilledTokenCounts({
input_tokens: 28_640,
output_tokens: 7_550,
total_tokens: 43_690,
@ -47,7 +48,7 @@ describe("mapRunStagesToSidebarStages", () => {
cache_read_tokens: 4_800,
cache_write_tokens: 1_500,
total_usd_micros: 720_000,
},
}),
},
{
id: "apply-changes@2",
@ -56,14 +57,7 @@ describe("mapRunStagesToSidebarStages", () => {
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,
},
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -84,8 +78,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");
@ -105,6 +99,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "start",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "verify@1",
@ -113,6 +108,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "exit@1",
@ -121,6 +117,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "exit",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -140,6 +137,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "running",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -159,6 +157,7 @@ describe("mapRunStagesToSidebarStages", () => {
node_id: "work",
visit: 1,
graph_visit: 1,
billing: makeBilledTokenCounts(),
},
{
id: "work@2",
@ -169,6 +168,7 @@ describe("mapRunStagesToSidebarStages", () => {
visit: 2,
graph_visit: 1,
resumed_from_stage_id: "work@1",
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -194,6 +194,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "succeeded",
node_id: "verify",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
meta: { has_more: false },
@ -214,6 +215,7 @@ describe("mapRunStagesToSidebarStages", () => {
status: "pending",
node_id: "approval",
visit: 1,
billing: makeBilledTokenCounts(),
},
],
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 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

@ -7,6 +7,7 @@ import type {
StageModelUsage,
} from "@qltysh/fabro-api-client";
import { makeBilledTokenCounts } 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({
makeBilledTokenCounts({
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(makeBilledTokenCounts());
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 }),
makeBilledTokenCounts({
input_tokens: 1_000,
output_tokens: 500,
total_tokens: 1_500,
}),
);
expect(html).toContain("Uncached");
@ -148,10 +141,13 @@ 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 provider-reported cost when token counts are unavailable", () => {
const html = popoverMarkup(
makeBilledTokenCounts({ total_usd_micros: 720_000 }),
);
expect(html).toContain("kimi-k3");
expect(html).not.toContain("Tokens");
expect(html).toContain("Cost");
expect(html).toContain("$0.72");
});
});

View file

@ -69,6 +69,7 @@ import {
formatTokenCount,
formatUsdMicros,
} from "../lib/format";
import { billingTokenBuckets, hasBillingUsage } from "../lib/billing";
import { plural } from "../lib/plural";
import {
useRun,
@ -870,27 +871,10 @@ 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;
if (!hasBillingUsage(billing)) return null;
const buckets = billingTokenBuckets(billing);
const cost = formatUsdMicros(billing.total_usd_micros);
return (
<div className="mt-3">
@ -920,7 +904,7 @@ export function ModelUsagePopover({
billing,
}: {
providerUsed: StageModelUsage;
billing: BilledTokenCounts | null;
billing: BilledTokenCounts;
}) {
return (
<>
@ -943,7 +927,7 @@ export function ModelUsagePopover({
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
)}
</PopoverRows>
{billing && <StageBillingRows billing={billing} />}
<StageBillingRows billing={billing} />
</>
);
}
@ -1977,7 +1961,7 @@ function EventsToolbar({
filteredCount: number;
totalCount: number;
providerUsed: StageModelUsage | null;
billing: BilledTokenCounts | null;
billing: BilledTokenCounts;
events: EventEnvelope[];
runId: string;
stageId: string;

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();
@ -5654,65 +5712,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));
@ -5746,7 +5748,6 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
@ -5793,72 +5794,16 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() {
/// 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`.
/// 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_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;
create_billed_retry_run(&state, run_id).await;
let response = app
.clone()
.oneshot(
Request::builder()
.method("GET")
@ -5895,23 +5840,8 @@ async fn list_run_stages_reports_zero_billing_for_a_stage_that_called_no_model()
workflow_event::Event::RunRunning,
])
.await;
append_scoped_stage_event(
&state,
run_id,
"script",
1,
&workflow_event::Event::StageStarted {
graph_visit: None,
resumed_from_stage_id: None,
node_id: "script".to_string(),
name: "Script".to_string(),
index: 0,
handler_type: "command".to_string(),
attempt: 1,
max_attempts: 1,
},
)
.await;
let started = stage_started_event("script", "command");
append_scoped_stage_event(&state, run_id, "script", 1, &started).await;
let response = app
.oneshot(

View file

@ -545,10 +545,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
/// model, which leaves `total_usd_micros` as `None` rather than zero.
/// 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() {
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 +1053,17 @@ mod iter_stages_tests {
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
);
}
}