diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 4a8bbb12c..a27db5aa7 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 { testBilledTokenCounts } 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: testBilledTokenCounts(), ...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..f7a3e4057 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 @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; +import { testBilledTokenCounts } from "../../lib/test-fixtures"; import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; import type { Stage } from "../stage-sidebar"; import { FanInResults } from "./fan-in-results"; @@ -20,8 +21,11 @@ const fanInStage: Stage = { duration: "1s", nodeId: "join", visit: 1, + graphVisit: null, + resumedFromStageId: null, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: testBilledTokenCounts(), }; function event(seq: number, partial: Partial): EventEnvelope { 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..c3f2d81c3 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 @@ -3,6 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { MemoryRouter } from "react-router"; +import { testBilledTokenCounts } from "../../lib/test-fixtures"; import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; import type { Stage } from "../stage-sidebar"; import { ParallelChildren } from "./parallel-children"; @@ -21,8 +22,11 @@ const parallelStage: Stage = { duration: "12s", nodeId: "fork", visit: 1, + graphVisit: null, + resumedFromStageId: null, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, + billing: testBilledTokenCounts(), }; function event(partial: Partial): EventEnvelope { diff --git a/apps/fabro-web/app/components/stage-sidebar.test.tsx b/apps/fabro-web/app/components/stage-sidebar.test.tsx index 69cf0c097..171059826 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 { testBilledTokenCounts } 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: testBilledTokenCounts(), ...overrides, }; } diff --git a/apps/fabro-web/app/lib/billing.test.ts b/apps/fabro-web/app/lib/billing.test.ts new file mode 100644 index 000000000..0764a0f75 --- /dev/null +++ b/apps/fabro-web/app/lib/billing.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import { + billingTokenBuckets, + formatBillingTokenCount, + hasBillingUsage, +} from "./billing"; +import { testBilledTokenCounts } from "./test-fixtures"; + +describe("billingTokenBuckets", () => { + test("returns the shared display order and folds reasoning into output", () => { + expect( + billingTokenBuckets( + testBilledTokenCounts({ + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 5, + cache_read_tokens: 30, + cache_write_tokens: 40, + }), + ), + ).toEqual([ + { label: "Cache read", value: 30 }, + { label: "Cache creation", value: 40 }, + { label: "Uncached", value: 10 }, + { label: "Output", value: 25 }, + ]); + }); +}); + +describe("hasBillingUsage", () => { + test("includes cache-only and cost-only usage", () => { + expect(hasBillingUsage(testBilledTokenCounts())).toBe(false); + expect( + hasBillingUsage(testBilledTokenCounts({ cache_read_tokens: 1 })), + ).toBe(true); + expect( + hasBillingUsage(testBilledTokenCounts({ total_usd_micros: 1 })), + ).toBe(true); + }); +}); + +describe("formatBillingTokenCount", () => { + test("formats zero without a fractional suffix", () => { + expect(formatBillingTokenCount(0)).toBe("0"); + expect(formatBillingTokenCount(1_200)).toBe("1.2k"); + }); +}); diff --git a/apps/fabro-web/app/lib/billing.ts b/apps/fabro-web/app/lib/billing.ts new file mode 100644 index 000000000..f11ecde54 --- /dev/null +++ b/apps/fabro-web/app/lib/billing.ts @@ -0,0 +1,39 @@ +import type { BilledTokenCounts } from "@qltysh/fabro-api-client"; + +import { formatTokenCount } from "./format"; + +export interface BillingTokenBucket { + label: "Cache read" | "Cache creation" | "Uncached" | "Output"; + value: number; +} + +/** + * Return the disjoint billing buckets in their shared display order. + * Reasoning tokens are billed as output tokens. + */ +export function billingTokenBuckets(billing: BilledTokenCounts): BillingTokenBucket[] { + return [ + { label: "Cache read", value: billing.cache_read_tokens }, + { label: "Cache creation", value: billing.cache_write_tokens }, + { label: "Uncached", value: billing.input_tokens }, + { + label: "Output", + value: billing.output_tokens + billing.reasoning_tokens, + }, + ]; +} + +export function hasBillingUsage(billing: BilledTokenCounts): boolean { + return ( + billing.input_tokens !== 0 || + billing.output_tokens !== 0 || + billing.reasoning_tokens !== 0 || + billing.cache_read_tokens !== 0 || + billing.cache_write_tokens !== 0 || + (billing.total_usd_micros ?? 0) !== 0 + ); +} + +export function formatBillingTokenCount(value: number): string { + return value === 0 ? "0" : formatTokenCount(value, { compactDecimal: true }); +} diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index e1cc9092b..e6206634a 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -1,8 +1,23 @@ import { describe, expect, test } from "bun:test"; -import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fabro-api-client"; +import type { + PaginatedRunStageList, + RunStage, + StageHandler, + StageState, +} from "@qltysh/fabro-api-client"; import type { Stage } from "../components/stage-sidebar"; import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar"; +import { testBilledTokenCounts } from "./test-fixtures"; + +function runStage( + stage: Omit & Partial>, +): RunStage { + return { + billing: testBilledTokenCounts(), + ...stage, + }; +} function makeStage(nodeId: string, visit: number, status: StageState): Stage { return { @@ -17,7 +32,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage { duration: "--", startedAt: null, providerUsed: null, - billing: null, + billing: testBilledTokenCounts(), }; } @@ -25,7 +40,7 @@ describe("mapRunStagesToSidebarStages", () => { test("maps two visits of the same node to distinct sidebar entries", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "apply-changes@1", name: "Apply Changes", handler: "command", @@ -39,7 +54,7 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }, - billing: { + billing: testBilledTokenCounts({ input_tokens: 28_640, output_tokens: 7_550, total_tokens: 43_690, @@ -47,24 +62,16 @@ describe("mapRunStagesToSidebarStages", () => { cache_read_tokens: 4_800, cache_write_tokens: 1_500, total_usd_micros: 720_000, - }, - }, - { + }), + }), + runStage({ id: "apply-changes@2", name: "Apply Changes", handler: "agent", status: "running", node_id: "apply", visit: 2, - billing: { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0, - reasoning_tokens: 0, - cache_read_tokens: 0, - cache_write_tokens: 0, - }, - }, + }), ], meta: { has_more: false }, }; @@ -84,8 +91,8 @@ describe("mapRunStagesToSidebarStages", () => { }); // Each visit keeps its own tokens and cost, so the stage popover never // shows a sibling visit's usage. - expect(result[0].billing?.total_usd_micros).toBe(720_000); - expect(result[1].billing?.total_usd_micros).toBeUndefined(); + expect(result[0].billing.total_usd_micros).toBe(720_000); + expect(result[1].billing.total_usd_micros).toBeUndefined(); expect(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); @@ -98,30 +105,30 @@ describe("mapRunStagesToSidebarStages", () => { test("filters by node_id (suffixed start@1 / exit@1 are still hidden)", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "start@1", name: "start", handler: "start", status: "succeeded", node_id: "start", visit: 1, - }, - { + }), + runStage({ id: "verify@1", name: "verify", handler: "human", status: "succeeded", node_id: "verify", visit: 1, - }, - { + }), + runStage({ id: "exit@1", name: "exit", handler: "exit", status: "succeeded", node_id: "exit", visit: 1, - }, + }), ], meta: { has_more: false }, }; @@ -133,14 +140,14 @@ describe("mapRunStagesToSidebarStages", () => { test("missing duration renders as '--'", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "verify@1", name: "verify", handler: "wait", status: "running", node_id: "verify", visit: 1, - }, + }), ], meta: { has_more: false }, }; @@ -151,7 +158,7 @@ describe("mapRunStagesToSidebarStages", () => { test("maps a resumed execution's identity fields and keeps both entries in order", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "work@1", name: "work", handler: "agent", @@ -159,8 +166,8 @@ describe("mapRunStagesToSidebarStages", () => { node_id: "work", visit: 1, graph_visit: 1, - }, - { + }), + runStage({ id: "work@2", name: "work", handler: "agent", @@ -169,7 +176,7 @@ describe("mapRunStagesToSidebarStages", () => { visit: 2, graph_visit: 1, resumed_from_stage_id: "work@1", - }, + }), ], meta: { has_more: false }, }; @@ -187,14 +194,14 @@ describe("mapRunStagesToSidebarStages", () => { test("omits identity fields for stages recorded before execution tracking", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "verify@1", name: "verify", handler: "agent", status: "succeeded", node_id: "verify", visit: 1, - }, + }), ], meta: { has_more: false }, }; @@ -207,14 +214,14 @@ describe("mapRunStagesToSidebarStages", () => { test("preserves the authoritative handler for renderer dispatch", () => { const stages: PaginatedRunStageList = { data: [ - { + runStage({ id: "approval@1", name: "approval", handler: "human" satisfies StageHandler, status: "pending", node_id: "approval", visit: 1, - }, + }), ], meta: { has_more: false }, }; 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..bf131a6fb 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 testBilledTokenCounts( + overrides: Partial = {}, +): BilledTokenCounts { + return { + input_tokens: 0, + output_tokens: 0, + total_tokens: 0, + reasoning_tokens: 0, + cache_read_tokens: 0, + cache_write_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..1d7531503 100644 --- a/apps/fabro-web/app/routes/run-billing.test.tsx +++ b/apps/fabro-web/app/routes/run-billing.test.tsx @@ -1,11 +1,9 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import TestRenderer from "react-test-renderer"; -import type { - BilledTokenCounts, - RunBilling, - StageTiming, -} from "@qltysh/fabro-api-client"; +import type { RunBilling, StageTiming } from "@qltysh/fabro-api-client"; + +import { testBilledTokenCounts } from "../lib/test-fixtures"; function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming { return { @@ -24,25 +22,12 @@ mock.module("../lib/queries", () => ({ const { default: RunBillingRoute } = await import("./run-billing"); -function zeroBilling(overrides: Partial = {}): BilledTokenCounts { - 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(), + ...testBilledTokenCounts(), }, by_model: [], ...overrides, @@ -86,21 +71,21 @@ describe("RunBilling", () => { { stage: { id: "start", name: "start" }, model: null, - billing: zeroBilling(), + billing: testBilledTokenCounts(), timing: stageTiming(), state: "succeeded", }, { stage: { id: "command", name: "command" }, model: null, - billing: zeroBilling(), + billing: testBilledTokenCounts(), timing: stageTiming(61000), state: "succeeded", }, ], totals: { timing: stageTiming(61000), - ...zeroBilling(), + ...testBilledTokenCounts(), }, }), ); @@ -121,7 +106,7 @@ describe("RunBilling", () => { { stage: { id: "start", name: "start" }, model: null, - billing: zeroBilling(), + billing: testBilledTokenCounts(), timing: stageTiming(), state: "succeeded", }, @@ -131,7 +116,7 @@ describe("RunBilling", () => { provider: "anthropic", model_id: "claude-sonnet-4-5", }, - billing: zeroBilling({ + billing: testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -143,7 +128,7 @@ describe("RunBilling", () => { ], totals: { timing: stageTiming(42000), - ...zeroBilling({ + ...testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -157,7 +142,7 @@ describe("RunBilling", () => { model_id: "claude-sonnet-4-5", }, stages: 1, - billing: zeroBilling({ + billing: testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -204,7 +189,7 @@ describe("RunBilling", () => { model_id: "claude-opus-4-6", speed: "fast", }, - billing: zeroBilling({ + billing: testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -217,7 +202,7 @@ describe("RunBilling", () => { ], totals: { timing: stageTiming(), - ...zeroBilling({ + ...testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -232,7 +217,7 @@ describe("RunBilling", () => { speed: "fast", }, stages: 1, - billing: zeroBilling({ + billing: testBilledTokenCounts({ input_tokens: 1200, output_tokens: 300, total_tokens: 1500, @@ -268,4 +253,4 @@ describe("RunBilling", () => { Date.now = originalNow; } }); -}); \ 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..68ea7be04 100644 --- a/apps/fabro-web/app/routes/run-billing.tsx +++ b/apps/fabro-web/app/routes/run-billing.tsx @@ -3,14 +3,16 @@ import { Fragment, useMemo } from "react"; import { EmptyState } from "../components/state"; import { Tooltip } from "../components/ui"; import { - formatDurationMs, - formatTokenCount, - formatUsdMicros, -} from "../lib/format"; + billingTokenBuckets, + formatBillingTokenCount, + hasBillingUsage, +} from "../lib/billing"; +import { formatDurationMs, formatUsdMicros } from "../lib/format"; import { useRunBilling } from "../lib/queries"; import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar"; import { useTickingNow } from "../lib/time"; import type { + BilledTokenCounts, BillingModelRef, RunBilling, RunBillingStage, @@ -20,7 +22,7 @@ const EMPTY_VALUE = "—"; function formatTokens(n: number | null | undefined) { if (n == null) return EMPTY_VALUE; - return formatTokenCount(n, { compactDecimal: true }); + return formatBillingTokenCount(n); } function formatUsdMicrosOrDash(usdMicros?: number | null): string { @@ -38,24 +40,15 @@ function isInFlight(stage: RunBillingStage): boolean { } function isVisibleRow(row: MappedStageRow): boolean { - if (row.inFlight) return true; - return ( - (row.inputTokens ?? 0) > 0 || - (row.outputTokens ?? 0) > 0 || - (row.totalUsdMicros ?? 0) > 0 - ); + return row.inFlight || hasBillingUsage(row.billing); } interface MappedStageRow { - stage: string; - model: string | null; - inputTokens: number | null; - outputTokens: number | null; - cacheReadTokens: number | null; - cacheWriteTokens: number | null; - wallTimeMs: number; - totalUsdMicros: number | null | undefined; - inFlight: boolean; + stage: string; + model: string | null; + billing: BilledTokenCounts; + wallTimeMs: number; + inFlight: boolean; } function liveWallTimeMs(stage: RunBillingStage, now: number): number { @@ -71,40 +64,18 @@ function liveWallTimeMs(stage: RunBillingStage, now: number): number { export const handle = { wide: true }; function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow { - const hasModel = stage.model != null; return { - stage: stage.stage.name, - model: formatModelRef(stage.model), - inputTokens: hasModel ? stage.billing.input_tokens : null, - outputTokens: hasModel - ? stage.billing.output_tokens + stage.billing.reasoning_tokens - : null, - cacheReadTokens: hasModel ? stage.billing.cache_read_tokens : null, - cacheWriteTokens: hasModel ? stage.billing.cache_write_tokens : null, + stage: stage.stage.name, + model: formatModelRef(stage.model), + billing: stage.billing, wallTimeMs, - totalUsdMicros: stage.billing.total_usd_micros, - inFlight: isInFlight(stage), + inFlight: isInFlight(stage), }; } /** Hover breakdown of the disjoint token buckets behind an `in / out` count. */ -function TokenBreakdown({ - cacheReadTokens, - cacheWriteTokens, - inputTokens, - outputTokens, -}: { - cacheReadTokens: number; - cacheWriteTokens: number; - inputTokens: number; - outputTokens: number; -}) { - const rows = [ - { label: "Cache read", value: cacheReadTokens }, - { label: "Cache creation", value: cacheWriteTokens }, - { label: "Uncached", value: inputTokens }, - { label: "Output", value: outputTokens }, - ]; +function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) { + const rows = billingTokenBuckets(billing); return (
@@ -129,41 +100,22 @@ function TokenBreakdown({ * hovering the count reveals the cache breakdown. */ function TokensCell({ - inputTokens, - outputTokens, - cacheReadTokens, - cacheWriteTokens, + billing, }: { - inputTokens: number | null; - outputTokens: number | null; - cacheReadTokens: number | null; - cacheWriteTokens: number | null; + billing: BilledTokenCounts | null; }) { + const inputTokens = billing?.input_tokens; + const outputTokens = + billing == null ? null : billing.output_tokens + billing.reasoning_tokens; const display = ( <> {formatTokens(inputTokens)} /{" "} {formatTokens(outputTokens)} ); - if ( - inputTokens == null || - outputTokens == null || - cacheReadTokens == null || - cacheWriteTokens == null - ) { - return display; - } + if (billing == null) return display; return ( - - } - > + }> {display} ); @@ -189,15 +141,15 @@ export default function RunBilling({ params }: { params: { id: string } }) { if (!billing) return []; return billing.by_model .map((entry) => ({ - model: formatModelRef(entry.model) ?? EMPTY_VALUE, - stages: entry.stages, - inputTokens: entry.billing.input_tokens, - outputTokens: entry.billing.output_tokens + entry.billing.reasoning_tokens, - cacheReadTokens: entry.billing.cache_read_tokens, - cacheWriteTokens: entry.billing.cache_write_tokens, - totalUsdMicros: entry.billing.total_usd_micros, + model: formatModelRef(entry.model) ?? EMPTY_VALUE, + stages: entry.stages, + billing: entry.billing, })) - .sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1)); + .sort( + (a, b) => + (b.billing.total_usd_micros ?? -1) - + (a.billing.total_usd_micros ?? -1), + ); }, [billing]); // Re-derive only the in-flight rows on each tick; everything else stays put. @@ -218,16 +170,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { : (billing?.totals.timing.wall_time_ms ?? 0); const hasLlmStages = (billing?.by_model.length ?? 0) > 0; - const totalInput = hasLlmStages ? (billing?.totals.input_tokens ?? null) : null; - const totalOutput = hasLlmStages && billing - ? billing.totals.output_tokens + billing.totals.reasoning_tokens - : null; - const totalCacheRead = hasLlmStages - ? (billing?.totals.cache_read_tokens ?? null) - : null; - const totalCacheWrite = hasLlmStages - ? (billing?.totals.cache_write_tokens ?? null) - : null; + const totalBilling = hasLlmStages && billing ? billing.totals : null; const totalUsdMicros = billing?.totals.total_usd_micros; const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0); const visibleRows = rows.filter(isVisibleRow); @@ -268,18 +211,13 @@ export default function RunBilling({ params }: { params: { id: string } }) { {row.model ?? EMPTY_VALUE} - + {formatDurationMs(row.wallTimeMs)} - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing.total_usd_micros)} ))} @@ -289,12 +227,7 @@ export default function RunBilling({ params }: { params: { id: string } }) { Total All models - + {formatDurationMs(totalWallTimeMs)} @@ -328,15 +261,10 @@ export default function RunBilling({ params }: { params: { id: string } }) { {row.stages} - + - {formatUsdMicrosOrDash(row.totalUsdMicros)} + {formatUsdMicrosOrDash(row.billing.total_usd_micros)} ))} @@ -348,12 +276,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..b56261269 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 { testBilledTokenCounts } 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: testBilledTokenCounts(), ...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..ee0931833 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 { testBilledTokenCounts } from "../lib/test-fixtures"; import { EventDetails, ModelUsagePopover } from "./run-stages"; const RUN_START = "2026-04-09T12:00:00Z"; @@ -84,19 +85,7 @@ const PROVIDER_USED: StageModelUsage = { reasoning_effort: "max", }; -function billing(partial: Partial): BilledTokenCounts { - 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({ + testBilledTokenCounts({ input_tokens: 28_640, output_tokens: 7_550, reasoning_tokens: 1_200, @@ -131,7 +120,7 @@ describe("ModelUsagePopover billing", () => { }); test("omits the token section for a stage that called no model", () => { - const html = popoverMarkup(billing({})); + const html = popoverMarkup(testBilledTokenCounts()); expect(html).toContain("kimi-k3"); expect(html).not.toContain("Tokens"); @@ -140,7 +129,11 @@ describe("ModelUsagePopover billing", () => { test("still shows tokens when nothing priced the stage", () => { const html = popoverMarkup( - billing({ input_tokens: 1_000, output_tokens: 500, total_tokens: 1_500 }), + testBilledTokenCounts({ + input_tokens: 1_000, + output_tokens: 500, + total_tokens: 1_500, + }), ); expect(html).toContain("Uncached"); @@ -148,10 +141,14 @@ describe("ModelUsagePopover billing", () => { expect(html).not.toContain("Cost"); }); - test("renders the model rows alone when the stage list carried no billing", () => { - const html = popoverMarkup(null); + test("shows a reported cost when token buckets are empty", () => { + const html = popoverMarkup( + testBilledTokenCounts({ total_usd_micros: 1_000_000 }), + ); expect(html).toContain("kimi-k3"); - expect(html).not.toContain("Tokens"); + expect(html).toContain("Tokens"); + expect(html).toContain("Cost"); + expect(html).toContain("$1.00"); }); }); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 246f3b2ed..76f11b841 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -62,6 +62,11 @@ import { } from "../components/stage-renderers/primitives"; import { StageSummary } from "../components/stage-renderers/stage-summary"; import { WaitStatus } from "../components/stage-renderers/wait-status"; +import { + billingTokenBuckets, + formatBillingTokenCount, + hasBillingUsage, +} from "../lib/billing"; import { formatAbsoluteTs, formatBytes, @@ -870,38 +875,25 @@ export function formatStageModelUsageLabel( const POPOVER_NUMBER = "block text-right font-mono tabular-nums"; -/** - * The disjoint token buckets behind a stage's usage, labelled and ordered to - * match the Billing tab's breakdown so the two views read the same. `Uncached` - * is input that missed the cache; `Output` folds in reasoning tokens. - */ -function stageTokenBuckets(billing: BilledTokenCounts) { - return [ - { label: "Cache read", value: billing.cache_read_tokens }, - { label: "Cache creation", value: billing.cache_write_tokens }, - { label: "Uncached", value: billing.input_tokens }, - { - label: "Output", - value: billing.output_tokens + billing.reasoning_tokens, - }, - ]; -} - /** Tokens and cost for this stage visit alone. */ -function StageBillingRows({ billing }: { billing: BilledTokenCounts }) { - const buckets = stageTokenBuckets(billing); - if (buckets.every((bucket) => bucket.value === 0)) return null; +function StageBillingRows({ + billing, + className, +}: { + billing: BilledTokenCounts; + className?: string; +}) { + if (!hasBillingUsage(billing)) return null; + const buckets = billingTokenBuckets(billing); const cost = formatUsdMicros(billing.total_usd_micros); return ( -
+
Tokens {buckets.map((bucket) => ( - {bucket.value === 0 - ? "0" - : formatTokenCount(bucket.value, { compactDecimal: true })} + {formatBillingTokenCount(bucket.value)} ))} @@ -920,7 +912,7 @@ export function ModelUsagePopover({ billing, }: { providerUsed: StageModelUsage; - billing: BilledTokenCounts | null; + billing: BilledTokenCounts; }) { return ( <> @@ -943,7 +935,7 @@ export function ModelUsagePopover({ {providerUsed.speed} )} - {billing && } + ); } @@ -1977,7 +1969,7 @@ function EventsToolbar({ filteredCount: number; totalCount: number; providerUsed: StageModelUsage | null; - billing: BilledTokenCounts | null; + billing: BilledTokenCounts; events: EventEnvelope[]; runId: string; stageId: string; diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index 69f6fabc3..a6a8c5ff1 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -5650,7 +5650,7 @@ async fn run_billing_dedups_retried_nodes_and_sums_their_durations() { } #[tokio::test] -async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() { +async fn billing_endpoints_report_retry_usage_per_node_and_per_visit() { let state = test_app_state_with_isolated_storage(); let app = crate::test_support::build_test_router(Arc::clone(&state)); let run_id = RunId::new(); @@ -5789,76 +5789,8 @@ async fn run_billing_sums_usage_across_retry_visits_and_uses_latest_model() { assert_eq!(old_model["billing"]["input_tokens"], 100); assert_eq!(new_model["stages"], 1); assert_eq!(new_model["billing"]["input_tokens"], 200); -} - -/// The stage popover reads `billing` off the stages list, so it must be scoped -/// to one visit — unlike the Billing tab's rows, which sum every visit of a -/// node. This exercises the same two-visit history as -/// `run_billing_sums_usage_across_retry_visits_and_uses_latest_model`. -#[tokio::test] -async fn list_run_stages_reports_billing_per_visit() { - let state = test_app_state_with_isolated_storage(); - let app = crate::test_support::build_test_router(Arc::clone(&state)); - let run_id = RunId::new(); - - create_durable_run_with_events(&state, run_id, &[ - workflow_event::Event::RunSubmitted { - definition_blob: None, - }, - workflow_event::Event::RunStarting, - workflow_event::Event::RunRunning, - ]) - .await; - - append_scoped_stage_event( - &state, - run_id, - "verify", - 1, - &workflow_event::Event::StageFailed { - node_id: "verify".to_string(), - name: "Verify".to_string(), - index: 1, - failure: FailureDetail::new("try again", FailureCategory::TransientInfra), - will_retry: true, - timing: fabro_types::StageTiming::wall_only(1200), - billing: Some(test_billed_usage("gpt-old", 100, 10)), - actor: None, - }, - ) - .await; - append_scoped_stage_event( - &state, - run_id, - "verify", - 2, - &workflow_event::Event::StageCompleted { - node_id: "verify".to_string(), - name: "Verify".to_string(), - index: 1, - timing: fabro_types::StageTiming::wall_only(800), - status: "succeeded".to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - billing: Some(test_billed_usage("gpt-new", 200, 20)), - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures: None, - restart_failure_signatures: None, - response: None, - attempt: 2, - max_attempts: 2, - }, - ) - .await; let response = app - .clone() .oneshot( Request::builder() .method("GET") diff --git a/lib/foundation/fabro-types/src/run_projection.rs b/lib/foundation/fabro-types/src/run_projection.rs index 987edf03d..4e7d27af8 100644 --- a/lib/foundation/fabro-types/src/run_projection.rs +++ b/lib/foundation/fabro-types/src/run_projection.rs @@ -544,11 +544,11 @@ impl StageProjection { /// /// A provider-reported cost always wins. Otherwise the catalog prices the /// recorded tokens for the stage's model. The stored counts pass through - /// untouched when there is no catalog, no model, or no price for that + /// untouched when there is no usage, catalog, model, or price for that /// model, which leaves `total_usd_micros` as `None` rather than zero. #[must_use] pub fn billed_usage(&self, catalog: Option<&Catalog>) -> Cow<'_, BilledTokenCounts> { - if self.usage.total_usd_micros.is_some() { + if self.usage.total_usd_micros.is_some() || self.usage.is_zero() { return Cow::Borrowed(&self.usage); } let (Some(catalog), Some(model)) = (catalog, self.model.as_ref()) else { @@ -1052,4 +1052,17 @@ mod iter_stages_tests { None ); } + + #[test] + fn billed_usage_leaves_an_unused_model_uncosted() { + let mut stage = priced_stage(None); + stage.usage = BilledTokenCounts::default(); + + assert_eq!( + stage + .billed_usage(Some(Catalog::builtin())) + .total_usd_micros, + None + ); + } }