diff --git a/dashboard/src/v2/pages/stats/__tests__/CostAllocationPanels.test.tsx b/dashboard/src/v2/pages/stats/__tests__/CostAllocationPanels.test.tsx new file mode 100644 index 0000000000..c3b37d2b9e --- /dev/null +++ b/dashboard/src/v2/pages/stats/__tests__/CostAllocationPanels.test.tsx @@ -0,0 +1,274 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, within } from "@testing-library/preact"; +import * as matchers from "@testing-library/jest-dom/matchers"; +import { afterEach, describe, expect, it } from "vitest"; +import type { + CostAmount, + CostCoverageState, + CostDimensionRow, + CostModelRow, + CostProvenance, + CostSpendSegment, + CostTokenSegment, +} from "../cost-insights.js"; +import { CostAllocationPanels } from "../components/cost/CostAllocationPanels.js"; + +expect.extend(matchers); + +afterEach(() => cleanup()); + +function provenance(state: CostCoverageState, calls = 10): CostProvenance { + return { + state, + invocationCount: calls, + configuredPricingInvocationCount: state === "complete" || state === "partial" ? calls - (state === "partial" ? 2 : 0) : 0, + providerReportedCostInvocationCount: 0, + unpricedInvocationCount: state === "unpriced" ? calls : state === "partial" ? 2 : 0, + unknownInvocationCount: state === "unknown" ? calls : 0, + }; +} + +function amount(usd: number | null, state: CostCoverageState = "complete", calls = 10): CostAmount { + return { usd, provenance: provenance(state, calls) }; +} + +const tokenSegments: CostTokenSegment[] = [ + { id: "input", label: "Input", tokens: 40, share: 0.4 }, + { id: "cached_input", label: "Cached input", tokens: 20, share: 0.2 }, + { id: "output", label: "Output", tokens: 30, share: 0.3 }, + { id: "reasoning", label: "Reasoning", tokens: 10, share: 0.1 }, +]; + +function spendSegments(state: CostCoverageState = "complete"): CostSpendSegment[] { + const values = [1, 2, 4, 3]; + const ids: CostSpendSegment["id"][] = ["input", "cached_input", "output", "provider_reported"]; + const labels = ["Input", "Cached input", "Output", "Provider reported"]; + return ids.map((id, index) => ({ + id, + label: labels[index] ?? id, + amount: amount(values[index] ?? 0, state), + share: (values[index] ?? 0) / 10, + })); +} + +function dimensionRow(id: string, label: string, cost: number, tokens: number, calls = 2): CostDimensionRow { + return { + id, + label, + amount: amount(cost, "complete", calls), + spendShare: cost / 10, + tokenShare: tokens / 100, + calls, + costPerCall: amount(cost / calls, "complete", calls), + tokens, + }; +} + +function modelRow( + id: string, + provider: string, + model: string, + cost: number, + tokens: number, +): CostModelRow { + return { + ...dimensionRow(id, `${provider} / ${model}`, cost, tokens), + provider, + model, + }; +} + +function renderPanels(overrides: Partial[0]> = {}) { + const models = [ + modelRow("provider-a:model-alpha", "Provider A", "model-alpha", 6, 60), + modelRow("provider-b:model-alpha", "Provider B", "model-alpha", 4, 40), + ]; + const purposes = [ + dimensionRow("task_coding", "task_coding", 6, 60), + dimensionRow("quality-assurance", "quality-assurance", 4, 40), + ]; + + return render( + , + ); +} + +describe("CostAllocationPanels", () => { + it("reconciles exact token and spend segments and keeps provider-reported remainder distinct", () => { + renderPanels(); + + expect(screen.getByRole("region", { name: "Cost allocation" })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Input 40; cached 20; output 30; reasoning 10; total 100/i })).toBeInTheDocument(); + expect(screen.getByRole("img", { name: /Spend allocation.*Provider reported: \$3\.00, 30\.0%.*Total: \$10\.00/i })).toBeInTheDocument(); + + const tokens = screen.getByRole("list", { name: "Exact token allocation values" }); + expect(within(tokens).getByText("40 tokens")).toBeInTheDocument(); + expect(within(tokens).getByText("20 tokens")).toBeInTheDocument(); + expect(within(tokens).getByText("30 tokens")).toBeInTheDocument(); + expect(within(tokens).getByText("10 tokens")).toBeInTheDocument(); + expect(within(tokens).getAllByText(/%$/).map((node) => node.textContent)).toEqual([ + "40.0%", "20.0%", "30.0%", "10.0%", + ]); + + const spend = screen.getByRole("list", { name: "Exact spend allocation values" }); + expect(within(spend).getByText("Provider reported")).toBeInTheDocument(); + expect(within(spend).getByText("$3.00")).toBeInTheDocument(); + expect(within(spend).getAllByText(/%$/).map((node) => node.textContent)).toEqual([ + "10.0%", "20.0%", "40.0%", "30.0%", + ]); + }); + + it("preserves deterministic view-model ranking and tie order while retaining provider/model identities", () => { + const tiedModels = [ + modelRow("a", "Provider A", "same-model", 5, 50), + modelRow("b", "Provider B", "same-model", 5, 50), + ]; + renderPanels({ models: tiedModels }); + + const ranking = screen.getByRole("list", { name: "Ranked model cost allocation" }); + const providerA = within(ranking).getByText("Provider A · same-model"); + const providerB = within(ranking).getByText("Provider B · same-model"); + expect(providerA.compareDocumentPosition(providerB) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(within(ranking).getAllByText("same-model")).toHaveLength(2); + }); + + it("groups rows beyond the deterministic top six into a bounded Other row that reconciles totals", () => { + const purposes = Array.from({ length: 8 }, (_, index) => ( + dimensionRow(`purpose_${index + 1}`, `purpose_${index + 1}`, 1.25, 12.5, 1) + )); + renderPanels({ purposes }); + + const ranking = screen.getByRole("list", { name: "Ranked purpose cost allocation" }); + expect(within(ranking).getAllByRole("listitem")).toHaveLength(7); + const other = screen.getByLabelText("Other purpose entries, 2 rows, ranked 7"); + expect(within(other).getByText("Other (2)")).toBeInTheDocument(); + expect(within(other).getByText("$2.50")).toBeInTheDocument(); + expect(within(other).getByText("25")).toBeInTheDocument(); + expect(within(other).getAllByText("25.0%", { selector: "dd" })).toHaveLength(2); + expect(screen.queryByText("Purpose 7")).not.toBeInTheDocument(); + expect(screen.queryByText("Purpose 8")).not.toBeInTheDocument(); + }); + + it("keeps long labels intact, humanizes purpose identifiers, and exposes keyboard-scannable rows", () => { + const longModel = "model-with-an-intentionally-very-long-context-and-reasoning-variant-name"; + const longPurpose = "automated_security_review_and_dependency_validation"; + renderPanels({ + models: [modelRow("long", "Provider with a long regional deployment identity", longModel, 10, 100)], + purposes: [dimensionRow("long-purpose", longPurpose, 10, 100)], + }); + + expect(screen.getByText(longModel)).toBeInTheDocument(); + expect(screen.getByText(`Provider with a long regional deployment identity · ${longModel}`)).toBeInTheDocument(); + expect(screen.getByText("Automated security review and dependency validation")).toBeInTheDocument(); + expect(screen.getByLabelText(`${longModel} ranked 1`)).toHaveAttribute("tabindex", "0"); + }); + + it("shows an explicit empty state without presenting missing data as free usage", () => { + renderPanels({ + totalSpend: amount(null, "unavailable", 0), + totalTokens: 0, + tokenSegments: tokenSegments.map((segment) => ({ ...segment, tokens: 0, share: 0 })), + spendSegments: spendSegments().map((segment) => ({ + ...segment, + amount: amount(null, "unavailable", 0), + share: 0, + })), + models: [], + purposes: [], + }); + + expect(screen.getByText("Empty window — no calls or token usage were recorded.")).toBeInTheDocument(); + expect(screen.getAllByText("Unavailable").length).toBeGreaterThan(0); + expect(screen.queryByText("Configured free usage", { exact: false })).not.toBeInTheDocument(); + expect(screen.getByText("No model cost allocation is available for this window.")).toBeInTheDocument(); + expect(screen.getByText("No purpose cost allocation is available for this window.")).toBeInTheDocument(); + }); + + it("keeps unpriced usage visibly distinct from a configured zero-dollar total", () => { + const unpriced = amount(0, "unpriced", 4); + renderPanels({ + totalSpend: unpriced, + spendSegments: spendSegments("unpriced").map((segment) => ({ + ...segment, + amount: unpriced, + share: 0, + })), + }); + + expect(screen.getByText("Unpriced usage — 4 calls have usage telemetry but no usable price.")).toBeInTheDocument(); + expect(screen.getAllByText("Unpriced").length).toBeGreaterThan(0); + expect(screen.queryByText("Configured free usage", { exact: false })).not.toBeInTheDocument(); + }); + + it("marks partial coverage as a minimum rather than a complete total", () => { + renderPanels({ totalSpend: amount(5, "partial", 10) }); + + expect(screen.getByText("Partial cost coverage — 2 of 10 calls remain unpriced; shown spend is a minimum.")).toBeInTheDocument(); + expect(screen.getByText("$5.00+", { selector: "strong" })).toBeInTheDocument(); + }); + + it("does not announce unknown or unavailable coverage as zero spend", () => { + const { rerender } = renderPanels({ + totalSpend: amount(0, "unknown", 3), + spendSegments: spendSegments("unknown").map((segment) => ({ + ...segment, + amount: amount(0, "unknown", 3), + share: 0, + })), + }); + + expect(screen.getByText("Coverage unknown — 3 of 3 calls lack cost-source metadata.")).toBeInTheDocument(); + expect(screen.getByRole("img", { + name: /Spend allocation.*Input: Coverage unknown, 0\.0%.*Total: Coverage unknown\. No positive spend lanes\./i, + })).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + + rerender( + ({ + ...segment, + amount: amount(null, "unavailable", 3), + share: 0, + }))} + models={[]} + purposes={[]} + />, + ); + + expect(screen.getByText("Spend unavailable — this window does not contain enough cost data to price usage.")).toBeInTheDocument(); + expect(screen.getByRole("img", { + name: /Spend allocation.*Provider reported: Unavailable, 0\.0%.*Total: Unavailable\. No positive spend lanes\./i, + })).toBeInTheDocument(); + }); + + it("identifies a covered zero total as configured free usage", () => { + renderPanels({ + totalSpend: amount(0, "complete", 3), + spendSegments: spendSegments().map((segment) => ({ + ...segment, + amount: amount(0, "complete", 3), + share: 0, + })), + }); + + expect(screen.getByText("Configured free usage — covered calls reconcile to $0.00 and are not unpriced.")).toBeInTheDocument(); + expect(screen.getAllByText("$0.00").length).toBeGreaterThan(0); + expect(screen.getByRole("img", { + name: /Spend allocation.*Provider reported: \$0\.00, 0\.0%.*Total: \$0\.00\. No positive spend lanes\./i, + })).toBeInTheDocument(); + expect(screen.queryByText("Unpriced")).not.toBeInTheDocument(); + }); +}); diff --git a/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.module.css b/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.module.css new file mode 100644 index 0000000000..7e2f6d52d6 --- /dev/null +++ b/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.module.css @@ -0,0 +1,284 @@ +.root { + display: grid; + min-width: 0; + gap: 1rem; +} + +.coverageNotice { + border-left-width: 3px; + padding: 0.75rem 1rem; + font-size: 0.8125rem; + line-height: 1.5; +} + +.allocationGrid, +.breakdownGrid { + display: grid; + min-width: 0; + gap: 1rem; +} + +.allocationPanel, +.breakdownPanel { + min-width: 0; +} + +.panelHeading { + min-width: 0; +} + +.eyebrow, +.metrics dt { + color: var(--stats-label-color); + font-size: 0.625rem; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.panelTitle { + margin: 0.35rem 0 0; + color: var(--stats-value-color); + font-size: 1.25rem; + font-weight: 630; + line-height: 1.25; + letter-spacing: -0.02em; +} + +.panelDescription { + max-width: 44rem; + margin: 0.4rem 0 0; + color: var(--stats-detail-color); + font-size: 0.8125rem; + line-height: 1.55; +} + +.totalLine { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem 1rem; + margin: 1.5rem 0 0.75rem; + color: var(--stats-detail-color); + font-size: 0.75rem; +} + +.totalLine strong { + color: var(--stats-value-color); + font-family: var(--font-mono); + font-size: 1rem; +} + +.spendBar { + display: flex; + width: 100%; + height: 0.5rem; + overflow: hidden; + border-radius: 999px; +} + +.spendSegment { + min-width: 2px; + height: 100%; + transition: width var(--stats-motion-standard); +} + +.allocationSummary { + margin: 0.75rem 0 0; + color: var(--stats-detail-color); + font-size: 0.75rem; + line-height: 1.45; +} + +.legend { + display: grid; + margin: 1rem 0 0; + padding: 0; + border-top: 1px solid var(--stats-border-hairline); + list-style: none; +} + +.legendRow { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto auto; + align-items: center; + gap: 0.6rem; + border-bottom: 1px solid var(--stats-border-hairline); + padding: 0.7rem 0; + outline: none; +} + +.swatch { + width: 0.75rem; + height: 0.75rem; + border: 1px solid var(--stats-border-strong); + border-radius: 0.2rem; + background-color: var(--stats-accent-signal); +} + +.segment_input { + background-color: var(--stats-signal-text); +} + +.segment_cached_input { + background-color: var(--stats-accent-cyan); + background-image: repeating-linear-gradient(135deg, transparent 0 2px, var(--stats-surface-panel) 2px 3px); +} + +.segment_output { + background-color: var(--stats-warning-text); + background-image: repeating-linear-gradient(90deg, transparent 0 3px, var(--stats-surface-panel) 3px 4px); +} + +.segment_reasoning { + background-color: var(--stats-negative-text); + background-image: repeating-linear-gradient(45deg, transparent 0 2px, var(--stats-surface-panel) 2px 3px); +} + +.segment_provider_reported { + background-color: var(--stats-positive-text); + background-image: repeating-linear-gradient(-45deg, transparent 0 2px, var(--stats-surface-panel) 2px 4px); +} + +.legendLabel, +.legendValue, +.legendShare { + min-width: 0; + overflow-wrap: anywhere; +} + +.legendLabel { + color: var(--stats-value-color); + font-size: 0.8125rem; + font-weight: 600; +} + +.legendValue, +.legendShare { + font-family: var(--font-mono); + font-size: 0.75rem; + text-align: right; +} + +.legendValue { + color: var(--stats-value-color); +} + +.legendShare { + min-width: 3.75rem; + color: var(--stats-detail-color); +} + +.dimensionList { + display: grid; + margin: 1rem 0 0; + padding: 0; + gap: 0.625rem; + list-style: none; +} + +.dimensionRow { + min-width: 0; + outline: none; +} + +.rowHeading { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 0.75rem; +} + +.rank { + color: var(--stats-label-color); + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.5rem; +} + +.rowCopy { + min-width: 0; +} + +.rowCopy h3, +.rowCopy p { + overflow-wrap: anywhere; +} + +.rowCopy h3 { + margin: 0; + color: var(--stats-value-color); + font-size: 0.875rem; + font-weight: 650; + line-height: 1.5; +} + +.rowCopy p { + margin: 0.15rem 0 0; + color: var(--stats-detail-color); + font-family: var(--font-mono); + font-size: 0.6875rem; + line-height: 1.45; +} + +.metrics { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.65rem; + margin: 0.9rem 0 0; +} + +.metrics > div { + min-width: 0; + border-top: 1px solid var(--stats-border-hairline); + padding-top: 0.5rem; +} + +.metrics dt, +.metrics dd { + overflow-wrap: anywhere; +} + +.metrics dd { + margin: 0.2rem 0 0; + color: var(--stats-value-color); + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.35; +} + +@media (min-width: 52rem) { + .allocationGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 70rem) { + .breakdownGrid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 34rem) { + .legendRow { + grid-template-columns: auto minmax(0, 1fr) auto; + } + + .legendShare { + grid-column: 2 / -1; + text-align: left; + } + + .metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (prefers-reduced-motion: reduce) { + .spendSegment { + transition: none; + } +} diff --git a/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.tsx b/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.tsx new file mode 100644 index 0000000000..4b04995fa5 --- /dev/null +++ b/dashboard/src/v2/pages/stats/components/cost/CostAllocationPanels.tsx @@ -0,0 +1,412 @@ +import type { FunctionComponent, JSX } from "preact"; +import type { + CostAmount, + CostDimensionRow, + CostModelRow, + CostProvenance, + CostSpendSegment, + CostTokenSegment, +} from "../../cost-insights.js"; +import { formatAdaptiveCurrency } from "../../cost-insights.js"; +import { NUMBER_FORMATTER } from "../../stats-utils.js"; +import { + CHIP_CLASS, + CONTROL_FOCUS_CLASS, + DASHED_EMPTY_CLASS, + PANEL_CLASS, + STATUS_TONE_CLASS, + SUBPANEL_CLASS, + TRACK_CLASS, + TokenFlowBar, +} from "../stats-ui-primitives.js"; +import styles from "./CostAllocationPanels.module.css"; + +const TOP_ROW_COUNT = 6; + +export interface CostAllocationPanelsProps { + totalSpend: CostAmount; + totalTokens: number; + tokenSegments: CostTokenSegment[]; + spendSegments: CostSpendSegment[]; + models: CostModelRow[]; + purposes: CostDimensionRow[]; +} + +interface GroupedDimensionRow extends CostDimensionRow { + groupedRows?: CostDimensionRow[]; +} + +function formatShare(share: number): string { + const percentage = Math.max(0, share) * 100; + return `${percentage.toLocaleString("en-US", { + minimumFractionDigits: percentage > 0 && percentage < 0.1 ? 2 : 1, + maximumFractionDigits: percentage > 0 && percentage < 0.1 ? 2 : 1, + })}%`; +} + +function formatExactTokens(tokens: number): string { + return `${NUMBER_FORMATTER.format(tokens)} tokens`; +} + +function sumProvenance(rows: CostDimensionRow[]): CostProvenance { + const totals = rows.reduce((sum, row) => ({ + state: sum.state, + invocationCount: sum.invocationCount + row.amount.provenance.invocationCount, + configuredPricingInvocationCount: sum.configuredPricingInvocationCount + + row.amount.provenance.configuredPricingInvocationCount, + providerReportedCostInvocationCount: sum.providerReportedCostInvocationCount + + row.amount.provenance.providerReportedCostInvocationCount, + unpricedInvocationCount: sum.unpricedInvocationCount + + row.amount.provenance.unpricedInvocationCount, + unknownInvocationCount: sum.unknownInvocationCount + + row.amount.provenance.unknownInvocationCount, + }), { + state: "unavailable", + invocationCount: 0, + configuredPricingInvocationCount: 0, + providerReportedCostInvocationCount: 0, + unpricedInvocationCount: 0, + unknownInvocationCount: 0, + }); + + const covered = totals.configuredPricingInvocationCount + + totals.providerReportedCostInvocationCount; + if (totals.unknownInvocationCount > 0) totals.state = "unknown"; + else if (rows.some((row) => row.amount.provenance.state === "partial")) totals.state = "partial"; + else if (covered > 0 && totals.unpricedInvocationCount > 0) totals.state = "partial"; + else if (covered === 0 && totals.unpricedInvocationCount > 0) totals.state = "unpriced"; + else if (totals.invocationCount > 0) totals.state = "complete"; + return totals; +} + +function groupDimensionRows(rows: CostDimensionRow[]): GroupedDimensionRow[] { + if (rows.length <= TOP_ROW_COUNT) return rows; + + const groupedRows = rows.slice(TOP_ROW_COUNT); + const provenance = sumProvenance(groupedRows); + const hasUnavailableAmount = groupedRows.some((row) => ( + row.amount.usd === null || row.amount.provenance.state === "unavailable" + )); + const usd = hasUnavailableAmount + ? null + : groupedRows.reduce((sum, row) => sum + (row.amount.usd ?? 0), 0); + const calls = groupedRows.reduce((sum, row) => sum + row.calls, 0); + const amount: CostAmount = { usd, provenance }; + const costPerCall: CostAmount = calls > 0 && usd !== null + ? { usd: usd / calls, provenance } + : { usd: null, provenance: { ...provenance, state: "unavailable" } }; + + return [ + ...rows.slice(0, TOP_ROW_COUNT), + { + id: "__other__", + label: "Other", + amount, + spendShare: groupedRows.reduce((sum, row) => sum + row.spendShare, 0), + tokenShare: groupedRows.reduce((sum, row) => sum + row.tokenShare, 0), + calls, + costPerCall, + tokens: groupedRows.reduce((sum, row) => sum + row.tokens, 0), + groupedRows, + }, + ]; +} + +function humanizePurpose(label: string): string { + const normalized = label.replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); + return normalized.length > 0 + ? `${normalized.charAt(0).toUpperCase()}${normalized.slice(1)}` + : "Unclassified purpose"; +} + +function coverageMessage(totalSpend: CostAmount, totalTokens: number): string { + const provenance = totalSpend.provenance; + if (provenance.invocationCount === 0 && totalTokens === 0) { + return "Empty window — no calls or token usage were recorded."; + } + if (provenance.state === "unpriced") { + return `Unpriced usage — ${NUMBER_FORMATTER.format(provenance.unpricedInvocationCount)} calls have usage telemetry but no usable price.`; + } + if (provenance.state === "partial") { + return `Partial cost coverage — ${NUMBER_FORMATTER.format(provenance.unpricedInvocationCount)} of ${NUMBER_FORMATTER.format(provenance.invocationCount)} calls remain unpriced; shown spend is a minimum.`; + } + if (provenance.state === "unknown") { + return `Coverage unknown — ${NUMBER_FORMATTER.format(provenance.unknownInvocationCount)} of ${NUMBER_FORMATTER.format(provenance.invocationCount)} calls lack cost-source metadata.`; + } + if (provenance.state === "unavailable") { + return "Spend unavailable — this window does not contain enough cost data to price usage."; + } + if (totalSpend.usd === 0) { + const configuredOnly = provenance.configuredPricingInvocationCount > 0 + && provenance.providerReportedCostInvocationCount === 0; + return configuredOnly + ? "Configured free usage — covered calls reconcile to $0.00 and are not unpriced." + : "Covered zero-cost usage — covered calls reconcile to $0.00 and are not unpriced."; + } + return `Complete cost coverage — all ${NUMBER_FORMATTER.format(provenance.invocationCount)} calls have a usable cost source.`; +} + +function coverageTone( + totalSpend: CostAmount, + totalTokens: number, +): keyof typeof STATUS_TONE_CLASS { + if (totalSpend.provenance.invocationCount === 0 && totalTokens === 0) return "neutral"; + if (totalSpend.provenance.state === "unpriced" || totalSpend.provenance.state === "partial") { + return "warning"; + } + if (totalSpend.provenance.state === "unknown") return "cyan"; + if (totalSpend.provenance.state === "unavailable") return "neutral"; + return totalSpend.usd === 0 ? "positive" : "signal"; +} + +function AllocationHeading({ id, eyebrow, title, description }: { + id: string; + eyebrow: string; + title: string; + description: string; +}): JSX.Element { + return ( +
+
{eyebrow}
+

{title}

+

{description}

+
+ ); +} + +const TokenAllocation: FunctionComponent<{ + totalTokens: number; + segments: CostTokenSegment[]; +}> = ({ totalTokens, segments }) => { + const byId = new Map(segments.map((segment) => [segment.id, segment])); + const getTokens = (id: CostTokenSegment["id"]): number => byId.get(id)?.tokens ?? 0; + + return ( +
+ +
+ Total token volume + {formatExactTokens(totalTokens)} +
+ + {totalTokens === 0 ? ( +

Zero total tokens — no token allocation is available.

+ ) : ( +

+ {segments[0]?.label ?? "Token lanes"} accounts for {formatShare(segments[0]?.share ?? 0)} of recorded volume. +

+ )} +
    + {segments.map((segment) => ( +
  • +
  • + ))} +
+
+ ); +}; + +const SpendAllocation: FunctionComponent<{ + totalSpend: CostAmount; + totalTokens: number; + segments: CostSpendSegment[]; +}> = ({ totalSpend, totalTokens, segments }) => { + const hasVisualSpend = totalSpend.usd !== null && totalSpend.usd > 0; + const leadingSegment = segments.reduce((leader, segment) => ( + leader === null || segment.share > leader.share ? segment : leader + ), null); + const chartLabel = `Spend allocation. ${segments.map((segment) => ( + `${segment.label}: ${formatAdaptiveCurrency(segment.amount)}, ${formatShare(segment.share)}` + )).join("; ")}. Total: ${formatAdaptiveCurrency(totalSpend)}.${hasVisualSpend ? "" : " No positive spend lanes."}`; + + return ( +
+ +
+ Total recorded spend + {formatAdaptiveCurrency(totalSpend)} +
+
+ {hasVisualSpend ? segments.map((segment) => ( + segment.share > 0 ? ( +
+

+ {hasVisualSpend && leadingSegment + ? `${leadingSegment.label} accounts for ${formatShare(leadingSegment.share)} of recorded spend.` + : totalTokens > 0 + ? "No positive priced spend lanes are available; consult the coverage state above." + : "No spend allocation is available for this empty window."} +

+
    + {segments.map((segment) => ( +
  • +
  • + ))} +
+
+ ); +}; + +const DimensionMetrics: FunctionComponent<{ row: CostDimensionRow }> = ({ row }) => ( +
+
Total cost
{formatAdaptiveCurrency(row.amount)}
+
Spend share
{formatShare(row.spendShare)}
+
Tokens
{NUMBER_FORMATTER.format(row.tokens)}
+
Token share
{formatShare(row.tokenShare)}
+
Calls
{NUMBER_FORMATTER.format(row.calls)}
+
Cost / call
{formatAdaptiveCurrency(row.costPerCall)}
+
+); + +function modelIdentity(row: CostModelRow): string { + return `${row.provider} · ${row.model ?? "Model not reported"}`; +} + +const DimensionRow: FunctionComponent<{ + row: GroupedDimensionRow; + rank: number; + kind: "model" | "purpose"; +}> = ({ row, rank, kind }) => { + const model = kind === "model" && row.id !== "__other__" ? row as CostModelRow : null; + const title = row.id === "__other__" + ? `Other (${row.groupedRows?.length ?? 0})` + : kind === "purpose" ? humanizePurpose(row.label) : model?.model ?? "Model not reported"; + const identity = model ? modelIdentity(model) : null; + const content = ( + <> +
+ +
+

{title}

+ {identity ?

{identity}

: null} +
+
+ + + ); + + if (!row.groupedRows) { + return ( +
  • + {content} +
  • + ); + } + + return ( +
  • + {content} +
  • + ); +}; + +const DimensionBreakdown: FunctionComponent<{ + title: string; + description: string; + rows: CostDimensionRow[]; + kind: "model" | "purpose"; +}> = ({ title, description, rows, kind }) => { + const groupedRows = groupDimensionRows(rows); + const titleId = `cost-${kind}-breakdown-title`; + + return ( +
    + + {rows.length === 0 ? ( +
    + No {kind} cost allocation is available for this window. +
    + ) : ( +
      + {groupedRows.map((row, index) => ( + + ))} +
    + )} +
    + ); +}; + +export const CostAllocationPanels: FunctionComponent = ({ + totalSpend, + totalTokens, + tokenSegments, + spendSegments, + models, + purposes, +}) => ( +
    +
    + {coverageMessage(totalSpend, totalTokens)} +
    +
    + + +
    +
    + + +
    +
    +); diff --git a/docs-web/content/docs/user-dashboard-stats.mdx b/docs-web/content/docs/user-dashboard-stats.mdx index c3cef89193..e6f0dcb4b9 100644 --- a/docs-web/content/docs/user-dashboard-stats.mdx +++ b/docs-web/content/docs/user-dashboard-stats.mdx @@ -77,6 +77,8 @@ Cost data is visualized directly within the Usage Graph and Composition views, f - Cost coverage distinguishes configured-pricing calls, provider-reported fallback calls, and unpriced calls. A zero-dollar covered call is different from an invocation that has no usable pricing source, and an empty window reports zero calls in every category. - Current pricing settings recalculate historical Stats projections when the snapshot is requested; changing pricing does not rewrite stored invocation telemetry. - Cost analytics group multiple runs of the same conceptual sprint into one canonical sprint row. The existing Sprint Telemetry ledger remains run-oriented. +- Cost allocation separates input, cached input, output, reasoning, and provider-reported fallback lanes with exact values and percentages. Ranked model and execution-purpose rows include spend, tokens, shares, calls, and cost per call; dense collections keep six leaders visible and reconcile the remainder in one bounded `Other` group. +- Allocation patterns and text labels duplicate color meaning, rows support keyboard scanning and long provider/model names, and empty, unpriced, partial, covered zero-dollar, and unknown-coverage windows remain visibly different. - Cost totals, per-call and per-token rates, task/sprint averages, breakdowns, and detail rows share one deterministic calculation model. Invalid negative or non-finite telemetry is treated as zero, breakdowns reconcile to the normalized totals, and equal rankings use stable labels and identities. - Small proven costs retain sub-cent precision. Fully covered zero-price usage displays as `$0.00`; partially priced, unpriced, legacy unknown-coverage, and empty telemetry use distinct states so missing pricing is never presented as free usage. - The Cost ledger switches between task and canonical-sprint views, searches status and context, and sorts in either direction by spend, tokens, calls, cost per call, recency, or name. Each row includes spend share, token mix, status, activity, and pricing provenance; large result sets load progressively while totals use every filtered match and the comparison average stays anchored to the complete task or canonical-sprint collection. diff --git a/docs-web/user/dashboard/stats.md b/docs-web/user/dashboard/stats.md index fda1b1425d..7a2cc1b38e 100644 --- a/docs-web/user/dashboard/stats.md +++ b/docs-web/user/dashboard/stats.md @@ -77,6 +77,8 @@ Cost data is visualized directly within the Usage Graph and Composition views, f - Cost coverage distinguishes configured-pricing calls, provider-reported fallback calls, and unpriced calls. A zero-dollar covered call is different from an invocation that has no usable pricing source, and an empty window reports zero calls in every category. - Current pricing settings recalculate historical Stats projections when the snapshot is requested; changing pricing does not rewrite stored invocation telemetry. - Cost analytics group multiple runs of the same conceptual sprint into one canonical sprint row. The existing Sprint Telemetry ledger remains run-oriented. +- Cost allocation separates input, cached input, output, reasoning, and provider-reported fallback lanes with exact values and percentages. Ranked model and execution-purpose rows include spend, tokens, shares, calls, and cost per call; dense collections keep six leaders visible and reconcile the remainder in one bounded `Other` group. +- Allocation patterns and text labels duplicate color meaning, rows support keyboard scanning and long provider/model names, and empty, unpriced, partial, covered zero-dollar, and unknown-coverage windows remain visibly different. - Cost totals, per-call and per-token rates, task/sprint averages, breakdowns, and detail rows share one deterministic calculation model. Invalid negative or non-finite telemetry is treated as zero, breakdowns reconcile to the normalized totals, and equal rankings use stable labels and identities. - Small proven costs retain sub-cent precision. Fully covered zero-price usage displays as `$0.00`; partially priced, unpriced, legacy unknown-coverage, and empty telemetry use distinct states so missing pricing is never presented as free usage. - The Cost overview pairs total spend, task/sprint averages, per-call and per-million-token rates with pricing-coverage provenance. Its spend-over-time chart exposes every bucket through keyboard focus, exact focused detail, and a screen-reader data table; peak and selected buckets also use non-color markers. diff --git a/docs/dashboard/design-system-stats.md b/docs/dashboard/design-system-stats.md index 69069b1c58..a8abb3ebff 100644 --- a/docs/dashboard/design-system-stats.md +++ b/docs/dashboard/design-system-stats.md @@ -191,6 +191,7 @@ The `StatsPage` uses the `useStatsPageData` hook to coordinate visual modes. The `cost-insights.ts` is the pure frontend boundary for Cost calculations and display state. Cost components consume its normalized totals, rates, averages, reconciled spend/token segments, deterministic dimension rows, and task/canonical-sprint details instead of re-deriving values in JSX. Every monetary amount carries coverage provenance so complete zero-price usage, partial pricing, unpriced telemetry, legacy unknown coverage, and empty data remain distinct. +Cost allocation panels pair separate token and spend graphics with exact textual legends. Token lanes cover input, cached input, output, and reasoning; spend lanes keep token-priced input, cached input, output, and provider-reported fallback spend distinct. Ranked model and execution-purpose rows show spend, tokens, calls, both shares, and cost per call. The first six rows remain visible and all additional rows reconcile into one bounded `Other` summary so source collection size does not expand the rendered ledger. Patterns, labels, focusable rows, and full provider/model identities make the breakdown usable without relying on color or pointer input. `CostOverviewPanel` renders the Cost executive metrics and spend-over-time surface directly from that view model. Its SVG is presentation-only: every bucket is also a keyboard-focusable value with exact focused detail and a screen-reader table, while peak, selection, and missing-price states use shape or text cues in addition to color. The panel keeps no-usage, fully unpriced, partial, covered-zero, and single-bucket states explicit and page-overflow safe. Task and canonical sprint averages use distinct rows that contain provider invocations. Covered zero-cost rows remain in the denominator, while an empty collection produces an unavailable amount. Canonical sprint rows come from `costAnalytics.sprints`; legacy snapshots fall back to the run-oriented `sprints` ledger only when the additive projection is absent.