diff --git a/Cargo.lock b/Cargo.lock index 7e297f549..987d2eb24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4888,7 +4888,7 @@ checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "lithos-llm" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/lithos-llm?rev=a1e3fd37b7153870411701327ac117606753fe90#a1e3fd37b7153870411701327ac117606753fe90" +source = "git+https://github.com/lithoscomputer/lithos-llm?rev=55add4596b861a0623d00c3a54aa5c147c8d504b#55add4596b861a0623d00c3a54aa5c147c8d504b" dependencies = [ "async-trait", "aws-config", @@ -5869,7 +5869,7 @@ dependencies = [ [[package]] name = "pebble-agent" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5" +source = "git+https://github.com/lithoscomputer/pebble?rev=a39f43e26effdf99635eaf343f095c17157c9c93#a39f43e26effdf99635eaf343f095c17157c9c93" dependencies = [ "async-trait", "futures-util", @@ -5886,7 +5886,7 @@ dependencies = [ [[package]] name = "pebble-cli-core" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5" +source = "git+https://github.com/lithoscomputer/pebble?rev=a39f43e26effdf99635eaf343f095c17157c9c93#a39f43e26effdf99635eaf343f095c17157c9c93" dependencies = [ "anyhow", "async-trait", @@ -5915,7 +5915,7 @@ dependencies = [ [[package]] name = "pebble-coding-agent" version = "0.1.0" -source = "git+https://github.com/lithoscomputer/pebble?rev=6d802a9d2e9c356e76371089e94d4d80c0ba16c5#6d802a9d2e9c356e76371089e94d4d80c0ba16c5" +source = "git+https://github.com/lithoscomputer/pebble?rev=a39f43e26effdf99635eaf343f095c17157c9c93#a39f43e26effdf99635eaf343f095c17157c9c93" dependencies = [ "async-trait", "futures-util", diff --git a/Cargo.toml b/Cargo.toml index b539ed615..4fa4f8650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,7 @@ insta = "1" fabro-test = { path = "lib/foundation/fabro-test" } # Provider-neutral LLM catalog and client. Pinned to a revision until 0.x is # published to crates.io. -lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "a1e3fd37b7153870411701327ac117606753fe90", default-features = false } +lithos-llm = { git = "https://github.com/lithoscomputer/lithos-llm", rev = "55add4596b861a0623d00c3a54aa5c147c8d504b", default-features = false } # Deterministic OpenAI twin used by twin-mode E2E tests; the same revision # lithos-llm verifies its codecs against. twin-openai = { git = "https://github.com/lithoscomputer/twins", rev = "ca45f0e50a6716d716aa2f638ca3cf767e88f613" } @@ -122,9 +122,9 @@ sandbox-driver-testing = { git = "https://github.com/lithoscomputer/sandbox-driv # sandbox, so the pebble and sandbox-driver pins move independently. Pebble # pins the same lithos-llm rev as fabro, and its lockfile policy is that # every shared crate resolves to the version lithos-llm locks. -pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5" } -pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5", features = ["mcp", "search-providers"] } -pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "6d802a9d2e9c356e76371089e94d4d80c0ba16c5" } +pebble-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "a39f43e26effdf99635eaf343f095c17157c9c93" } +pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble", rev = "a39f43e26effdf99635eaf343f095c17157c9c93", features = ["mcp", "search-providers"] } +pebble-cli-core = { git = "https://github.com/lithoscomputer/pebble", rev = "a39f43e26effdf99635eaf343f095c17157c9c93" } sentry = { version = "0.35", default-features = false, features = ["backtrace", "contexts", "ureq", "rustls"] } fork = "0.2" exec = "0.3" diff --git a/apps/fabro-web/app/components/run-summary-panel.test.tsx b/apps/fabro-web/app/components/run-summary-panel.test.tsx index 5ed6eca29..1c87a37cb 100644 --- a/apps/fabro-web/app/components/run-summary-panel.test.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.test.tsx @@ -6,7 +6,7 @@ import { RunSummaryPanelView, type RunSummaryPanelViewProps, } from "./run-summary-panel"; -import { TEST_PRINCIPAL } from "../lib/test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures"; function instanceText(instance: TestRenderer.ReactTestInstance): string { const parts: string[] = []; @@ -56,7 +56,7 @@ function makeRun(overrides: Record = {}) { id: "run_1", created_by: TEST_PRINCIPAL, diff: null, - billing: null, + usage: makeUsage(), ...overrides, } as any; } @@ -163,9 +163,9 @@ describe("RunSummaryPanelView", () => { ); }); - test("renders cost from total_usd_micros", () => { + test("renders cost from the run's usage", () => { const tree = render({ - run: makeRun({ billing: { total_usd_micros: 840_000 } }), + run: makeRun({ usage: makeUsage({}, 840_000) }), }); expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84"); }); diff --git a/apps/fabro-web/app/components/run-summary-panel.tsx b/apps/fabro-web/app/components/run-summary-panel.tsx index b462402a3..f5998d1ab 100644 --- a/apps/fabro-web/app/components/run-summary-panel.tsx +++ b/apps/fabro-web/app/components/run-summary-panel.tsx @@ -128,7 +128,7 @@ export function RunSummaryPanelView({ artifactsLoading, }: RunSummaryPanelViewProps) { const diff = run?.diff ?? null; - const cost = formatUsdMicros(run?.billing?.total_usd_micros); + const cost = formatUsdMicros(run?.usage.cost?.usd_micros); const sandboxKind = sandboxLifecycleKind(run?.sandbox); return ( diff --git a/apps/fabro-web/app/components/size-chip.test.tsx b/apps/fabro-web/app/components/size-chip.test.tsx index 498c97f5e..2635a411a 100644 --- a/apps/fabro-web/app/components/size-chip.test.tsx +++ b/apps/fabro-web/app/components/size-chip.test.tsx @@ -45,7 +45,7 @@ describe("SizeChip", () => { .toBe("Size M · $12.34"); }); - test("omits the cost when the run has no billing yet", () => { + test("omits the cost when the run has no cost yet", () => { expect(tooltipLabel()).toBe("Size M"); expect(tooltipLabel()).toBe("Size M"); }); diff --git a/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx index c69811c92..d4de4eee9 100644 --- a/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx +++ b/apps/fabro-web/app/components/stage-insights-sidebar.test.tsx @@ -21,36 +21,31 @@ import type { McpToolSummary, StageContextWindow, StageProjection, - TokenUsage, + Usage, } from "@qltysh/fabro-api-client"; import { StageInsightsSidebar } from "./stage-insights-sidebar"; +const NO_USAGE: Usage = { + tokens: { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }, +}; + function makeStage(overrides: Partial = {}): StageProjection { return { first_event_seq: 1, state: "running", - usage: { - input_tokens: 0, - output_tokens: 0, - cache_read_tokens: 0, - cache_create_tokens: 0, - total_tokens: 0, - } as StageProjection["usage"], + usage: NO_USAGE, ...overrides, }; } -const NO_TOKENS: TokenUsage = { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }; - /** The coding agent's fold of a stage that has seen nothing yet. */ function makeAgent(overrides: Partial = {}): AgentSessionProjection { return { root_session_id: "ses_root", route: { provider: "anthropic", model: "claude-opus-4-7" }, activity: AgentSessionActivity.RUNNING, - usage: NO_TOKENS, - cost_usd_micros: null, + usage: NO_USAGE, messages: 0, descendants: {}, context_window: null, @@ -67,8 +62,7 @@ function makeAgent(overrides: Partial = {}): AgentSessio prompts: 1, prompt: { completed: false, - usage: NO_TOKENS, - cost_usd_micros: null, + usage: NO_USAGE, messages: 0, context_window: null, tool_calls: 0, @@ -386,7 +380,7 @@ describe("StageInsightsSidebar", () => { to: "openai/gpt-5.4", attempt: 1, error: agentError("rate limited"), - usage: NO_TOKENS, + usage: NO_USAGE, inference_ms: 120, tool_ms: 30, continuation: FailoverContinuation.CONTINUE_TURN, diff --git a/apps/fabro-web/app/components/stage-popover-summary.ts b/apps/fabro-web/app/components/stage-popover-summary.ts index e550f4fe8..48aace6c1 100644 --- a/apps/fabro-web/app/components/stage-popover-summary.ts +++ b/apps/fabro-web/app/components/stage-popover-summary.ts @@ -33,7 +33,7 @@ export function deriveStageSummary(events: EventEnvelope[]): StageSummary { } case "stage.completed": { readFailure(summary, getObject(props, "failure")); - readBilling(summary, getObject(props, "billing")); + readUsage(summary, getObject(props, "usage")); readTermination(summary, getObject(props, "termination")); const notes = getString(props, "notes"); if (notes !== undefined) summary.notes = notes; @@ -43,7 +43,7 @@ export function deriveStageSummary(events: EventEnvelope[]): StageSummary { } case "stage.failed": { readFailure(summary, getObject(props, "failure")); - readBilling(summary, getObject(props, "billing")); + readUsage(summary, getObject(props, "usage")); break; } } @@ -59,10 +59,13 @@ function readFailure(summary: StageSummary, failure: unknown) { if (actor !== undefined) summary.systemActor = actor; } -function readBilling(summary: StageSummary, billing: unknown) { - if (!billing) return; - const input = getNumber(billing, "input_tokens"); - const output = getNumber(billing, "output_tokens"); +/** `stage.completed.usage` is a `ModelUsage`: the model, then the usage. */ +function readUsage(summary: StageSummary, modelUsage: unknown) { + if (!modelUsage) return; + const tokens = getObject(getObject(modelUsage, "usage"), "tokens"); + if (!tokens) return; + const input = getNumber(tokens, "input"); + const output = getNumber(tokens, "output"); if (input !== undefined) summary.inputTokens = input; if (output !== undefined) summary.outputTokens = output; } diff --git a/apps/fabro-web/app/components/stage-popover.test.tsx b/apps/fabro-web/app/components/stage-popover.test.tsx index 5d514e734..2d456dd65 100644 --- a/apps/fabro-web/app/components/stage-popover.test.tsx +++ b/apps/fabro-web/app/components/stage-popover.test.tsx @@ -58,11 +58,16 @@ describe("deriveStageSummary", () => { expect(summary.systemActor).toBe("agent"); }); - test("captures billing tokens from stage.completed", () => { + test("captures usage tokens from stage.completed", () => { const summary = deriveStageSummary([ makeEvent({ event: "stage.completed", - properties: { billing: { input_tokens: 12400, output_tokens: 3120 } }, + properties: { + usage: { + model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, + usage: { tokens: { input: 12400, output: 3120 } }, + }, + }, }), ]); expect(summary.inputTokens).toBe(12400); @@ -118,7 +123,7 @@ describe("deriveStageSummary", () => { test("tolerates missing or non-numeric properties", () => { const summary = deriveStageSummary([ makeEvent({ event: "stage.started", properties: {} }), - makeEvent({ event: "stage.completed", properties: { billing: null } }), + makeEvent({ event: "stage.completed", properties: { usage: null } }), ]); expect(summary).toEqual({}); }); @@ -177,7 +182,10 @@ describe("StagePopover rendering", () => { makeEvent({ event: "stage.completed", properties: { - billing: { input_tokens: 12400, output_tokens: 3120 }, + usage: { + model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, + usage: { tokens: { input: 12400, output: 3120 } }, + }, files_touched: ["a.rs", "b.rs"], }, }), 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 9c48b1960..d75410137 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,7 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; -import { makeBilledTokenCounts } from "../../lib/test-fixtures"; +import { makeUsage } from "../../lib/test-fixtures"; import type { Stage } from "../stage-sidebar"; import { FanInResults } from "./fan-in-results"; @@ -23,7 +23,7 @@ const fanInStage: Stage = { visit: 1, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }; function event(seq: number, partial: Partial): EventEnvelope { @@ -63,7 +63,7 @@ describe("FanInResults", () => { event: "prompt.completed", properties: { response: "All branch findings are now available.", - billing: { input_tokens: 1200, output_tokens: 340 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } }, }, }), ]); diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts index a7be45a24..1537d40a6 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts @@ -269,7 +269,7 @@ describe("parseReducerTranscript", () => { event: "prompt.completed", properties: { response: "The branch results are joined.", - billing: { input_tokens: 1200, output_tokens: 340 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1200, output: 340 } } }, }, }), ]; diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts index afbed444f..d9153c2a0 100644 --- a/apps/fabro-web/app/components/stage-renderers/helpers.ts +++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts @@ -240,9 +240,10 @@ export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscri } else if (event.event === "prompt.completed" && hasReducer) { response = getString(props, "response") ?? response; model = getString(props, "model") ?? model; - const billing = getObject(props, "billing") ?? {}; - inputTokens = getNumber(billing, "input_tokens") ?? inputTokens; - outputTokens = getNumber(billing, "output_tokens") ?? outputTokens; + // `prompt.completed.usage` is a `ModelUsage`: the model, then the usage. + const tokens = getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {}; + inputTokens = getNumber(tokens, "input") ?? inputTokens; + outputTokens = getNumber(tokens, "output") ?? outputTokens; } } diff --git a/apps/fabro-web/app/data/runs.test.ts b/apps/fabro-web/app/data/runs.test.ts index 77fdc91e2..fbc21c019 100644 --- a/apps/fabro-web/app/data/runs.test.ts +++ b/apps/fabro-web/app/data/runs.test.ts @@ -8,7 +8,7 @@ import { mapRunToRunItem, runStatusDisplay, } from "./runs"; -import { TEST_PRINCIPAL } from "../lib/test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures"; function makeRun(overrides: Partial = {}): Run { return { @@ -45,7 +45,7 @@ function makeRun(overrides: Partial = {}): Run { tool_time_ms: 0, active_time_ms: 0, }, - billing: { total_usd_micros: 500000 }, + usage: makeUsage({}, 500000), size: "XS", diff: null, pull_request: null, @@ -108,10 +108,10 @@ describe("mapRunListItem", () => { expect(mapRunListItem(makeRun()).totalUsdMicros).toBe(500000); }); - test("leaves the billed total undefined for runs without terminal billing", () => { - expect(mapRunListItem(makeRun({ billing: null })).totalUsdMicros).toBeUndefined(); + test("leaves the cost undefined for runs whose usage carries none", () => { + expect(mapRunListItem(makeRun({ usage: makeUsage() })).totalUsdMicros).toBeUndefined(); expect( - mapRunListItem(makeRun({ billing: { total_usd_micros: null } })).totalUsdMicros, + mapRunListItem(makeRun({ usage: makeUsage({ input: 12 }) })).totalUsdMicros, ).toBeUndefined(); }); }); @@ -154,7 +154,7 @@ describe("mapRunToRunItem", () => { completed_at: null, }, timing: null, - billing: null, + usage: makeUsage(), }); const item = mapRunToRunItem(summary); expect(item.id).toBe("01DEF"); diff --git a/apps/fabro-web/app/data/runs.ts b/apps/fabro-web/app/data/runs.ts index c5f29f6ae..1c2e587a0 100644 --- a/apps/fabro-web/app/data/runs.ts +++ b/apps/fabro-web/app/data/runs.ts @@ -120,7 +120,7 @@ export function mapRunListItem(item: Run): RunItem { additions: item.diff?.additions, deletions: item.diff?.deletions, size: item.size, - totalUsdMicros: item.billing?.total_usd_micros ?? undefined, + totalUsdMicros: item.usage.cost?.usd_micros, }; } diff --git a/apps/fabro-web/app/lib/billing.ts b/apps/fabro-web/app/lib/billing.ts deleted file mode 100644 index 8d7e2cbf7..000000000 --- a/apps/fabro-web/app/lib/billing.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { BilledTokenCounts } from "@qltysh/fabro-api-client"; - -export interface BillingTokenBucket { - label: string; - value: number; -} - -export function billableOutputTokens(billing: BilledTokenCounts): number { - return billing.output_tokens + billing.reasoning_tokens; -} - -/** The disjoint token buckets shown in every billing breakdown. */ -export function billingTokenBuckets(billing: BilledTokenCounts): BillingTokenBucket[] { - return [ - { label: "Cache read", value: billing.cache_read_tokens }, - { label: "Cache creation", value: billing.cache_write_tokens }, - { label: "Uncached", value: billing.input_tokens }, - { label: "Output", value: billableOutputTokens(billing) }, - ]; -} - -export function hasBillingUsage(billing: BilledTokenCounts): boolean { - return ( - billing.total_tokens !== 0 || - (billing.total_usd_micros ?? 0) !== 0 || - billingTokenBuckets(billing).some((bucket) => bucket.value !== 0) - ); -} diff --git a/apps/fabro-web/app/lib/mutations.ts b/apps/fabro-web/app/lib/mutations.ts index ac44278d6..77aa0bb90 100644 --- a/apps/fabro-web/app/lib/mutations.ts +++ b/apps/fabro-web/app/lib/mutations.ts @@ -122,7 +122,7 @@ function useLifecycleMutation( // Keep the returned lifecycle state visible while revalidation // observes the durable follow-up event (notably a 202 cancel). void mutate(queryKeys.runs.detail(id), result.run, { revalidate: true }); - void mutate(queryKeys.runs.billing(id)); + void mutate(queryKeys.runs.usage(id)); } mutateRunListCaches(mutate); onSuccessExtra?.(result.run, mutate); diff --git a/apps/fabro-web/app/lib/queries.ts b/apps/fabro-web/app/lib/queries.ts index 9900a585e..6eb15249a 100644 --- a/apps/fabro-web/app/lib/queries.ts +++ b/apps/fabro-web/app/lib/queries.ts @@ -25,9 +25,9 @@ import type { ProviderList, PullRequestResponse, RunArtifactListResponse, - RunBilling, RunProjection, Run, + RunUsage, SandboxDetails, SecretListResponse, SandboxFileListResponse, @@ -286,10 +286,10 @@ export function useRunSettings(id: string | undefined) { ); } -export function useRunBilling(id: string | undefined) { - return useSWR( - id ? queryKeys.runs.billing(id) : null, - () => apiData(() => runOutputsApi.retrieveRunBilling(id!)), +export function useRunUsage(id: string | undefined) { + return useSWR( + id ? queryKeys.runs.usage(id) : null, + () => apiData(() => runOutputsApi.retrieveRunUsage(id!)), ); } diff --git a/apps/fabro-web/app/lib/query-keys.ts b/apps/fabro-web/app/lib/query-keys.ts index 168fc4469..154dabfcb 100644 --- a/apps/fabro-web/app/lib/query-keys.ts +++ b/apps/fabro-web/app/lib/query-keys.ts @@ -57,7 +57,7 @@ export const queryKeys = { settings: (id: string) => ["runs", "settings", id] as const, logs: (id: string) => ["runs", "logs", id] as const, artifacts: (id: string) => ["runs", "artifacts", id] as const, - billing: (id: string) => ["runs", "billing", id] as const, + usage: (id: string) => ["runs", "usage", id] as const, questions: (id: string, limit = 1, offset = 0) => ["runs", "questions", id, limit, offset] as const, events: (id: string, limit = 1000) => ["runs", "events", id, limit] as const, diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts index 0547f9398..50942e250 100644 --- a/apps/fabro-web/app/lib/run-actions.test.ts +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -29,7 +29,7 @@ import { unarchiveRuns, } from "./run-actions"; import { generatedAxios } from "./api-client"; -import { TEST_PRINCIPAL } from "./test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "./test-fixtures"; type StubResponseInit = { status: number; @@ -74,7 +74,7 @@ function makeRun(status: RunStatus, archived = false): Run { last_event_at: null, completed_at: null, }, - billing: null, + usage: makeUsage(), size: "XS", diff: null, pull_request: null, diff --git a/apps/fabro-web/app/lib/run-events.test.tsx b/apps/fabro-web/app/lib/run-events.test.tsx index 88f184545..794e183c8 100644 --- a/apps/fabro-web/app/lib/run-events.test.tsx +++ b/apps/fabro-web/app/lib/run-events.test.tsx @@ -46,7 +46,7 @@ describe("queryKeysForRunEvent", () => { queryKeys.runs.state("run-1"), ...queryKeys.runs.filesAllScopes("run-1"), queryKeys.runs.commits("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.stages("run-1"), queryKeys.runs.graph("run-1", "LR"), queryKeys.runs.graph("run-1", "TB"), @@ -56,7 +56,7 @@ describe("queryKeysForRunEvent", () => { test("stage.retrying invalidates stage-scoped and run-scoped resources", () => { expect(queryKeysForRunEvent("run-1", "stage.retrying", "verify@2")).toEqual([ queryKeys.runs.stages("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.events("run-1", 1000), queryKeys.runs.graph("run-1", "LR"), queryKeys.runs.graph("run-1", "TB"), @@ -86,7 +86,7 @@ describe("queryKeysForRunEvent", () => { test("interrupt settlement invalidates projected control state and stage activity", () => { expect(queryKeysForRunEvent("run-1", "agent.round.interrupted", "nap@1")).toEqual([ queryKeys.runs.detail("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.state("run-1"), queryKeys.runs.events("run-1", 1000), queryKeys.runs.stageEvents("run-1", "nap@1"), @@ -166,7 +166,7 @@ describe("queryKeysForRunEvent", () => { expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), ]); } @@ -181,14 +181,14 @@ describe("queryKeysForRunEvent", () => { ).toEqual([ queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), queryKeys.runs.stageContextWindow("run-1", "code@1"), ]); expect(queryKeysForRunEvent("run-1", "agent.session.ended")).toEqual([ queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), ]); }); @@ -202,7 +202,7 @@ describe("queryKeysForRunEvent", () => { expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), ]); } @@ -213,7 +213,7 @@ describe("queryKeysForRunEvent", () => { expect(queryKeysForRunEvent("run-1", event, "code@1")).toEqual([ queryKeys.runs.detail("run-1"), queryKeys.runs.state("run-1"), - queryKeys.runs.billing("run-1"), + queryKeys.runs.usage("run-1"), queryKeys.runs.stageEvents("run-1", "code@1"), queryKeys.runs.stageContextWindow("run-1", "code@1"), ]); @@ -285,7 +285,7 @@ describe("subscribeToRunEvents", () => { source.emit({ event: "run.failed", run_id: "run-terminal" }); expect(source.closed).toBe(false); expect(keys).toContainEqual(queryKeys.runs.files("run-terminal")); - expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal")); + expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal")); keys.length = 0; source.emit({ event: "run.archived", run_id: "run-terminal" }); @@ -383,7 +383,7 @@ describe("subscribeToRunEvents", () => { expect(source.closed).toBe(true); expect(keys).toContainEqual(queryKeys.runs.files("run-terminal")); - expect(keys).toContainEqual(queryKeys.runs.billing("run-terminal")); + expect(keys).toContainEqual(queryKeys.runs.usage("run-terminal")); cleanup(); coordinator.close(); diff --git a/apps/fabro-web/app/lib/run-events.ts b/apps/fabro-web/app/lib/run-events.ts index f7c0dd129..ed2025c95 100644 --- a/apps/fabro-web/app/lib/run-events.ts +++ b/apps/fabro-web/app/lib/run-events.ts @@ -177,7 +177,7 @@ function liveTimingKeys(runId: string): Key[] { return [ queryKeys.runs.detail(runId), queryKeys.runs.state(runId), - queryKeys.runs.billing(runId), + queryKeys.runs.usage(runId), ]; } @@ -199,7 +199,7 @@ export function queryKeysForRunEvent( queryKeys.runs.state(runId), ...queryKeys.runs.filesAllScopes(runId), queryKeys.runs.commits(runId), - queryKeys.runs.billing(runId), + queryKeys.runs.usage(runId), queryKeys.runs.stages(runId), queryKeys.runs.graph(runId, "LR"), queryKeys.runs.graph(runId, "TB"), @@ -220,7 +220,7 @@ export function queryKeysForRunEvent( if (STAGE_EVENTS.has(event)) { const keys: Key[] = [ queryKeys.runs.stages(runId), - queryKeys.runs.billing(runId), + queryKeys.runs.usage(runId), queryKeys.runs.events(runId, 1000), queryKeys.runs.graph(runId, "LR"), queryKeys.runs.graph(runId, "TB"), @@ -255,7 +255,7 @@ export function queryKeysForRunEvent( if (event === "agent.round.interrupted") { keys.unshift( queryKeys.runs.detail(runId), - queryKeys.runs.billing(runId), + queryKeys.runs.usage(runId), ); } if (stageId) { @@ -372,7 +372,7 @@ function resyncKeysForRun(runId: string) { queryKeys.runs.state(runId), ...queryKeys.runs.filesAllScopes(runId), queryKeys.runs.commits(runId), - queryKeys.runs.billing(runId), + queryKeys.runs.usage(runId), queryKeys.runs.stages(runId), queryKeys.runs.events(runId, 1000), queryKeys.runs.graph(runId, "LR"), diff --git a/apps/fabro-web/app/lib/stage-sidebar.test.ts b/apps/fabro-web/app/lib/stage-sidebar.test.ts index fee4a592d..5a3c14674 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.test.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.test.ts @@ -3,7 +3,7 @@ import type { PaginatedRunStageList, StageHandler, StageState } from "@qltysh/fa import type { Stage } from "../components/stage-sidebar"; import { aggregateGraphNodeStatus, formatStageLabel, mapRunStagesToSidebarStages } from "./stage-sidebar"; -import { makeBilledTokenCounts } from "./test-fixtures"; +import { makeUsage } from "./test-fixtures"; import { makeStage as baseMakeStage } from "./test-utils"; function makeStage(nodeId: string, visit: number, status: StageState): Stage { @@ -28,15 +28,16 @@ describe("mapRunStagesToSidebarStages", () => { model: "gpt-5.5", reasoning_effort: "high", }, - billing: makeBilledTokenCounts({ - input_tokens: 28_640, - output_tokens: 7_550, - total_tokens: 43_690, - reasoning_tokens: 1_200, - cache_read_tokens: 4_800, - cache_write_tokens: 1_500, - total_usd_micros: 720_000, - }), + usage: makeUsage( + { + input: 28_640, + output: 7_550, + reasoning: 1_200, + cache_read: 4_800, + cache_write: 1_500, + }, + 720_000, + ), }, { id: "apply-changes@2", @@ -45,7 +46,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "running", node_id: "apply", visit: 2, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, @@ -66,8 +67,8 @@ describe("mapRunStagesToSidebarStages", () => { }); // Each visit keeps its own tokens and cost, so the stage popover never // shows a sibling visit's usage. - expect(result[0].billing.total_usd_micros).toBe(720_000); - expect(result[1].billing.total_usd_micros).toBeUndefined(); + expect(result[0].usage.cost?.usd_micros).toBe(720_000); + expect(result[1].usage.cost).toBeUndefined(); expect(formatStageLabel(result[0])).toBe("Apply Changes"); expect(result[1].id).toBe("apply-changes@2"); @@ -87,7 +88,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "start", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, { id: "verify@1", @@ -96,7 +97,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "verify", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, { id: "exit@1", @@ -105,7 +106,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "exit", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, @@ -125,7 +126,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "running", node_id: "verify", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, @@ -145,7 +146,7 @@ describe("mapRunStagesToSidebarStages", () => { node_id: "work", visit: 1, graph_visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, { id: "work@2", @@ -156,7 +157,7 @@ describe("mapRunStagesToSidebarStages", () => { visit: 2, graph_visit: 1, resumed_from_stage_id: "work@1", - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, @@ -182,7 +183,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "succeeded", node_id: "verify", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, @@ -225,7 +226,7 @@ describe("mapRunStagesToSidebarStages", () => { status: "pending", node_id: "approval", visit: 1, - billing: makeBilledTokenCounts(), + usage: makeUsage(), }, ], meta: { has_more: false }, diff --git a/apps/fabro-web/app/lib/stage-sidebar.ts b/apps/fabro-web/app/lib/stage-sidebar.ts index 560e6a4d1..b0b5508ae 100644 --- a/apps/fabro-web/app/lib/stage-sidebar.ts +++ b/apps/fabro-web/app/lib/stage-sidebar.ts @@ -1,9 +1,9 @@ import { StageState } from "@qltysh/fabro-api-client"; import type { - BilledTokenCounts, PaginatedRunStageList, StageHandler, StageModelUsage, + Usage, } from "@qltysh/fabro-api-client"; import { isVisibleStage } from "../data/runs"; @@ -33,10 +33,10 @@ export interface Stage { startedAt: string | null; providerUsed: StageModelUsage | null; /** - * Tokens and cost for this visit alone, priced the same way the Billing tab + * Tokens and cost for this visit alone, priced the same way the Usage tab * prices its per-node rows. All-zero counts mean the stage called no model. */ - billing: BilledTokenCounts; + usage: Usage; } export const ACTIVE_STAGE_STATES: ReadonlySet = new Set([ @@ -114,7 +114,7 @@ export function mapRunStagesToSidebarStages( : "--", startedAt: stage.started_at ?? null, providerUsed: stage.provider_used ?? null, - billing: stage.billing, + usage: stage.usage, }); } return stages; diff --git a/apps/fabro-web/app/lib/test-fixtures.ts b/apps/fabro-web/app/lib/test-fixtures.ts index 521e46e14..579965d93 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 { BilledTokenCounts, Principal } from "@qltysh/fabro-api-client"; +import type { Cost, Principal, TokenCounts, Usage } from "@qltysh/fabro-api-client"; export const TEST_PRINCIPAL: Principal = { kind: "user", @@ -7,16 +7,24 @@ export const TEST_PRINCIPAL: Principal = { auth_method: "dev_token", }; -export function makeBilledTokenCounts( - overrides: Partial = {}, -): BilledTokenCounts { +export function makeTokenCounts(overrides: Partial = {}): TokenCounts { return { - cache_read_tokens: 0, - cache_write_tokens: 0, - input_tokens: 0, - output_tokens: 0, - reasoning_tokens: 0, - total_tokens: 0, + input: 0, + output: 0, + reasoning: 0, + cache_read: 0, + cache_write: 0, ...overrides, }; } + +/** A usage with the given token buckets and, when `cost` is given, a catalog cost. */ +export function makeUsage( + tokens: Partial = {}, + cost?: number | Cost, +): Usage { + const usage: Usage = { tokens: makeTokenCounts(tokens) }; + if (typeof cost === "number") usage.cost = { usd_micros: cost, source: "catalog" }; + else if (cost) usage.cost = cost; + return usage; +} diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx index fd1a7c249..88b676954 100644 --- a/apps/fabro-web/app/lib/test-utils.tsx +++ b/apps/fabro-web/app/lib/test-utils.tsx @@ -3,7 +3,7 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; import TestRenderer, { act } from "react-test-renderer"; import type { Stage } from "./stage-sidebar"; -import { makeBilledTokenCounts } from "./test-fixtures"; +import { makeUsage } from "./test-fixtures"; const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; @@ -85,7 +85,7 @@ export function makeStage(overrides: Partial = {}): Stage { duration: "--", startedAt: null, providerUsed: null, - billing: makeBilledTokenCounts(), + usage: makeUsage(), ...overrides, }; } diff --git a/apps/fabro-web/app/lib/usage.ts b/apps/fabro-web/app/lib/usage.ts new file mode 100644 index 000000000..3235ad61c --- /dev/null +++ b/apps/fabro-web/app/lib/usage.ts @@ -0,0 +1,53 @@ +import type { Cost, TokenCounts, Usage } from "@qltysh/fabro-api-client"; + +export interface UsageTokenBucket { + label: string; + value: number; +} + +/** The sum of the five disjoint token buckets. */ +export function totalTokens(usage: Usage): number { + const { input, output, reasoning, cache_read, cache_write } = usage.tokens; + return input + output + reasoning + cache_read + cache_write; +} + +/** Output tokens as they are priced: completion plus reasoning. */ +export function billableOutputTokens(tokens: TokenCounts): number { + return tokens.output + tokens.reasoning; +} + +/** The disjoint token buckets shown in every usage breakdown. */ +export function usageTokenBuckets(usage: Usage): UsageTokenBucket[] { + return [ + { label: "Cache read", value: usage.tokens.cache_read }, + { label: "Cache creation", value: usage.tokens.cache_write }, + { label: "Uncached", value: usage.tokens.input }, + { label: "Output", value: billableOutputTokens(usage.tokens) }, + ]; +} + +/** Whether the usage carries any tokens or a cost. */ +export function hasUsage(usage: Usage): boolean { + return totalTokens(usage) !== 0 || (usage.cost?.usd_micros ?? 0) !== 0; +} + +/** The cost in USD micros, when the usage carries one. */ +export function costUsdMicros(usage: Usage | null | undefined): number | undefined { + return usage?.cost?.usd_micros; +} + +/** + * A short tag for a cost that did not come from the catalog: `reported` + * when the provider gave the figure, `summed` when it was assembled from + * differently sourced parts. Catalog estimates carry no tag. + */ +export function costSourceTag(cost: Cost | null | undefined): string | null { + switch (cost?.source) { + case "provider": + return "reported"; + case "application": + return "summed"; + default: + return null; + } +} diff --git a/apps/fabro-web/app/router.tsx b/apps/fabro-web/app/router.tsx index 21d30a2a4..73f2f1068 100644 --- a/apps/fabro-web/app/router.tsx +++ b/apps/fabro-web/app/router.tsx @@ -27,7 +27,7 @@ import * as RunChildren from "./routes/run-children"; import * as RunFiles from "./routes/run-files"; import * as RunSandbox from "./routes/run-sandbox"; import * as RunTerminal from "./routes/run-terminal"; -import * as RunBilling from "./routes/run-billing"; +import * as RunUsage from "./routes/run-usage"; import * as Insights from "./routes/insights"; import * as InsightsEditor from "./routes/insights-editor"; import * as InsightsNew from "./routes/insights-new"; @@ -136,7 +136,7 @@ export const routes: RouteObject[] = [ route("files", RunFiles), route("children", RunChildren), route("sandbox", RunSandbox), - route("billing", RunBilling), + route("usage", RunUsage), ], }), route("insights", Insights, { diff --git a/apps/fabro-web/app/routes/automations-new.test.tsx b/apps/fabro-web/app/routes/automations-new.test.tsx index 6e54e4e59..8a5059a28 100644 --- a/apps/fabro-web/app/routes/automations-new.test.tsx +++ b/apps/fabro-web/app/routes/automations-new.test.tsx @@ -4,7 +4,7 @@ import TestRenderer, { act } from "react-test-renderer"; import { createMemoryRouter, RouterProvider } from "react-router"; import { ToastProvider } from "../components/toast"; -import { TEST_PRINCIPAL } from "../lib/test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures"; import { setupReactTestEnv } from "../lib/test-utils"; let currentRun: any = null; @@ -176,7 +176,7 @@ function makeRun(overrides: Record = {}) { completed_at: null, }, timing: null, - billing: null, + usage: makeUsage(), size: "XS", ask_fabro: { available: false, diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts index a2608b1db..e0462d724 100644 --- a/apps/fabro-web/app/routes/run-detail.test.ts +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -14,7 +14,7 @@ import { import { ToastProvider } from "../components/toast"; import { DemoModeProvider } from "../lib/demo-mode"; -import { TEST_PRINCIPAL } from "../lib/test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures"; let currentRunSummary: any = null; let currentRunState: any = null; @@ -267,7 +267,7 @@ function makeRunSummary({ completed_at: null, }, timing: null, - billing: null, + usage: makeUsage(), size: "XS", diff: diffSummary, pull_request: pullRequest, diff --git a/apps/fabro-web/app/routes/run-detail/header.tsx b/apps/fabro-web/app/routes/run-detail/header.tsx index 28135ec68..a03baa3f7 100644 --- a/apps/fabro-web/app/routes/run-detail/header.tsx +++ b/apps/fabro-web/app/routes/run-detail/header.tsx @@ -93,7 +93,7 @@ export function RunDetailHeader({ ); const sizeChip = ( - + ); return ( diff --git a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx index 5674ea275..b9107ca2a 100644 --- a/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx +++ b/apps/fabro-web/app/routes/run-detail/tabs-shell.tsx @@ -16,7 +16,7 @@ const allTabs: RunDetailTabDefinition[] = [ { name: "Files Changed", path: "/files", count: null }, { name: "Children", path: "/children", count: null }, { name: "Sandbox", path: "/sandbox", count: null, requiresSandbox: true }, - { name: "Billing", path: "/billing", count: null }, + { name: "Usage", path: "/usage", count: null }, ]; export type RunDetailTab = RunDetailTabDefinition; diff --git a/apps/fabro-web/app/routes/run-files.render.test.tsx b/apps/fabro-web/app/routes/run-files.render.test.tsx index 40a51614e..6acba2739 100644 --- a/apps/fabro-web/app/routes/run-files.render.test.tsx +++ b/apps/fabro-web/app/routes/run-files.render.test.tsx @@ -5,7 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router"; import { toast as sonnerToast } from "sonner"; import { ToastProvider } from "../components/toast"; -import { TEST_PRINCIPAL } from "../lib/test-fixtures"; +import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures"; let currentFilesPayload: any = null; let currentCommitsPayload: any = null; @@ -73,7 +73,7 @@ mock.module("../lib/queries", () => ({ last_event_at: null, completed_at: null, }, - billing: null, + usage: makeUsage(), size: "XS", diff: null, pull_request: null, 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 1142ae6b8..4b8da9959 100644 --- a/apps/fabro-web/app/routes/run-stages-chat.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-chat.test.tsx @@ -3,7 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { StageState } from "@qltysh/fabro-api-client"; import type { Stage } from "../lib/stage-sidebar"; -import { makeBilledTokenCounts } from "../lib/test-fixtures"; +import { makeUsage } from "../lib/test-fixtures"; import { StageChatView } from "./run-stages"; function stage(overrides: Partial = {}): Stage { @@ -19,7 +19,7 @@ function stage(overrides: Partial = {}): Stage { resumedFromStageId: null, startedAt: "2026-04-09T12:00:00Z", providerUsed: null, - billing: makeBilledTokenCounts(), + usage: makeUsage(), ...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 4483ec4e9..d7df826ea 100644 --- a/apps/fabro-web/app/routes/run-stages-details.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-details.test.tsx @@ -2,12 +2,12 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import type { - BilledTokenCounts, + Usage, ReasoningOutput, StageModelUsage, } from "@qltysh/fabro-api-client"; -import { makeBilledTokenCounts } from "../lib/test-fixtures"; +import { makeUsage } from "../lib/test-fixtures"; import { EventDetails, ModelUsagePopover } from "./run-stages"; const RUN_START = "2026-04-09T12:00:00Z"; @@ -85,24 +85,25 @@ const PROVIDER_USED: StageModelUsage = { reasoning_effort: "max", }; -function popoverMarkup(counts: BilledTokenCounts): string { +function popoverMarkup(usage: Usage): string { return renderToStaticMarkup( - , + , ); } -describe("ModelUsagePopover billing", () => { +describe("ModelUsagePopover usage", () => { test("shows the visit's token buckets and cost next to the model", () => { const html = popoverMarkup( - makeBilledTokenCounts({ - input_tokens: 28_640, - output_tokens: 7_550, - reasoning_tokens: 1_200, - cache_read_tokens: 4_800, - cache_write_tokens: 1_500, - total_tokens: 43_690, - total_usd_micros: 720_000, - }), + makeUsage( + { + input: 28_640, + output: 7_550, + reasoning: 1_200, + cache_read: 4_800, + cache_write: 1_500, + }, + 720_000, + ), ); expect(html).toContain("kimi-k3"); @@ -112,7 +113,7 @@ describe("ModelUsagePopover billing", () => { expect(html).toContain("1.5k"); expect(html).toContain("Uncached"); expect(html).toContain("28.6k"); - // Output folds in reasoning tokens, matching the Billing tab. + // Output folds in reasoning tokens, matching the Usage tab. expect(html).toContain("Output"); expect(html).toContain("8.8k"); expect(html).toContain("Cost"); @@ -120,7 +121,7 @@ describe("ModelUsagePopover billing", () => { }); test("omits the token section for a stage that called no model", () => { - const html = popoverMarkup(makeBilledTokenCounts()); + const html = popoverMarkup(makeUsage()); expect(html).toContain("kimi-k3"); expect(html).not.toContain("Tokens"); @@ -129,11 +130,7 @@ describe("ModelUsagePopover billing", () => { test("still shows tokens when nothing priced the stage", () => { const html = popoverMarkup( - makeBilledTokenCounts({ - input_tokens: 1_000, - output_tokens: 500, - total_tokens: 1_500, - }), + makeUsage({ input: 1_000, output: 500 }), ); expect(html).toContain("Uncached"); @@ -143,7 +140,7 @@ describe("ModelUsagePopover billing", () => { test("shows a provider-reported cost when token counts are unavailable", () => { const html = popoverMarkup( - makeBilledTokenCounts({ total_usd_micros: 720_000 }), + makeUsage({}, { usd_micros: 720_000, source: "provider" }), ); expect(html).toContain("kimi-k3"); diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index dad3df6f5..df2acb00d 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -382,7 +382,7 @@ describe("eventsToActivity", () => { response: "Refactored auth module", model: "claude-sonnet-4-6", provider: "anthropic", - billing: { input_tokens: 120, output_tokens: 30 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 120, output: 30 } } }, }, }), ]; @@ -430,7 +430,7 @@ describe("eventsToActivity", () => { response: "Done.", model: "claude-sonnet-4-6", provider: "anthropic", - billing: { input_tokens: 10, output_tokens: 5 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 10, output: 5 } } }, }, }), ]; @@ -464,7 +464,7 @@ describe("eventsToActivity", () => { response: "All clear.", model: "claude-sonnet-4-6", provider: "anthropic", - billing: { input_tokens: 0, output_tokens: 4 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 0, output: 4 } } }, }, }), ]; @@ -1263,7 +1263,7 @@ describe("buildThreadDnaItems", () => { }); describe("tool-call-only agent responses", () => { - test("retains an empty agent.message with its timestamp, billing, and tool-call count", () => { + test("retains an empty agent.message with its timestamp, usage, and tool-call count", () => { const events: EventEnvelope[] = [ envelope(1, { event: "agent.message", @@ -1307,7 +1307,7 @@ describe("tool-call-only agent responses", () => { node_id: "code", properties: { response: "", - billing: { input_tokens: 1, output_tokens: 2 }, + usage: { model: { provider: "anthropic", model_id: "claude-sonnet-4-6" }, usage: { tokens: { input: 1, output: 2 } } }, }, }), ]; diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 586f8a14b..1613096e8 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -69,7 +69,7 @@ import { formatTokenCount, formatUsdMicros, } from "../lib/format"; -import { billingTokenBuckets, hasBillingUsage } from "../lib/billing"; +import { costSourceTag, hasUsage, usageTokenBuckets } from "../lib/usage"; import { plural } from "../lib/plural"; import { useRun, @@ -96,11 +96,11 @@ import { type UnknownRecord, } from "../lib/unknown"; import type { - BilledTokenCounts, EventEnvelope, ReasoningOutput, StageHandler, StageModelUsage, + Usage, } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; @@ -370,13 +370,15 @@ export function buildStageActivity( } case "prompt.completed": { if (!sawAssistantMessage) { - const billing = (props.billing ?? {}) as UnknownRecord; + // `prompt.completed.usage` is a `ModelUsage`: the model, then the usage. + const tokens = + getObject(getObject(getObject(props, "usage"), "usage"), "tokens") ?? {}; turns.push({ kind: "assistant", ts: e.ts, content: getString(props, "response") ?? "", - inputTokens: getNumber(billing, "input_tokens") ?? 0, - outputTokens: getNumber(billing, "output_tokens") ?? 0, + inputTokens: getNumber(tokens, "input") ?? 0, + outputTokens: getNumber(tokens, "output") ?? 0, toolCallCount: null, // Only agent.message carries reasoning; prompt stages have none. reasoning: null, @@ -887,10 +889,11 @@ export function formatStageModelUsageLabel( const POPOVER_NUMBER = "block text-right font-mono tabular-nums"; /** Tokens and cost for this stage visit alone. */ -function StageBillingRows({ billing }: { billing: BilledTokenCounts }) { - if (!hasBillingUsage(billing)) return null; - const buckets = billingTokenBuckets(billing); - const cost = formatUsdMicros(billing.total_usd_micros); +function StageUsageRows({ usage }: { usage: Usage }) { + if (!hasUsage(usage)) return null; + const buckets = usageTokenBuckets(usage); + const cost = formatUsdMicros(usage.cost?.usd_micros); + const costTag = costSourceTag(usage.cost); return (
Tokens @@ -905,7 +908,7 @@ function StageBillingRows({ billing }: { billing: BilledTokenCounts }) { ))} {cost && ( - + {cost} )} @@ -916,10 +919,10 @@ function StageBillingRows({ billing }: { billing: BilledTokenCounts }) { export function ModelUsagePopover({ providerUsed, - billing, + usage, }: { providerUsed: StageModelUsage; - billing: BilledTokenCounts; + usage: Usage; }) { return ( <> @@ -942,7 +945,7 @@ export function ModelUsagePopover({ {providerUsed.speed} )} - + ); } @@ -1956,7 +1959,7 @@ function EventsToolbar({ filteredCount, totalCount, providerUsed, - billing, + usage, events, runId, stageId, @@ -1976,7 +1979,7 @@ function EventsToolbar({ filteredCount: number; totalCount: number; providerUsed: StageModelUsage | null; - billing: BilledTokenCounts; + usage: Usage; events: EventEnvelope[]; runId: string; stageId: string; @@ -2058,7 +2061,7 @@ function EventsToolbar({ showFilters ? "" : "ml-auto" }`} content={ - + } >