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