@oh-my-pi/omp-stats 18.3.5 → 18.4.1

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.
Files changed (192) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +143 -87
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -1,252 +1,424 @@
1
- import type { Plugin } from "chart.js";
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
- barDatasetStyle,
7
- buildAggregateTimeSeries,
8
- buildSharedPlugins,
9
- buildSharedScales,
10
- buildTopNByModelSeries,
11
- CHART_THEMES,
12
- lineDatasetStyle,
13
- MODEL_COLORS,
14
- styleDatasets,
15
- } from "../components/chart-shared";
16
- import { formatCost, formatEstimatedCost } from "../data/formatters";
17
- import { useResource } from "../data/useResource";
18
- import { buildCostSummary } from "../data/view-models";
19
- import type { CostTimeSeriesPoint, TimeRange } from "../types";
20
- import { AsyncBoundary, Panel, SegmentedControl } from "../ui";
21
- import { useSystemTheme } from "../useSystemTheme";
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
- export function CostsRoute({ active, range, refreshTrigger }: CostsRouteProps) {
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
- return (
40
- <div className="stats-route-container space-y-6">
41
- <AsyncBoundary loading={loading} error={error} data={costStats}>
42
- {costStats && (
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
- function CostOverviewPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) {
54
- const summary = useMemo(() => buildCostSummary(costSeries), [costSeries]);
42
+ /** Models stacked individually in the daily chart; the rest fold into "Other". */
43
+ const MODEL_LIMIT = 6;
55
44
 
56
- const cards = [
57
- {
58
- label: "API-equivalent estimate",
59
- value: formatEstimatedCost(summary.totalCost, summary.unpricedRequests),
60
- sub:
61
- summary.unpricedRequests > 0
62
- ? `Excludes ${summary.unpricedRequests.toLocaleString()} unpriced subscription request${summary.unpricedRequests === 1 ? "" : "s"}`
63
- : undefined,
64
- },
65
- {
66
- label: "Average estimate / Day",
67
- value: formatEstimatedCost(summary.avgDailyCost, summary.unpricedRequests),
68
- },
69
- {
70
- label: "Top Model",
71
- value: summary.topModelName || "—",
72
- sub: summary.topModelName ? `API-equivalent estimate: ${formatCost(summary.topModelCost)}` : undefined,
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="grid grid-cols-1 md:grid-cols-3 gap-4">
78
- {cards.map(card => (
79
- <Panel key={card.label} className="stats-cost-overview-card py-4 px-5">
80
- <p className="text-xs stats-text-muted mb-1 font-medium uppercase tracking-wider">{card.label}</p>
81
- <p className="text-2xl font-bold stats-text-primary truncate" title={card.value}>
82
- {card.value}
83
- </p>
84
- {card.sub && <p className="text-xs stats-text-muted mt-1 font-medium">{card.sub}</p>}
85
- </Panel>
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
- const BAR_LABEL_COLORS = {
92
- dark: "rgba(248, 250, 252, 0.7)",
93
- light: "rgba(15, 23, 42, 0.6)",
94
- } as const;
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
- // Inline Chart.js plugin to draw cost value above bars
97
- function makeBarLabelPlugin(color: string): Plugin<"bar"> {
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
- id: "costBarLabels",
100
- afterDatasetsDraw(chart) {
101
- const { ctx } = chart;
102
- const dataset = chart.data.datasets[0];
103
- if (!dataset) return;
104
- const meta = chart.getDatasetMeta(0);
105
- ctx.save();
106
- ctx.font = "11px system-ui, sans-serif";
107
- ctx.fillStyle = color;
108
- ctx.textAlign = "center";
109
- ctx.textBaseline = "bottom";
110
- for (const bar of meta.data) {
111
- // Accessing Chart.js internal parsed coordinates via unknown cast
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 CostTrendPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) {
128
- const [byModel, setByModel] = useState(false);
129
- const theme = useSystemTheme();
130
- const chartTheme = CHART_THEMES[theme];
131
- const unpricedRequests = useMemo(
132
- () => costSeries.reduce((sum, point) => sum + point.unpricedRequests, 0),
133
- [costSeries],
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
- const chartData = useMemo(() => {
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
- <Panel
231
- title="Daily API-equivalent estimate"
232
- subtitle={
233
- unpricedRequests > 0
234
- ? `Public API rate-card value over time; excludes ${unpricedRequests.toLocaleString()} unpriced subscription request${unpricedRequests === 1 ? "" : "s"}`
235
- : "Public API rate-card value over time"
236
- }
237
- actions={<SegmentedControl options={toggleOptions} value={byModel} onChange={setByModel} />}
238
- >
239
- <div className="h-[300px]">
240
- {chartData.labels.length === 0 ? (
241
- <div className="h-full flex items-center justify-center text-stats-muted text-sm">
242
- No API-equivalent estimate data available
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
- ) : byModel && lineData ? (
245
- <Line data={lineData} options={lineOptions} />
246
- ) : barData ? (
247
- <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
248
- ) : null}
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
- </Panel>
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
+ }