@oh-my-pi/omp-stats 18.3.4 → 18.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
|
@@ -1,252 +1,424 @@
|
|
|
1
|
-
import
|
|
1
|
+
import "./costs.css";
|
|
2
2
|
import { useMemo, useState } from "react";
|
|
3
|
-
import { Bar, Line } from "react-chartjs-2";
|
|
4
3
|
import { getCostDashboardStats } from "../api";
|
|
4
|
+
import { Chart, type ChartSeries, Legend, ShareBar, useHiddenSeries } from "../charts";
|
|
5
|
+
import { buildModelColorLookup, modelKey, OTHER_COLOR, SERIES_COLORS } from "../data/colors";
|
|
6
|
+
import { formatCost, formatEstimatedCost, formatInteger, formatPercent } from "../data/formatters";
|
|
7
|
+
import { useQuery } from "../data/query";
|
|
8
|
+
import { bucketAxis, rangeMeta } from "../data/range";
|
|
9
|
+
import { densify, pivotSeries } from "../data/series";
|
|
10
|
+
import { buildCostSummary, type CostComponents, type CostModelRow, type CostSummaryView } from "../data/view-models";
|
|
11
|
+
import type { CostDashboardStats, TimeRange } from "../types";
|
|
5
12
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
Card,
|
|
14
|
+
ChartSkeleton,
|
|
15
|
+
type Column,
|
|
16
|
+
EmptyState,
|
|
17
|
+
LabelCell,
|
|
18
|
+
MeterCell,
|
|
19
|
+
PageHeader,
|
|
20
|
+
QueryView,
|
|
21
|
+
Segmented,
|
|
22
|
+
Skeleton,
|
|
23
|
+
Stat,
|
|
24
|
+
StatGrid,
|
|
25
|
+
Swatch,
|
|
26
|
+
Table,
|
|
27
|
+
TableSkeleton,
|
|
28
|
+
} from "../ui";
|
|
22
29
|
|
|
23
30
|
export interface CostsRouteProps {
|
|
24
31
|
active: boolean;
|
|
25
32
|
range: TimeRange;
|
|
26
|
-
refreshTrigger: number;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
|
|
30
|
-
const {
|
|
31
|
-
data: costStats,
|
|
32
|
-
error,
|
|
33
|
-
loading,
|
|
34
|
-
} = useResource(["costs", range, refreshTrigger], signal => getCostDashboardStats(range, signal), {
|
|
35
|
-
pollMs: 30000,
|
|
36
|
-
enabled: active,
|
|
37
|
-
});
|
|
35
|
+
type SplitMode = "model" | "component";
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
<>
|
|
44
|
-
<CostOverviewPanel costSeries={costStats.costSeries} />
|
|
45
|
-
<CostTrendPanel costSeries={costStats.costSeries} />
|
|
46
|
-
</>
|
|
47
|
-
)}
|
|
48
|
-
</AsyncBoundary>
|
|
49
|
-
</div>
|
|
50
|
-
);
|
|
51
|
-
}
|
|
37
|
+
const SPLIT_OPTIONS = [
|
|
38
|
+
{ value: "model" as const, label: "By model" },
|
|
39
|
+
{ value: "component" as const, label: "By component" },
|
|
40
|
+
];
|
|
52
41
|
|
|
53
|
-
|
|
54
|
-
|
|
42
|
+
/** Models stacked individually in the daily chart; the rest fold into "Other". */
|
|
43
|
+
const MODEL_LIMIT = 6;
|
|
55
44
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
45
|
+
/** The cost series is bucketed by UTC day on the server. */
|
|
46
|
+
const DAY_MS = 86_400_000;
|
|
47
|
+
|
|
48
|
+
const COMPONENTS = [
|
|
49
|
+
{ key: "costInput", label: "Input", color: "#5b8cff" },
|
|
50
|
+
{ key: "costOutput", label: "Output", color: "var(--chart-secondary)" },
|
|
51
|
+
{ key: "costCacheRead", label: "Cache read", color: "var(--chart-primary)" },
|
|
52
|
+
{ key: "costCacheWrite", label: "Cache write", color: "#f5b54a" },
|
|
53
|
+
] as const satisfies readonly { key: keyof CostComponents; label: string; color: string }[];
|
|
54
|
+
|
|
55
|
+
const UTC_DAY = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", timeZone: "UTC" });
|
|
56
|
+
const UTC_DAY_LONG = new Intl.DateTimeFormat(undefined, {
|
|
57
|
+
weekday: "short",
|
|
58
|
+
month: "short",
|
|
59
|
+
day: "numeric",
|
|
60
|
+
year: "numeric",
|
|
61
|
+
timeZone: "UTC",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export function CostsRoute({ active, range }: CostsRouteProps) {
|
|
65
|
+
const query = useQuery(["costs", range], () => getCostDashboardStats(range), { enabled: active });
|
|
66
|
+
const meta = rangeMeta(range);
|
|
67
|
+
const [split, setSplit] = useState<SplitMode>("model");
|
|
68
|
+
const [hidden, toggleSeries] = useHiddenSeries();
|
|
69
|
+
const view = useMemo(() => (query.data ? buildCostsView(query.data, range) : null), [query.data, range]);
|
|
70
|
+
const isEmpty = (data: CostDashboardStats) => data.costSeries.length === 0;
|
|
71
|
+
const empty = <EmptyState title="No usage in this range" hint="Pick a wider range in the top bar." />;
|
|
75
72
|
|
|
76
73
|
return (
|
|
77
|
-
<div className="
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
74
|
+
<div className="page">
|
|
75
|
+
<PageHeader
|
|
76
|
+
title="Costs"
|
|
77
|
+
description={`What ${meta.windowLabel} of usage would cost at public API rates. Subscription usage without a public price is counted as unpriced, not free.`}
|
|
78
|
+
/>
|
|
79
|
+
|
|
80
|
+
<QueryView
|
|
81
|
+
query={query}
|
|
82
|
+
skeleton={<Skeleton height={112} style={{ borderRadius: 12 }} />}
|
|
83
|
+
isEmpty={isEmpty}
|
|
84
|
+
empty={empty}
|
|
85
|
+
>
|
|
86
|
+
{() => view && <CostStats view={view} stale={query.stale} />}
|
|
87
|
+
</QueryView>
|
|
88
|
+
|
|
89
|
+
<div className="grid grid-main-side">
|
|
90
|
+
<Card
|
|
91
|
+
index={1}
|
|
92
|
+
title="Daily estimate"
|
|
93
|
+
description={
|
|
94
|
+
split === "model" ? "Per UTC day, stacked by model" : "Per UTC day, stacked by billing component"
|
|
95
|
+
}
|
|
96
|
+
actions={
|
|
97
|
+
<Segmented
|
|
98
|
+
size="sm"
|
|
99
|
+
options={SPLIT_OPTIONS}
|
|
100
|
+
value={split}
|
|
101
|
+
onChange={setSplit}
|
|
102
|
+
aria-label="Split by"
|
|
103
|
+
/>
|
|
104
|
+
}
|
|
105
|
+
stale={query.stale}
|
|
106
|
+
>
|
|
107
|
+
<QueryView query={query} skeleton={<ChartSkeleton height={280} />} isEmpty={isEmpty} empty={empty}>
|
|
108
|
+
{() => {
|
|
109
|
+
if (!view) return null;
|
|
110
|
+
const series = split === "model" ? view.modelSeries : view.componentSeries;
|
|
111
|
+
return (
|
|
112
|
+
<div className="stack" style={{ gap: 12 }}>
|
|
113
|
+
<Chart
|
|
114
|
+
slots={view.buckets.length}
|
|
115
|
+
tickLabel={i => UTC_DAY.format(view.buckets[i])}
|
|
116
|
+
tooltipTitle={i => UTC_DAY_LONG.format(view.buckets[i])}
|
|
117
|
+
series={series}
|
|
118
|
+
hidden={hidden}
|
|
119
|
+
height={280}
|
|
120
|
+
format={v => formatCost(v, Number.isInteger(v) ? 0 : 2)}
|
|
121
|
+
formatTooltip={v => formatCost(v)}
|
|
122
|
+
emptyLabel="No priced usage in this range"
|
|
123
|
+
tooltipExtra={i =>
|
|
124
|
+
view.unpricedPerDay[i] > 0 ? (
|
|
125
|
+
<div className="chart-tooltip-row chart-tooltip-total">
|
|
126
|
+
<span className="chart-tooltip-label">Unpriced requests</span>
|
|
127
|
+
<span className="chart-tooltip-value">
|
|
128
|
+
{formatInteger(view.unpricedPerDay[i])}
|
|
129
|
+
</span>
|
|
130
|
+
</div>
|
|
131
|
+
) : null
|
|
132
|
+
}
|
|
133
|
+
/>
|
|
134
|
+
<Legend
|
|
135
|
+
items={series.map(s => ({
|
|
136
|
+
key: s.key,
|
|
137
|
+
label: s.label,
|
|
138
|
+
color: s.color,
|
|
139
|
+
value: formatCost(sum(s.values)),
|
|
140
|
+
}))}
|
|
141
|
+
hidden={hidden}
|
|
142
|
+
onToggle={toggleSeries}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}}
|
|
147
|
+
</QueryView>
|
|
148
|
+
</Card>
|
|
149
|
+
|
|
150
|
+
<Card index={2} title="Where it went" description="Estimate by billing component" stale={query.stale}>
|
|
151
|
+
<QueryView query={query} skeleton={<ChartSkeleton height={280} />} isEmpty={isEmpty} empty={empty}>
|
|
152
|
+
{() => view && <ComponentBreakdown summary={view.summary} />}
|
|
153
|
+
</QueryView>
|
|
154
|
+
</Card>
|
|
155
|
+
</div>
|
|
156
|
+
|
|
157
|
+
<Card
|
|
158
|
+
index={3}
|
|
159
|
+
title="By model"
|
|
160
|
+
description="Estimate per model with its input, output and cache split"
|
|
161
|
+
stale={query.stale}
|
|
162
|
+
flush
|
|
163
|
+
>
|
|
164
|
+
<QueryView query={query} skeleton={<TableSkeleton rows={8} />} isEmpty={isEmpty} empty={empty}>
|
|
165
|
+
{() =>
|
|
166
|
+
view && (
|
|
167
|
+
<Table
|
|
168
|
+
rows={view.summary.models}
|
|
169
|
+
rowKey={row => row.key}
|
|
170
|
+
columns={buildCostColumns(view)}
|
|
171
|
+
initialSort={{ key: "cost", dir: "desc" }}
|
|
172
|
+
limit={20}
|
|
173
|
+
/>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
</QueryView>
|
|
177
|
+
</Card>
|
|
87
178
|
</div>
|
|
88
179
|
);
|
|
89
180
|
}
|
|
90
181
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
182
|
+
interface CostsView {
|
|
183
|
+
summary: CostSummaryView;
|
|
184
|
+
/** Chart color of each individually plotted model; the rest share "Other" gray. */
|
|
185
|
+
swatches: Map<string, string>;
|
|
186
|
+
buckets: number[];
|
|
187
|
+
dailyTotals: number[];
|
|
188
|
+
unpricedPerDay: number[];
|
|
189
|
+
modelSeries: ChartSeries[];
|
|
190
|
+
componentSeries: ChartSeries[];
|
|
191
|
+
maxModelCost: number;
|
|
192
|
+
}
|
|
95
193
|
|
|
96
|
-
|
|
97
|
-
|
|
194
|
+
function buildCostsView(data: CostDashboardStats, range: TimeRange): CostsView {
|
|
195
|
+
const points = data.costSeries;
|
|
196
|
+
const summary = buildCostSummary(points);
|
|
197
|
+
const colors = buildModelColorLookup(
|
|
198
|
+
summary.models.map(m => ({ model: m.model, provider: m.provider, totalRequests: m.requests })),
|
|
199
|
+
);
|
|
200
|
+
const byKey = new Map(summary.models.map(m => [m.key, m]));
|
|
201
|
+
const buckets = bucketAxis(
|
|
202
|
+
range,
|
|
203
|
+
points.map(p => p.timestamp),
|
|
204
|
+
DAY_MS,
|
|
205
|
+
);
|
|
206
|
+
// Zero slots become gaps so the tooltip lists only what was spent that day.
|
|
207
|
+
const modelSeries = pivotSeries(points, {
|
|
208
|
+
buckets,
|
|
209
|
+
key: p => modelKey(p.model, p.provider),
|
|
210
|
+
label: key => byKey.get(key)?.model ?? key,
|
|
211
|
+
value: p => p.cost,
|
|
212
|
+
limit: MODEL_LIMIT,
|
|
213
|
+
colors,
|
|
214
|
+
}).map(s => ({ ...s, values: s.values.map(v => v || null) }));
|
|
215
|
+
// Colors follow request rank (shared with the Models page); the top models by
|
|
216
|
+
// cost can include ranks that wrap onto the same hue, so recolor repeats.
|
|
217
|
+
const used = new Set<string>();
|
|
218
|
+
for (const s of modelSeries) {
|
|
219
|
+
if (s.key === "__other__") continue;
|
|
220
|
+
if (used.has(s.color)) s.color = SERIES_COLORS.find(c => !used.has(c)) ?? s.color;
|
|
221
|
+
used.add(s.color);
|
|
222
|
+
}
|
|
98
223
|
return {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
|
|
113
|
-
if (!value) continue;
|
|
114
|
-
const label = `$${Math.round(value)}`;
|
|
115
|
-
// Accessing internal getProps for positioning via unknown cast
|
|
116
|
-
const { x, y } = bar.getProps(["x", "y"], true) as {
|
|
117
|
-
x: number;
|
|
118
|
-
y: number;
|
|
119
|
-
};
|
|
120
|
-
ctx.fillText(label, x, y - 3);
|
|
121
|
-
}
|
|
122
|
-
ctx.restore();
|
|
123
|
-
},
|
|
224
|
+
summary,
|
|
225
|
+
swatches: new Map(modelSeries.filter(s => s.key !== "__other__").map(s => [s.key, s.color])),
|
|
226
|
+
buckets,
|
|
227
|
+
dailyTotals: densify(points, buckets, p => p.cost),
|
|
228
|
+
unpricedPerDay: densify(points, buckets, p => p.unpricedRequests),
|
|
229
|
+
modelSeries,
|
|
230
|
+
componentSeries: COMPONENTS.map(c => ({
|
|
231
|
+
key: c.key,
|
|
232
|
+
label: c.label,
|
|
233
|
+
color: c.color,
|
|
234
|
+
values: densify(points, buckets, p => p[c.key]).map(v => v || null),
|
|
235
|
+
})),
|
|
236
|
+
maxModelCost: summary.models[0]?.cost ?? 0,
|
|
124
237
|
};
|
|
125
238
|
}
|
|
126
239
|
|
|
127
|
-
function
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
240
|
+
function CostStats({ view, stale }: { view: CostsView; stale: boolean }) {
|
|
241
|
+
const { summary } = view;
|
|
242
|
+
const pricedRequests = summary.requests - summary.unpricedRequests;
|
|
243
|
+
const top = summary.topModel;
|
|
244
|
+
return (
|
|
245
|
+
<div data-stale={stale}>
|
|
246
|
+
<StatGrid min={180}>
|
|
247
|
+
<Stat
|
|
248
|
+
label="API-equivalent estimate"
|
|
249
|
+
title="What this usage would cost at public API rates"
|
|
250
|
+
value={formatEstimatedCost(summary.totalCost, summary.unpricedRequests)}
|
|
251
|
+
hint={`${formatInteger(summary.requests)} requests`}
|
|
252
|
+
spark={view.dailyTotals}
|
|
253
|
+
sparkColor="var(--chart-secondary)"
|
|
254
|
+
/>
|
|
255
|
+
<Stat
|
|
256
|
+
label="Average per day"
|
|
257
|
+
title="Estimate ÷ days with any usage"
|
|
258
|
+
value={formatEstimatedCost(summary.avgDailyCost, summary.unpricedRequests)}
|
|
259
|
+
hint={`over ${formatInteger(summary.activeDays)} active day${summary.activeDays === 1 ? "" : "s"}`}
|
|
260
|
+
/>
|
|
261
|
+
<Stat
|
|
262
|
+
label="Top model"
|
|
263
|
+
title={top ? `${top.model} (${top.provider})` : undefined}
|
|
264
|
+
value={top ? top.model : "–"}
|
|
265
|
+
hint={top ? `${formatCost(top.cost)} · ${formatPercent(top.share)} of estimate` : "Nothing priced yet"}
|
|
266
|
+
/>
|
|
267
|
+
<Stat
|
|
268
|
+
label="Per priced request"
|
|
269
|
+
title="Estimate ÷ requests that have a public price"
|
|
270
|
+
value={pricedRequests > 0 ? formatUnitCost(summary.totalCost / pricedRequests) : "–"}
|
|
271
|
+
hint={`${formatInteger(pricedRequests)} priced`}
|
|
272
|
+
/>
|
|
273
|
+
<Stat
|
|
274
|
+
label="Unpriced requests"
|
|
275
|
+
title="Subscription usage with no public-equivalent price; excluded from the estimate"
|
|
276
|
+
value={formatInteger(summary.unpricedRequests)}
|
|
277
|
+
hint={summary.unpricedRequests > 0 ? "excluded from the estimate" : "all usage priced"}
|
|
278
|
+
/>
|
|
279
|
+
</StatGrid>
|
|
280
|
+
</div>
|
|
134
281
|
);
|
|
282
|
+
}
|
|
135
283
|
|
|
136
|
-
|
|
137
|
-
if (byModel) {
|
|
138
|
-
return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(costSeries, {
|
|
139
|
-
rankWeight: point => point.cost,
|
|
140
|
-
initBucket: () => ({ total: 0 }),
|
|
141
|
-
accumulate: (bucket, point) => {
|
|
142
|
-
bucket.total += point.cost;
|
|
143
|
-
},
|
|
144
|
-
bucketToValue: bucket => bucket.total,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(costSeries, "API-equivalent estimate", {
|
|
148
|
-
initBucket: () => ({ total: 0 }),
|
|
149
|
-
accumulate: (bucket, point) => {
|
|
150
|
-
bucket.total += point.cost;
|
|
151
|
-
},
|
|
152
|
-
bucketToValue: bucket => bucket.total,
|
|
153
|
-
});
|
|
154
|
-
}, [costSeries, byModel]);
|
|
155
|
-
|
|
156
|
-
const sharedPlugins = useMemo(() => {
|
|
157
|
-
return buildSharedPlugins({
|
|
158
|
-
chartTheme,
|
|
159
|
-
showLegend: byModel,
|
|
160
|
-
defaultLabel: "API-equivalent estimate",
|
|
161
|
-
formatValue: v => `$${v.toFixed(2)}`,
|
|
162
|
-
footer: items => {
|
|
163
|
-
if (!byModel || items.length < 2) return undefined;
|
|
164
|
-
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
165
|
-
return `Total: $${total.toFixed(2)}`;
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
}, [chartTheme, byModel]);
|
|
169
|
-
|
|
170
|
-
const { sharedScaleBase, yScale } = useMemo(() => {
|
|
171
|
-
return buildSharedScales({
|
|
172
|
-
chartTheme,
|
|
173
|
-
formatY: v => `$${Math.round(v)}`,
|
|
174
|
-
});
|
|
175
|
-
}, [chartTheme]);
|
|
176
|
-
|
|
177
|
-
const barLabelPlugin = useMemo(() => {
|
|
178
|
-
return makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
|
|
179
|
-
}, [theme]);
|
|
180
|
-
|
|
181
|
-
const lineData = useMemo(() => {
|
|
182
|
-
if (!byModel) return null;
|
|
183
|
-
return {
|
|
184
|
-
labels: chartData.labels,
|
|
185
|
-
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
186
|
-
};
|
|
187
|
-
}, [chartData, byModel]);
|
|
188
|
-
|
|
189
|
-
const lineOptions = useMemo(() => {
|
|
190
|
-
return {
|
|
191
|
-
responsive: true,
|
|
192
|
-
maintainAspectRatio: false,
|
|
193
|
-
interaction: { mode: "index" as const, intersect: false },
|
|
194
|
-
plugins: sharedPlugins,
|
|
195
|
-
scales: { x: sharedScaleBase, y: yScale },
|
|
196
|
-
};
|
|
197
|
-
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
198
|
-
|
|
199
|
-
const barData = useMemo(() => {
|
|
200
|
-
if (byModel) return null;
|
|
201
|
-
return {
|
|
202
|
-
labels: chartData.labels,
|
|
203
|
-
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
204
|
-
};
|
|
205
|
-
}, [chartData, byModel]);
|
|
206
|
-
|
|
207
|
-
const barOptions = useMemo(() => {
|
|
208
|
-
return {
|
|
209
|
-
responsive: true,
|
|
210
|
-
maintainAspectRatio: false,
|
|
211
|
-
interaction: { mode: "index" as const, intersect: false },
|
|
212
|
-
plugins: {
|
|
213
|
-
...sharedPlugins,
|
|
214
|
-
costBarLabels: {},
|
|
215
|
-
},
|
|
216
|
-
scales: {
|
|
217
|
-
x: { ...sharedScaleBase, stacked: true },
|
|
218
|
-
y: { ...yScale, stacked: true },
|
|
219
|
-
},
|
|
220
|
-
layout: { padding: { top: 24 } },
|
|
221
|
-
};
|
|
222
|
-
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
223
|
-
|
|
224
|
-
const toggleOptions = [
|
|
225
|
-
{ value: false, label: "All Models" },
|
|
226
|
-
{ value: true, label: "By Model" },
|
|
227
|
-
];
|
|
228
|
-
|
|
284
|
+
function ComponentBreakdown({ summary }: { summary: CostSummaryView }) {
|
|
229
285
|
return (
|
|
230
|
-
<
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
286
|
+
<div className="stack" style={{ gap: 14 }}>
|
|
287
|
+
<ShareBar
|
|
288
|
+
height={10}
|
|
289
|
+
segments={COMPONENTS.map(c => ({ key: c.key, label: c.label, value: summary[c.key], color: c.color }))}
|
|
290
|
+
/>
|
|
291
|
+
<div className="costs-components">
|
|
292
|
+
{COMPONENTS.map(c => (
|
|
293
|
+
<div key={c.key} className="costs-component-row">
|
|
294
|
+
<span className="row">
|
|
295
|
+
<Swatch color={c.color} />
|
|
296
|
+
{c.label}
|
|
297
|
+
</span>
|
|
298
|
+
<span className="num">{formatCost(summary[c.key])}</span>
|
|
299
|
+
<span className="num dim costs-component-share">
|
|
300
|
+
{summary.totalCost > 0 ? formatPercent(summary[c.key] / summary.totalCost) : "–"}
|
|
301
|
+
</span>
|
|
243
302
|
</div>
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
<
|
|
248
|
-
|
|
303
|
+
))}
|
|
304
|
+
<div className="costs-component-row costs-component-total">
|
|
305
|
+
<span>Total</span>
|
|
306
|
+
<span className="num">{formatEstimatedCost(summary.totalCost, summary.unpricedRequests)}</span>
|
|
307
|
+
<span className="costs-component-share" />
|
|
308
|
+
</div>
|
|
249
309
|
</div>
|
|
250
|
-
|
|
310
|
+
{summary.unpricedRequests > 0 && (
|
|
311
|
+
<p className="micro dim">
|
|
312
|
+
{formatInteger(summary.unpricedRequests)} unpriced subscription request
|
|
313
|
+
{summary.unpricedRequests === 1 ? " is" : "s are"} not included.
|
|
314
|
+
</p>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
251
317
|
);
|
|
252
318
|
}
|
|
319
|
+
|
|
320
|
+
function buildCostColumns(view: CostsView): Column<CostModelRow>[] {
|
|
321
|
+
const componentColumns: Column<CostModelRow>[] = COMPONENTS.map(c => ({
|
|
322
|
+
key: c.key,
|
|
323
|
+
header: c.label,
|
|
324
|
+
align: "right",
|
|
325
|
+
sort: row => row[c.key],
|
|
326
|
+
render: row => <span className="num">{formatCost(row[c.key])}</span>,
|
|
327
|
+
}));
|
|
328
|
+
return [
|
|
329
|
+
{
|
|
330
|
+
key: "model",
|
|
331
|
+
header: "Model",
|
|
332
|
+
sort: row => row.model,
|
|
333
|
+
render: row => (
|
|
334
|
+
<LabelCell
|
|
335
|
+
lead={<Swatch color={view.swatches.get(row.key) ?? OTHER_COLOR} />}
|
|
336
|
+
primary={<span className="mono">{row.model}</span>}
|
|
337
|
+
secondary={row.provider}
|
|
338
|
+
/>
|
|
339
|
+
),
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
key: "requests",
|
|
343
|
+
header: "Requests",
|
|
344
|
+
align: "right",
|
|
345
|
+
sort: row => row.requests,
|
|
346
|
+
render: row => <span className="num">{formatInteger(row.requests)}</span>,
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
key: "cost",
|
|
350
|
+
header: "Estimate",
|
|
351
|
+
title: "API-equivalent estimate at public rates",
|
|
352
|
+
align: "right",
|
|
353
|
+
sort: row => row.cost,
|
|
354
|
+
render: row => (
|
|
355
|
+
<MeterCell
|
|
356
|
+
value={row.cost}
|
|
357
|
+
max={view.maxModelCost}
|
|
358
|
+
display={formatEstimatedCost(row.cost, row.unpricedRequests)}
|
|
359
|
+
color={view.swatches.get(row.key) ?? OTHER_COLOR}
|
|
360
|
+
/>
|
|
361
|
+
),
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: "share",
|
|
365
|
+
header: "Share",
|
|
366
|
+
align: "right",
|
|
367
|
+
sort: row => row.share,
|
|
368
|
+
render: row => <span className="num muted">{row.cost > 0 ? formatPercent(row.share) : "–"}</span>,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
key: "split",
|
|
372
|
+
header: "Split",
|
|
373
|
+
title: "Input · output · cache read · cache write",
|
|
374
|
+
width: 150,
|
|
375
|
+
render: row =>
|
|
376
|
+
row.cost > 0 ? (
|
|
377
|
+
<ShareBar
|
|
378
|
+
segments={COMPONENTS.map(c => ({ key: c.key, label: c.label, value: row[c.key], color: c.color }))}
|
|
379
|
+
/>
|
|
380
|
+
) : (
|
|
381
|
+
<span className="dim">–</span>
|
|
382
|
+
),
|
|
383
|
+
},
|
|
384
|
+
...componentColumns,
|
|
385
|
+
{
|
|
386
|
+
key: "perRequest",
|
|
387
|
+
header: "Per request",
|
|
388
|
+
title: "Estimate ÷ priced requests",
|
|
389
|
+
align: "right",
|
|
390
|
+
sort: row => (row.requests > row.unpricedRequests ? row.cost / (row.requests - row.unpricedRequests) : -1),
|
|
391
|
+
render: row => (
|
|
392
|
+
<span className="num">
|
|
393
|
+
{row.requests > row.unpricedRequests
|
|
394
|
+
? formatUnitCost(row.cost / (row.requests - row.unpricedRequests))
|
|
395
|
+
: "–"}
|
|
396
|
+
</span>
|
|
397
|
+
),
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
key: "unpriced",
|
|
401
|
+
header: "Unpriced",
|
|
402
|
+
title: "Requests with no public-equivalent price",
|
|
403
|
+
align: "right",
|
|
404
|
+
sort: row => row.unpricedRequests,
|
|
405
|
+
render: row =>
|
|
406
|
+
row.unpricedRequests > 0 ? (
|
|
407
|
+
<span className="num tone-warn">{formatInteger(row.unpricedRequests)}</span>
|
|
408
|
+
) : (
|
|
409
|
+
<span className="num dim">–</span>
|
|
410
|
+
),
|
|
411
|
+
},
|
|
412
|
+
];
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Per-request cost; amounts below the 4-digit precision read as a bound instead of "$0.0000". */
|
|
416
|
+
function formatUnitCost(value: number): string {
|
|
417
|
+
return value > 0 && value < 0.0001 ? "<$0.0001" : formatCost(value);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function sum(values: readonly (number | null)[]): number {
|
|
421
|
+
let total = 0;
|
|
422
|
+
for (const v of values) total += v ?? 0;
|
|
423
|
+
return total;
|
|
424
|
+
}
|