From 7841a77f2c0962439864ebdf8f04ba9441fba7fd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 27 Jul 2026 16:06:19 -0400 Subject: [PATCH 1/2] feat(web): show stage tokens and cost in the model popover The model indicator on a stage page hovered to provider, model, and reasoning effort only. Seeing what a stage actually spent meant leaving for the Billing tab, which reports per node rather than per visit. The stage list had no token data to show, so add a per-visit `billing` block to `GET /runs/{id}/stages`. The Billing tab's pricing rule (a provider-reported cost wins, otherwise the server catalog prices the tokens) was private to `billing_rollup`; move it to `StageProjection::billed_usage` and drive both call sites from it so the two views cannot drift. The popover's buckets use the Billing tab's labels verbatim. It stays scoped to one visit, so a looped node's row on the Billing tab is the sum of what each of its visits shows here. Co-Authored-By: Claude Opus 5 (1M context) --- apps/fabro-web/app/lib/stage-sidebar.test.ts | 22 +++ apps/fabro-web/app/lib/stage-sidebar.ts | 7 + .../app/routes/run-stages-details.test.tsx | 87 ++++++++++- apps/fabro-web/app/routes/run-stages.tsx | 61 +++++++- docs/public/api-reference/fabro-api.yaml | 10 ++ lib/apps/fabro-server/src/demo/mod.rs | 1 + .../src/server/handler/billing.rs | 6 +- lib/apps/fabro-server/src/server/tests.rs | 141 ++++++++++++++++++ .../fabro-workflow/src/billing_rollup.rs | 29 +--- .../fabro-types/src/run_projection.rs | 86 ++++++++++- .../fabro-api-client/src/models/run-stage.ts | 7 + 11 files changed, 423 insertions(+), 34 deletions(-) diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index a71323654..e1cc9092b 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -17,6 +17,7 @@ function makeStage(nodeId: string, visit: number, status: StageState): Stage { duration: "--", startedAt: null, providerUsed: null, + billing: null, }; } @@ -38,6 +39,15 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }, + billing: { + input_tokens: 28_640, + output_tokens: 7_550, + total_tokens: 43_690, + reasoning_tokens: 1_200, + cache_read_tokens: 4_800, + cache_write_tokens: 1_500, + total_usd_micros: 720_000, + }, }, { id: "apply-changes@2", @@ -46,6 +56,14 @@ 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, + }, }, ], meta: { has_more: false }, @@ -64,6 +82,10 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }); + // 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(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index c165adee0..191681459 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -1,5 +1,6 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { + BilledTokenCounts, PaginatedRunStageList, StageHandler, StageModelUsage, @@ -27,6 +28,11 @@ export interface Stage { resumedFromStageId: string | null; startedAt: string | null; providerUsed: StageModelUsage | null; + /** + * 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; } export const ACTIVE_STAGE_STATES: ReadonlySet = new Set([ @@ -102,6 +108,7 @@ export function mapRunStagesToSidebarStages( : "--", startedAt: stage.started_at ?? null, providerUsed: stage.provider_used ?? null, + billing: stage.billing ?? null, }); } return stages; 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 da18f7e67..2f453c63e 100644 --- a/apps/fabro-web/app/routes/run-stages-details.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-details.test.tsx @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import type { ReasoningOutput } from "@qltysh/fabro-api-client"; +import type { + BilledTokenCounts, + ReasoningOutput, + StageModelUsage, +} from "@qltysh/fabro-api-client"; -import { EventDetails } from "./run-stages"; +import { EventDetails, ModelUsagePopover } from "./run-stages"; const RUN_START = "2026-04-09T12:00:00Z"; @@ -72,3 +76,82 @@ describe("EventDetails reasoning", () => { expect(html).toContain(`${"x".repeat(280)}…`); }); }); + +const PROVIDER_USED: StageModelUsage = { + mode: "agent", + provider: "moonshot", + model: "kimi-k3", + 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 { + return renderToStaticMarkup( + , + ); +} + +describe("ModelUsagePopover billing", () => { + test("shows the visit's token buckets and cost next to the model", () => { + const html = popoverMarkup( + billing({ + input_tokens: 28_640, + output_tokens: 7_550, + reasoning_tokens: 1_200, + cache_read_tokens: 4_800, + cache_write_tokens: 1_500, + total_tokens: 43_690, + total_usd_micros: 720_000, + }), + ); + + expect(html).toContain("kimi-k3"); + expect(html).toContain("Cache read"); + expect(html).toContain("4.8k"); + expect(html).toContain("Cache creation"); + expect(html).toContain("1.5k"); + expect(html).toContain("Uncached"); + expect(html).toContain("28.6k"); + // Output folds in reasoning tokens, matching the Billing tab. + expect(html).toContain("Output"); + expect(html).toContain("8.8k"); + expect(html).toContain("Cost"); + expect(html).toContain("$0.72"); + }); + + test("omits the token section for a stage that called no model", () => { + const html = popoverMarkup(billing({})); + + expect(html).toContain("kimi-k3"); + expect(html).not.toContain("Tokens"); + expect(html).not.toContain("Cost"); + }); + + test("still shows tokens when nothing priced the stage", () => { + const html = popoverMarkup( + billing({ input_tokens: 1_000, output_tokens: 500, total_tokens: 1_500 }), + ); + + expect(html).toContain("Uncached"); + expect(html).toContain("1.0k"); + expect(html).not.toContain("Cost"); + }); + + test("renders the model rows alone when the stage list carried no billing", () => { + const html = popoverMarkup(null); + + expect(html).toContain("kimi-k3"); + expect(html).not.toContain("Tokens"); + }); +}); diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 43f4410f1..246f3b2ed 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -67,6 +67,7 @@ import { formatBytes, formatDurationMs, formatTokenCount, + formatUsdMicros, } from "../lib/format"; import { plural } from "../lib/plural"; import { @@ -93,6 +94,7 @@ import { type UnknownRecord, } from "../lib/unknown"; import type { + BilledTokenCounts, EventEnvelope, ReasoningOutput, StageHandler, @@ -866,10 +868,59 @@ export function formatStageModelUsageLabel( return effort ? `${model}[${effort}]` : model; } -function ModelUsagePopover({ +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; + const cost = formatUsdMicros(billing.total_usd_micros); + return ( +
+ Tokens + + {buckets.map((bucket) => ( + + + {bucket.value === 0 + ? "0" + : formatTokenCount(bucket.value, { compactDecimal: true })} + + + ))} + {cost && ( + + {cost} + + )} + +
+ ); +} + +export function ModelUsagePopover({ providerUsed, + billing, }: { providerUsed: StageModelUsage; + billing: BilledTokenCounts | null; }) { return ( <> @@ -892,6 +943,7 @@ function ModelUsagePopover({ {providerUsed.speed} )} + {billing && } ); } @@ -1905,6 +1957,7 @@ function EventsToolbar({ filteredCount, totalCount, providerUsed, + billing, events, runId, stageId, @@ -1924,6 +1977,7 @@ function EventsToolbar({ filteredCount: number; totalCount: number; providerUsed: StageModelUsage | null; + billing: BilledTokenCounts | null; events: EventEnvelope[]; runId: string; stageId: string; @@ -2004,7 +2058,9 @@ function EventsToolbar({ className={`inline-flex items-center gap-1.5 text-xs text-fg-muted ${ showFilters ? "" : "ml-auto" }`} - content={} + content={ + + } >