mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-30 01:53:45 +00:00
Read Usage in the web app and rename the Billing tab to Usage
The run detail tab, route, query hook, and query key say usage. Every read of input_tokens, output_tokens, total_tokens, reasoning_tokens, cache_read_tokens, cache_write_tokens, and total_usd_micros moves to usage.tokens and usage.cost, with lib/usage.ts replacing lib/billing.ts: totalTokens sums the five buckets, and costSourceTag names a cost that the provider reported or that was summed from differently sourced parts. The Usage tab and the stage popover show that tag next to such a cost. Test fixtures build a Usage through makeUsage; the failing set of the web tests is unchanged from main (the same 13 environment failures). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
aca9bbbf97
commit
1cdd78926e
37 changed files with 378 additions and 316 deletions
|
|
@ -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<string, any> = {}) {
|
|||
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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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(<SizeChip size="M" />)).toBe("Size M");
|
||||
expect(tooltipLabel(<SizeChip size="M" totalUsdMicros={null} />)).toBe("Size M");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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> = {}): 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> = {}): 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<AgentSessionProjection> = {}): 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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>): 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 } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> = {}): Run {
|
||||
return {
|
||||
|
|
@ -45,7 +45,7 @@ function makeRun(overrides: Partial<Run> = {}): 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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -25,9 +25,9 @@ import type {
|
|||
ProviderList,
|
||||
PullRequestResponse,
|
||||
RunArtifactListResponse,
|
||||
RunBilling,
|
||||
RunProjection,
|
||||
Run,
|
||||
RunUsage,
|
||||
SandboxDetails,
|
||||
SecretListResponse,
|
||||
SandboxFileListResponse,
|
||||
|
|
@ -286,10 +286,10 @@ export function useRunSettings<T = WorkflowSettings>(id: string | undefined) {
|
|||
);
|
||||
}
|
||||
|
||||
export function useRunBilling(id: string | undefined) {
|
||||
return useSWR<RunBilling>(
|
||||
id ? queryKeys.runs.billing(id) : null,
|
||||
() => apiData(() => runOutputsApi.retrieveRunBilling(id!)),
|
||||
export function useRunUsage(id: string | undefined) {
|
||||
return useSWR<RunUsage>(
|
||||
id ? queryKeys.runs.usage(id) : null,
|
||||
() => apiData(() => runOutputsApi.retrieveRunUsage(id!)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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<StageState> = 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;
|
||||
|
|
|
|||
|
|
@ -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> = {},
|
||||
): BilledTokenCounts {
|
||||
export function makeTokenCounts(overrides: Partial<TokenCounts> = {}): 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<TokenCounts> = {},
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> = {}): Stage {
|
|||
duration: "--",
|
||||
startedAt: null,
|
||||
providerUsed: null,
|
||||
billing: makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
53
apps/fabro-web/app/lib/usage.ts
Normal file
53
apps/fabro-web/app/lib/usage.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> = {}) {
|
|||
completed_at: null,
|
||||
},
|
||||
timing: null,
|
||||
billing: null,
|
||||
usage: makeUsage(),
|
||||
size: "XS",
|
||||
ask_fabro: {
|
||||
available: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ export function RunDetailHeader({
|
|||
</span>
|
||||
);
|
||||
const sizeChip = (
|
||||
<SizeChip size={summary.size} totalUsdMicros={summary.billing?.total_usd_micros} />
|
||||
<SizeChip size={summary.size} totalUsdMicros={summary.usage.cost?.usd_micros} />
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> = {}): Stage {
|
||||
|
|
@ -19,7 +19,7 @@ function stage(overrides: Partial<Stage> = {}): Stage {
|
|||
resumedFromStageId: null,
|
||||
startedAt: "2026-04-09T12:00:00Z",
|
||||
providerUsed: null,
|
||||
billing: makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<ModelUsagePopover providerUsed={PROVIDER_USED} billing={counts} />,
|
||||
<ModelUsagePopover providerUsed={PROVIDER_USED} usage={usage} />,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="mt-3">
|
||||
<PopoverHeader>Tokens</PopoverHeader>
|
||||
|
|
@ -905,7 +908,7 @@ function StageBillingRows({ billing }: { billing: BilledTokenCounts }) {
|
|||
</PopoverRow>
|
||||
))}
|
||||
{cost && (
|
||||
<PopoverRow label="Cost">
|
||||
<PopoverRow label={costTag ? `Cost (${costTag})` : "Cost"}>
|
||||
<span className={POPOVER_NUMBER}>{cost}</span>
|
||||
</PopoverRow>
|
||||
)}
|
||||
|
|
@ -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({
|
|||
<PopoverRow label="Speed">{providerUsed.speed}</PopoverRow>
|
||||
)}
|
||||
</PopoverRows>
|
||||
<StageBillingRows billing={billing} />
|
||||
<StageUsageRows usage={usage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={
|
||||
<ModelUsagePopover providerUsed={providerUsed} billing={billing} />
|
||||
<ModelUsagePopover providerUsed={providerUsed} usage={usage} />
|
||||
}
|
||||
>
|
||||
<CpuChipIcon className="size-3.5" aria-hidden="true" />
|
||||
|
|
@ -2414,7 +2417,7 @@ function RunStageActivityStage({
|
|||
effectiveTab === "primary" ? turns.length : debugEvents.length
|
||||
}
|
||||
providerUsed={selectedStage.providerUsed}
|
||||
billing={selectedStage.billing}
|
||||
usage={selectedStage.usage}
|
||||
events={stageEventsQuery.data ?? []}
|
||||
runId={runId}
|
||||
stageId={selectedStageId}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import { afterEach, describe, expect, mock, test } from "bun:test";
|
|||
import TestRenderer from "react-test-renderer";
|
||||
|
||||
import type {
|
||||
RunBilling,
|
||||
RunUsage,
|
||||
StageTiming,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
import { makeBilledTokenCounts } from "../lib/test-fixtures";
|
||||
import { makeUsage } from "../lib/test-fixtures";
|
||||
|
||||
function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0): StageTiming {
|
||||
return {
|
||||
|
|
@ -17,33 +17,33 @@ function stageTiming(wall_time_ms = 0, inference_time_ms = 0, tool_time_ms = 0):
|
|||
};
|
||||
}
|
||||
|
||||
let currentBilling: RunBilling | undefined;
|
||||
let currentUsage: RunUsage | undefined;
|
||||
|
||||
mock.module("../lib/queries", () => ({
|
||||
useRunBilling: () => ({ data: currentBilling }),
|
||||
useRunUsage: () => ({ data: currentUsage }),
|
||||
}));
|
||||
|
||||
const { default: RunBillingRoute } = await import("./run-billing");
|
||||
const { default: RunUsageRoute } = await import("./run-usage");
|
||||
|
||||
function billing(overrides: Partial<RunBilling> = {}): RunBilling {
|
||||
function runUsage(overrides: Partial<RunUsage> = {}): RunUsage {
|
||||
return {
|
||||
stages: [],
|
||||
totals: {
|
||||
timing: stageTiming(),
|
||||
...makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
},
|
||||
by_model: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderBilling(data: RunBilling): TestRenderer.ReactTestRenderer {
|
||||
currentBilling = data;
|
||||
function renderUsage(data: RunUsage): TestRenderer.ReactTestRenderer {
|
||||
currentUsage = data;
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let renderer: TestRenderer.ReactTestRenderer | undefined;
|
||||
TestRenderer.act(() => {
|
||||
renderer = TestRenderer.create(<RunBillingRoute params={{ id: "run_1" }} />);
|
||||
renderer = TestRenderer.create(<RunUsageRoute params={{ id: "run_1" }} />);
|
||||
});
|
||||
return renderer!;
|
||||
}
|
||||
|
|
@ -61,34 +61,34 @@ function textFromInstance(node: TestRenderer.ReactTestInstance): string {
|
|||
.join("");
|
||||
}
|
||||
|
||||
describe("RunBilling", () => {
|
||||
describe("RunUsage", () => {
|
||||
afterEach(() => {
|
||||
currentBilling = undefined;
|
||||
currentUsage = undefined;
|
||||
delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
|
||||
});
|
||||
|
||||
test("shows a no-model-usage empty state when every stage is non-billable", () => {
|
||||
const renderer = renderBilling(
|
||||
billing({
|
||||
test("shows a no-model-usage empty state when every stage called no model", () => {
|
||||
const renderer = renderUsage(
|
||||
runUsage({
|
||||
stages: [
|
||||
{
|
||||
stage: { id: "start", name: "start" },
|
||||
model: null,
|
||||
billing: makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
timing: stageTiming(),
|
||||
state: "succeeded",
|
||||
},
|
||||
{
|
||||
stage: { id: "command", name: "command" },
|
||||
model: null,
|
||||
billing: makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
timing: stageTiming(61000),
|
||||
state: "succeeded",
|
||||
},
|
||||
],
|
||||
totals: {
|
||||
timing: stageTiming(61000),
|
||||
...makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -103,13 +103,13 @@ describe("RunBilling", () => {
|
|||
});
|
||||
|
||||
test("renders mixed LLM and non-LLM rows while counting only LLM rows by model", () => {
|
||||
const renderer = renderBilling(
|
||||
billing({
|
||||
const renderer = renderUsage(
|
||||
runUsage({
|
||||
stages: [
|
||||
{
|
||||
stage: { id: "start", name: "start" },
|
||||
model: null,
|
||||
billing: makeBilledTokenCounts(),
|
||||
usage: makeUsage(),
|
||||
timing: stageTiming(),
|
||||
state: "succeeded",
|
||||
},
|
||||
|
|
@ -119,24 +119,14 @@ describe("RunBilling", () => {
|
|||
provider: "anthropic",
|
||||
model_id: "claude-sonnet-4-5",
|
||||
},
|
||||
billing: makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
timing: stageTiming(42000),
|
||||
state: "succeeded",
|
||||
},
|
||||
],
|
||||
totals: {
|
||||
timing: stageTiming(42000),
|
||||
...makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
},
|
||||
by_model: [
|
||||
{
|
||||
|
|
@ -145,12 +135,7 @@ describe("RunBilling", () => {
|
|||
model_id: "claude-sonnet-4-5",
|
||||
},
|
||||
stages: 1,
|
||||
billing: makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -160,21 +145,56 @@ describe("RunBilling", () => {
|
|||
expect(text).not.toContain("start");
|
||||
expect(text).toContain("agent");
|
||||
expect(text).toContain("By model");
|
||||
expect(text).toContain("$0.24");
|
||||
// A catalog estimate carries no source tag.
|
||||
expect(text).not.toContain("reported");
|
||||
|
||||
const footers = renderer.root.findAll((node) => node.type === "tfoot");
|
||||
const byModelFooterCells = footers[1].findAll((node) => node.type === "td");
|
||||
expect(textFromInstance(byModelFooterCells[1])).toBe("1");
|
||||
});
|
||||
|
||||
test("tags a provider-reported cost with its source", () => {
|
||||
const reported = makeUsage({ input: 1200, output: 300 }, {
|
||||
usd_micros: 240000,
|
||||
source: "provider",
|
||||
});
|
||||
const renderer = renderUsage(
|
||||
runUsage({
|
||||
stages: [
|
||||
{
|
||||
stage: { id: "agent", name: "agent" },
|
||||
model: { provider: "openrouter", model_id: "kimi-k3" },
|
||||
usage: reported,
|
||||
timing: stageTiming(42000),
|
||||
state: "succeeded",
|
||||
},
|
||||
],
|
||||
totals: { timing: stageTiming(42000), usage: reported },
|
||||
by_model: [
|
||||
{
|
||||
model: { provider: "openrouter", model_id: "kimi-k3" },
|
||||
stages: 1,
|
||||
usage: reported,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const text = textFromNode(renderer.toJSON());
|
||||
expect(text).toContain("$0.24");
|
||||
expect(text).toContain("reported");
|
||||
});
|
||||
|
||||
test("keeps the empty state for runs with no stages", () => {
|
||||
const renderer = renderBilling(billing());
|
||||
const renderer = renderUsage(runUsage());
|
||||
|
||||
const text = textFromNode(renderer.toJSON());
|
||||
expect(text).toContain("No stages yet");
|
||||
expect(text).toContain("Stages will appear as soon as the run starts executing.");
|
||||
});
|
||||
|
||||
test("renders an in-flight row with live billing and includes its elapsed time in the footer", () => {
|
||||
test("renders an in-flight row with live usage and includes its elapsed time in the footer", () => {
|
||||
const originalNow = Date.now;
|
||||
// Pin "now" to 30s after the in-flight row started.
|
||||
const startedAt = "2026-04-29T12:00:00.000Z";
|
||||
|
|
@ -182,8 +202,8 @@ describe("RunBilling", () => {
|
|||
Date.now = () => fakeNow;
|
||||
|
||||
try {
|
||||
const renderer = renderBilling(
|
||||
billing({
|
||||
const renderer = renderUsage(
|
||||
runUsage({
|
||||
stages: [
|
||||
{
|
||||
stage: { id: "in-flight", name: "in-flight" },
|
||||
|
|
@ -192,12 +212,7 @@ describe("RunBilling", () => {
|
|||
model_id: "claude-opus-4-6",
|
||||
speed: "fast",
|
||||
},
|
||||
billing: makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
timing: stageTiming(),
|
||||
started_at: startedAt,
|
||||
state: "running",
|
||||
|
|
@ -205,12 +220,7 @@ describe("RunBilling", () => {
|
|||
],
|
||||
totals: {
|
||||
timing: stageTiming(),
|
||||
...makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
},
|
||||
by_model: [
|
||||
{
|
||||
|
|
@ -220,12 +230,7 @@ describe("RunBilling", () => {
|
|||
speed: "fast",
|
||||
},
|
||||
stages: 1,
|
||||
billing: makeBilledTokenCounts({
|
||||
input_tokens: 1200,
|
||||
output_tokens: 300,
|
||||
total_tokens: 1500,
|
||||
total_usd_micros: 240000,
|
||||
}),
|
||||
usage: makeUsage({ input: 1200, output: 300 }, 240000),
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -249,7 +254,7 @@ describe("RunBilling", () => {
|
|||
const footers = renderer.root.findAll((node) => node.type === "tfoot");
|
||||
const footerCells = footers[0].findAll((node) => node.type === "td");
|
||||
// The Run time column in the footer is index 3 (Total / [empty Model] /
|
||||
// Tokens / Run time / Billing).
|
||||
// Tokens / Run time / Cost).
|
||||
const footerRuntime = textFromInstance(footerCells[3]);
|
||||
expect(footerRuntime).toContain("30s");
|
||||
} finally {
|
||||
|
|
@ -2,24 +2,25 @@ 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,
|
||||
formatUsdMicros,
|
||||
} from "../lib/format";
|
||||
import { useRunBilling } from "../lib/queries";
|
||||
import { useRunUsage } from "../lib/queries";
|
||||
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
import {
|
||||
billableOutputTokens,
|
||||
costSourceTag,
|
||||
hasUsage,
|
||||
usageTokenBuckets,
|
||||
} from "../lib/usage";
|
||||
import type {
|
||||
BilledTokenCounts,
|
||||
BillingModelRef,
|
||||
RunBilling,
|
||||
RunBillingStage,
|
||||
RunUsage,
|
||||
RunUsageStage,
|
||||
Usage,
|
||||
UsageModelRef,
|
||||
} from "@qltysh/fabro-api-client";
|
||||
|
||||
const EMPTY_VALUE = "—";
|
||||
|
|
@ -29,34 +30,30 @@ function formatTokens(n: number | null | undefined) {
|
|||
return formatTokenCount(n, { compactDecimal: true });
|
||||
}
|
||||
|
||||
function formatUsdMicrosOrDash(usdMicros?: number | null): string {
|
||||
return formatUsdMicros(usdMicros) ?? EMPTY_VALUE;
|
||||
}
|
||||
|
||||
function formatModelRef(model?: BillingModelRef | null): string | null {
|
||||
function formatModelRef(model?: UsageModelRef | null): string | null {
|
||||
if (!model) return null;
|
||||
const speed = model.speed ? ` · ${model.speed}` : "";
|
||||
return `${model.provider}:${model.model_id}${speed}`;
|
||||
}
|
||||
|
||||
function isInFlight(stage: RunBillingStage): boolean {
|
||||
function isInFlight(stage: RunUsageStage): boolean {
|
||||
return stage.state != null && IN_FLIGHT_STAGE_STATES.has(stage.state);
|
||||
}
|
||||
|
||||
function isVisibleRow(row: MappedStageRow): boolean {
|
||||
if (row.inFlight) return true;
|
||||
return row.billing != null && hasBillingUsage(row.billing);
|
||||
return row.usage != null && hasUsage(row.usage);
|
||||
}
|
||||
|
||||
interface MappedStageRow {
|
||||
stage: string;
|
||||
model: string | null;
|
||||
billing: BilledTokenCounts | null;
|
||||
usage: Usage | null;
|
||||
wallTimeMs: number;
|
||||
inFlight: boolean;
|
||||
}
|
||||
|
||||
function liveWallTimeMs(stage: RunBillingStage, now: number): number {
|
||||
function liveWallTimeMs(stage: RunUsageStage, now: number): number {
|
||||
if (stage.started_at) {
|
||||
const startedMs = new Date(stage.started_at).getTime();
|
||||
if (Number.isFinite(startedMs)) {
|
||||
|
|
@ -68,20 +65,20 @@ function liveWallTimeMs(stage: RunBillingStage, now: number): number {
|
|||
|
||||
export const handle = { wide: true };
|
||||
|
||||
function mapStageRow(stage: RunBillingStage, wallTimeMs: number): MappedStageRow {
|
||||
function mapStageRow(stage: RunUsageStage, wallTimeMs: number): MappedStageRow {
|
||||
const hasModel = stage.model != null;
|
||||
return {
|
||||
stage: stage.stage.name,
|
||||
model: formatModelRef(stage.model),
|
||||
billing: hasModel ? stage.billing : null,
|
||||
usage: hasModel ? stage.usage : null,
|
||||
wallTimeMs,
|
||||
inFlight: isInFlight(stage),
|
||||
};
|
||||
}
|
||||
|
||||
/** Hover breakdown of the disjoint token buckets behind an `in / out` count. */
|
||||
function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
|
||||
const buckets = billingTokenBuckets(billing);
|
||||
function TokenBreakdown({ usage }: { usage: Usage }) {
|
||||
const buckets = usageTokenBuckets(usage);
|
||||
return (
|
||||
<div className="min-w-44 py-0.5">
|
||||
<div className="border-line text-fg-2 mb-1.5 border-b pb-1 font-medium">
|
||||
|
|
@ -108,71 +105,91 @@ function TokenBreakdown({ billing }: { billing: BilledTokenCounts }) {
|
|||
* Renders an `input / output` token count. When the row has model usage,
|
||||
* hovering the count reveals the cache breakdown.
|
||||
*/
|
||||
function TokensCell({ billing }: { billing: BilledTokenCounts | null }) {
|
||||
function TokensCell({ usage }: { usage: Usage | null }) {
|
||||
const display = (
|
||||
<>
|
||||
{formatTokens(billing?.input_tokens)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(billing ? billableOutputTokens(billing) : null)}
|
||||
{formatTokens(usage?.tokens.input)} <span className="text-fg-muted">/</span>{" "}
|
||||
{formatTokens(usage ? billableOutputTokens(usage.tokens) : null)}
|
||||
</>
|
||||
);
|
||||
if (!billing) return display;
|
||||
if (!usage) return display;
|
||||
return (
|
||||
<Tooltip label={<TokenBreakdown billing={billing} />}>
|
||||
<Tooltip label={<TokenBreakdown usage={usage} />}>
|
||||
<span>{display}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunBilling({ params }: { params: { id: string } }) {
|
||||
const billingQuery = useRunBilling(params.id);
|
||||
const billing = billingQuery.data;
|
||||
const hasInFlight = billing?.stages.some(isInFlight) ?? false;
|
||||
/**
|
||||
* Renders a cost, or a dash when the usage carries none. A cost that did not
|
||||
* come from the catalog is tagged with where it came from.
|
||||
*/
|
||||
function CostCell({ usage }: { usage: Usage | null | undefined }) {
|
||||
const cost = usage?.cost;
|
||||
const formatted = formatUsdMicros(cost?.usd_micros);
|
||||
if (formatted == null) return <>{EMPTY_VALUE}</>;
|
||||
const tag = costSourceTag(cost);
|
||||
return (
|
||||
<>
|
||||
{formatted}
|
||||
{tag ? (
|
||||
<span className="text-fg-muted ml-1.5 font-sans text-[10px] uppercase tracking-wide">
|
||||
{tag}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RunUsageRoute({ params }: { params: { id: string } }) {
|
||||
const usageQuery = useRunUsage(params.id);
|
||||
const runUsage: RunUsage | undefined = usageQuery.data;
|
||||
const hasInFlight = runUsage?.stages.some(isInFlight) ?? false;
|
||||
|
||||
// Tick once per second only while a stage is in-flight.
|
||||
const now = useTickingNow(hasInFlight);
|
||||
|
||||
// Completed rows don't depend on `now`; memoize them by `billing` so we
|
||||
// Completed rows don't depend on `now`; memoize them by `runUsage` so we
|
||||
// don't reallocate them every tick.
|
||||
const completedRows = useMemo<MappedStageRow[]>(() => {
|
||||
if (!billing) return [];
|
||||
return billing.stages.map((stage) => mapStageRow(stage, stage.timing.wall_time_ms));
|
||||
}, [billing]);
|
||||
if (!runUsage) return [];
|
||||
return runUsage.stages.map((stage) => mapStageRow(stage, stage.timing.wall_time_ms));
|
||||
}, [runUsage]);
|
||||
|
||||
// The model breakdown is server-derived and stable across ticks too.
|
||||
const modelBreakdown = useMemo(() => {
|
||||
if (!billing) return [];
|
||||
return billing.by_model
|
||||
if (!runUsage) return [];
|
||||
return runUsage.by_model
|
||||
.map((entry) => ({
|
||||
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
|
||||
stages: entry.stages,
|
||||
billing: entry.billing,
|
||||
model: formatModelRef(entry.model) ?? EMPTY_VALUE,
|
||||
stages: entry.stages,
|
||||
usage: entry.usage,
|
||||
}))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.billing.total_usd_micros ?? -1) - (a.billing.total_usd_micros ?? -1),
|
||||
(b.usage.cost?.usd_micros ?? -1) - (a.usage.cost?.usd_micros ?? -1),
|
||||
);
|
||||
}, [billing]);
|
||||
}, [runUsage]);
|
||||
|
||||
// Re-derive only the in-flight rows on each tick; everything else stays put.
|
||||
const rows = useMemo<MappedStageRow[]>(() => {
|
||||
if (!billing) return [];
|
||||
if (!runUsage) return [];
|
||||
if (!hasInFlight) return completedRows;
|
||||
return billing.stages.map((stage, idx) =>
|
||||
return runUsage.stages.map((stage, idx) =>
|
||||
isInFlight(stage)
|
||||
? mapStageRow(stage, liveWallTimeMs(stage, now))
|
||||
: completedRows[idx],
|
||||
);
|
||||
}, [billing, completedRows, hasInFlight, now]);
|
||||
}, [runUsage, completedRows, hasInFlight, now]);
|
||||
|
||||
// While ticking, sum the displayed row runtimes so the footer updates in
|
||||
// lock-step. Otherwise trust the server's authoritative total.
|
||||
const totalWallTimeMs = hasInFlight
|
||||
? rows.reduce((sum, row) => sum + row.wallTimeMs, 0)
|
||||
: (billing?.totals.timing.wall_time_ms ?? 0);
|
||||
: (runUsage?.totals.timing.wall_time_ms ?? 0);
|
||||
|
||||
const hasLlmStages = (billing?.by_model.length ?? 0) > 0;
|
||||
const totalBilling = hasLlmStages && billing ? billing.totals : null;
|
||||
const totalUsdMicros = billing?.totals.total_usd_micros;
|
||||
const hasLlmStages = (runUsage?.by_model.length ?? 0) > 0;
|
||||
const totalUsage = hasLlmStages && runUsage ? runUsage.totals.usage : null;
|
||||
const modelStageCount = modelBreakdown.reduce((sum, row) => sum + row.stages, 0);
|
||||
const visibleRows = rows.filter(isVisibleRow);
|
||||
|
||||
|
|
@ -201,7 +218,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -212,13 +229,13 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{row.model ?? EMPTY_VALUE}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
<TokensCell billing={row.billing} />
|
||||
<TokensCell usage={row.usage} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatDurationMs(row.wallTimeMs)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicrosOrDash(row.billing?.total_usd_micros)}
|
||||
<CostCell usage={row.usage} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -228,13 +245,13 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
<td className="px-4 py-3 font-medium text-fg">Total</td>
|
||||
<td className="px-4 py-3 text-xs text-fg-muted">All models</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
<TokensCell billing={totalBilling} />
|
||||
<TokensCell usage={totalUsage} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatDurationMs(totalWallTimeMs)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicrosOrDash(totalUsdMicros)}
|
||||
<CostCell usage={totalUsage} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -251,7 +268,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
<th className="px-4 py-2.5 font-medium">Model</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
|
||||
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -262,10 +279,10 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{row.stages}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
|
||||
<TokensCell billing={row.billing} />
|
||||
<TokensCell usage={row.usage} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicrosOrDash(row.billing.total_usd_micros)}
|
||||
<CostCell usage={row.usage} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -277,10 +294,10 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{modelStageCount}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
|
||||
<TokensCell billing={totalBilling} />
|
||||
<TokensCell usage={totalUsage} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicrosOrDash(totalUsdMicros)}
|
||||
<CostCell usage={totalUsage} />
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -5,7 +5,7 @@ import type { PaginatedRunList, Run } from "@qltysh/fabro-api-client";
|
|||
|
||||
import { ToastProvider } from "../components/toast";
|
||||
import { CHILD_RUNS_LIST_PREFERENCES_STORAGE_KEY } from "../components/runs-list/preferences";
|
||||
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
|
||||
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
|
||||
import { setupReactTestEnv } from "../lib/test-utils";
|
||||
|
||||
class MemoryStorage {
|
||||
|
|
@ -57,7 +57,7 @@ function run(id: string, repo = "qlty/fabro", workflow = "release"): Run {
|
|||
last_event_at: "2026-04-19T12:04:00Z",
|
||||
completed_at: "2026-04-19T12:05:00Z",
|
||||
},
|
||||
billing: null,
|
||||
usage: makeUsage(),
|
||||
size: "XS",
|
||||
diff: null,
|
||||
pull_request: null,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import {
|
|||
} from "./runs";
|
||||
import { summarizeBatchLifecycleAction } from "../components/runs-list/batch-lifecycle";
|
||||
import { mapRunListItem } from "../data/runs";
|
||||
import { TEST_PRINCIPAL } from "../lib/test-fixtures";
|
||||
import { TEST_PRINCIPAL, makeUsage } from "../lib/test-fixtures";
|
||||
|
||||
function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
|
||||
const status =
|
||||
|
|
@ -58,7 +58,7 @@ function boardRun(id: string, column: BoardColumn, questionText?: string): Run {
|
|||
last_event_at: null,
|
||||
completed_at: null,
|
||||
},
|
||||
billing: null,
|
||||
usage: makeUsage(),
|
||||
size: "XS",
|
||||
diff: null,
|
||||
pull_request: null,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue