From acff084afde69eed44a94d445d84ee2699452447 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Tue, 28 Jul 2026 14:51:47 -0400 Subject: [PATCH] refactor: simplify stage billing reuse --- .../app/components/stage-popover.test.tsx | 2 + .../stage-renderers/fan-in-results.test.tsx | 2 + .../parallel-children.test.tsx | 2 + .../app/components/stage-sidebar.test.tsx | 2 + apps/fabro-web/app/lib/billing.ts | 28 +++ apps/fabro-web/app/lib/stage-sidebar.test.ts | 28 +-- apps/fabro-web/app/lib/stage-sidebar.ts | 4 +- apps/fabro-web/app/lib/test-fixtures.ts | 16 +- .../fabro-web/app/routes/run-billing.test.tsx | 40 ++-- apps/fabro-web/app/routes/run-billing.tsx | 165 ++++----------- .../app/routes/run-stages-chat.test.tsx | 2 + .../app/routes/run-stages-details.test.tsx | 34 ++- apps/fabro-web/app/routes/run-stages.tsx | 28 +-- lib/apps/fabro-server/src/server/tests.rs | 198 ++++++------------ .../fabro-types/src/run_projection.rs | 18 +- 15 files changed, 228 insertions(+), 341 deletions(-) create mode 100644 apps/fabro-web/app/lib/billing.ts diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 4a8bbb12c..3f3c9fe92 100644 --- a/apps/fabro-web/app/components/stage-popover.test.tsx +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -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 { return { @@ -34,6 +35,7 @@ function makeStage(overrides: Partial = {}): Stage { duration: "1m 30s", startedAt: "2026-05-24T11:58:30Z", providerUsed: { mode: "policy", model: "claude-opus-4-7", reasoning_effort: "high" }, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx index 26115eaee..9c48b1960 100644 --- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx @@ -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 { diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx index f1313b780..e44953db5 100644 --- a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx @@ -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 { diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx index 69cf0c097..663734196 100644 --- a/apps/fabro-web/app/components/stage-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.test.tsx @@ -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 { @@ -17,6 +18,7 @@ function makeStage(overrides: Partial = {}): Stage { duration: "--", startedAt: null, providerUsed: null, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/lib/billing.ts b/apps/fabro-web/app/lib/billing.ts new file mode 100644 index 000000000..8d7e2cbf7 --- /dev/null +++ b/apps/fabro-web/app/lib/billing.ts @@ -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) + ); +} diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index e1cc9092b..53cfa1b2a 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -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 }, diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index 191681459..5aefb1166 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -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 = 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; diff --git a/apps/fabro-web/app/lib/test-fixtures.ts b/apps/fabro-web/app/lib/test-fixtures.ts index c625ebe7d..521e46e14 100644 --- a/apps/fabro-web/app/lib/test-fixtures.ts +++ b/apps/fabro-web/app/lib/test-fixtures.ts @@ -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 { + return { + cache_read_tokens: 0, + cache_write_tokens: 0, + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + total_tokens: 0, + ...overrides, + }; +} diff --git a/apps/fabro-web/app/routes/run-billing.test.tsx b/apps/fabro-web/app/routes/run-billing.test.tsx index 99f6ece5d..efb86e73d 100644 --- a/apps/fabro-web/app/routes/run-billing.test.tsx +++ b/apps/fabro-web/app/routes/run-billing.test.tsx @@ -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 { - 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 { 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; } }); -}); \ No newline at end of file +}); diff --git a/apps/fabro-web/app/routes/run-billing.tsx b/apps/fabro-web/app/routes/run-billing.tsx index 20b60e775..1973ffb10 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -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 (
-
+
Tokens in / out
- {rows.map((row) => ( - -
{row.label}
-
- {formatTokens(row.value)} + {buckets.map((bucket) => ( + +
{bucket.label}
+
+ {formatTokens(bucket.value)}
))} @@ -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)} /{" "} - {formatTokens(outputTokens)} + {formatTokens(billing?.input_tokens)} /{" "} + {formatTokens(billing ? billableOutputTokens(billing) : null)} ); - if ( - inputTokens == null || - outputTokens == null || - cacheReadTokens == null || - cacheWriteTokens == null - ) { - return display; - } + if (!billing) return display; return ( - - } - > + }> {display} ); @@ -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} - + {formatDurationMs(row.wallTimeMs)} - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing?.total_usd_micros)} ))} @@ -289,12 +225,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { Total All models - + {formatDurationMs(totalWallTimeMs)} @@ -328,15 +259,10 @@ export default function RunBilling({ params }: { params: { id: string } }) { {row.stages} - + - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing.total_usd_micros)} ))} @@ -348,12 +274,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { {modelStageCount} - + {formatUsdMicrosOrDash(totalUsdMicros)} diff --git a/apps/fabro-web/app/routes/run-stages-chat.test.tsx b/apps/fabro-web/app/routes/run-stages-chat.test.tsx index c4307ebbc..1142ae6b8 100644 --- a/apps/fabro-web/app/routes/run-stages-chat.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-chat.test.tsx @@ -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 { @@ -18,6 +19,7 @@ function stage(overrides: Partial = {}): Stage { resumedFromStageId: null, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: makeBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/routes/run-stages-details.test.tsx b/apps/fabro-web/app/routes/run-stages-details.test.tsx index 2f453c63e..4483ec4e9 100644 --- a/apps/fabro-web/app/routes/run-stages-details.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-details.test.tsx @@ -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 { - 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( , ); @@ -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"); }); }); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 246f3b2ed..0f03c6a98 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -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 (
@@ -920,7 +904,7 @@ export function ModelUsagePopover({ billing, }: { providerUsed: StageModelUsage; - billing: BilledTokenCounts | null; + billing: BilledTokenCounts; }) { return ( <> @@ -943,7 +927,7 @@ export function ModelUsagePopover({ {providerUsed.speed} )} - {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; diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 69f6fabc3..b3d57cfa2 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -5259,6 +5259,64 @@ fn test_billed_usage( .unwrap() } +async fn create_billed_retry_run(state: &Arc, 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> = 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( diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index 987edf03d..6acdbbff0 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -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 + ); + } }