@flame0510/project-aether 1.9.1 → 1.10.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.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/app/agents/CostSection.tsx +100 -0
  3. package/app/agents/PageClient.tsx +7 -0
  4. package/app/api/assistant/route.ts +8 -1
  5. package/app/api/costs/agent/route.ts +56 -0
  6. package/app/api/costs/route.ts +19 -77
  7. package/app/api/costs/usage/route.ts +65 -0
  8. package/app/api/gateway/sync.ts +18 -4
  9. package/app/api/stats/route.ts +2 -3
  10. package/app/api/stats-since/route.ts +1 -1
  11. package/app/api/stream/route.ts +3 -5
  12. package/app/api/system-health/route.ts +33 -32
  13. package/app/components/CostBreakdown.tsx +1 -1
  14. package/app/components/Sidebar.tsx +10 -0
  15. package/app/components/SystemCockpit.tsx +1 -1
  16. package/app/components/ui/TimeSeriesChart.tsx +27 -9
  17. package/app/costs/CostsSkeleton.tsx +88 -0
  18. package/app/costs/PageClient.tsx +366 -0
  19. package/app/costs/loading.tsx +13 -0
  20. package/app/costs/page.tsx +5 -0
  21. package/app/globals.css +6 -0
  22. package/daemon.js +455 -41
  23. package/docs/ARCHITECTURE.md +39 -29
  24. package/docs/DESIGN-SYSTEM.md +2 -2
  25. package/docs/FRONTEND-ARCHITECTURE.md +4 -2
  26. package/docs/REV4A.md +3 -2
  27. package/docs/dev/API-REFERENCE.md +118 -22
  28. package/docs/dev/DATABASE.md +119 -19
  29. package/docs/dev/GATEWAY.md +53 -9
  30. package/docs/rag/DATA-FRESHNESS.md +19 -7
  31. package/docs/rag/GLOSSARY.md +7 -4
  32. package/docs/rag/REV4A-OVERVIEW.md +4 -1
  33. package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
  34. package/instrumentation.ts +11 -0
  35. package/lib/agent-costs.ts +172 -0
  36. package/lib/cost-reconciliation.ts +78 -0
  37. package/lib/costs-db.ts +31 -0
  38. package/lib/model-pricing.ts +123 -3
  39. package/lib/price-schedule-sync.ts +133 -0
  40. package/lib/utils/format.ts +12 -0
  41. package/model-pricing.json +269 -121
  42. package/package.json +1 -1
  43. package/scripts/refresh-model-pricing.mjs +16 -4
  44. package/lib/billing.ts +0 -100
@@ -22,6 +22,8 @@ interface TimeSeriesChartProps {
22
22
  bucket: number;
23
23
  yMax?: number;
24
24
  unit?: string;
25
+ /** Formats a value (grid labels, end label, tooltip, table) instead of `<rounded><unit>` — e.g. dollars. */
26
+ formatValue?: (v: number) => string;
25
27
  height?: number;
26
28
  /** While a new range loads the previous render stays, dimmed. */
27
29
  stale?: boolean;
@@ -29,6 +31,8 @@ interface TimeSeriesChartProps {
29
31
  }
30
32
 
31
33
  const PAD = { top: 12, right: 44, bottom: 22, left: 34 };
34
+ /** Width of one character of the 10 px monospace labels, for sizing the margins. */
35
+ const CHAR_W = 6.2;
32
36
 
33
37
  /**
34
38
  * One series over time: a 2px line for the value, a 10% wash up to the peak, a hairline
@@ -44,6 +48,7 @@ export function TimeSeriesChart({
44
48
  bucket,
45
49
  yMax = 100,
46
50
  unit = '%',
51
+ formatValue,
47
52
  height = 160,
48
53
  stale = false,
49
54
  formatTime,
@@ -61,10 +66,22 @@ export function TimeSeriesChart({
61
66
  return () => ro.disconnect();
62
67
  }, []);
63
68
 
64
- const plotW = Math.max(0, width - PAD.left - PAD.right);
69
+ // With a custom format (`$0.0054`) the margins grow to fit the labels instead of cutting
70
+ // them; percentage charts keep the fixed margins.
71
+ const valueText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v * 10) / 10}${unit}`);
72
+ const tickText = (v: number) => (formatValue ? formatValue(v) : `${Math.round(v)}${unit}`);
73
+ const tickValues = [0, 50, 100].map((v) => (v / 100) * yMax);
74
+ const lastValue = [...points].reverse().find((p) => p.value !== null)?.value ?? null;
75
+ const padLeft = formatValue
76
+ ? Math.max(PAD.left, Math.ceil(Math.max(...tickValues.map((v) => tickText(v).length)) * CHAR_W) + 8)
77
+ : PAD.left;
78
+ const padRight = formatValue && lastValue !== null
79
+ ? Math.max(PAD.right, Math.ceil(valueText(lastValue).length * CHAR_W) + 14)
80
+ : PAD.right;
81
+ const plotW = Math.max(0, width - padLeft - padRight);
65
82
  const plotH = height - PAD.top - PAD.bottom;
66
83
  const span = Math.max(1, to - from);
67
- const x = (ts: number) => PAD.left + ((ts - from) / span) * plotW;
84
+ const x = (ts: number) => padLeft + ((ts - from) / span) * plotW;
68
85
  const y = (v: number) => PAD.top + plotH - (Math.max(0, Math.min(yMax, v)) / yMax) * plotH;
69
86
 
70
87
  const valid = useMemo(() => points.filter((p) => p.value !== null), [points]);
@@ -95,7 +112,8 @@ export function TimeSeriesChart({
95
112
 
96
113
  const last = valid[valid.length - 1];
97
114
  const shown = active !== null ? valid[active] : null;
98
- const fmt = (v: number | null | undefined) => (v === null || v === undefined ? '—' : `${Math.round(v * 10) / 10}${unit}`);
115
+ const fmt = (v: number | null | undefined) =>
116
+ v === null || v === undefined ? '—' : valueText(v);
99
117
 
100
118
  function onPointerMove(e: PointerEvent<SVGRectElement>) {
101
119
  if (!valid.length || plotW <= 0) return;
@@ -137,9 +155,9 @@ export function TimeSeriesChart({
137
155
  <svg width={width} height={height} aria-hidden="true">
138
156
  {[0, 50, 100].map((v) => (
139
157
  <g key={v}>
140
- <line x1={PAD.left} x2={PAD.left + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
141
- <text x={PAD.left - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
142
- {Math.round((v / 100) * yMax)}{unit}
158
+ <line x1={padLeft} x2={padLeft + plotW} y1={y((v / 100) * yMax)} y2={y((v / 100) * yMax)} className="ui-chart__grid" />
159
+ <text x={padLeft - 6} y={y((v / 100) * yMax) + 3} textAnchor="end" className="ui-chart__tick">
160
+ {tickText((v / 100) * yMax)}
143
161
  </text>
144
162
  </g>
145
163
  ))}
@@ -157,7 +175,7 @@ export function TimeSeriesChart({
157
175
  <defs>
158
176
  {/* Marks stay inside the plot, never over the axis labels. */}
159
177
  <clipPath id={clipId}>
160
- <rect x={PAD.left} y={0} width={plotW} height={PAD.top + plotH} />
178
+ <rect x={padLeft} y={0} width={plotW} height={PAD.top + plotH} />
161
179
  </clipPath>
162
180
  </defs>
163
181
  <g clipPath={`url(#${clipId})`}>
@@ -182,10 +200,10 @@ export function TimeSeriesChart({
182
200
  </g>
183
201
  )}
184
202
  {!valid.length && (
185
- <text x={PAD.left + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
203
+ <text x={padLeft + plotW / 2} y={PAD.top + plotH / 2} textAnchor="middle" className="ui-chart__tick">No samples in this range</text>
186
204
  )}
187
205
  <rect
188
- x={PAD.left}
206
+ x={padLeft}
189
207
  y={PAD.top}
190
208
  width={plotW}
191
209
  height={plotH}
@@ -0,0 +1,88 @@
1
+ import { Skeleton } from '../components/Skeleton';
2
+ import { Surface } from '../components/ui';
3
+
4
+ /**
5
+ * Placeholders for the Costs page, used by `loading.tsx` while the route loads and by the
6
+ * page before its first answer. Each shimmer sits in the class of the text it stands in
7
+ * for (`ui-kicker`, `ui-metric-value`, `ui-key-value`, `ui-meter`), so the boxes have the
8
+ * real heights and nothing jumps when the data lands.
9
+ */
10
+
11
+ function KeyValueRow({ label, value }: { label: number; value: number }) {
12
+ return (
13
+ <div className="ui-key-value">
14
+ <span><Skeleton as="span" inline width={label} height={11} /></span>
15
+ <span><Skeleton as="span" inline width={value} height={11} /></span>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ function FigureCard({ title, rows }: { title: number; rows: number }) {
21
+ return (
22
+ <Surface as="article">
23
+ <div className="ui-kicker"><Skeleton as="span" inline width={title} height={10} /></div>
24
+ <div className="ui-metric-value"><Skeleton as="span" inline width={88} height={26} accent /></div>
25
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
26
+ {Array.from({ length: rows }).map((_, i) => <KeyValueRow key={i} label={70 + (i % 2) * 30} value={48} />)}
27
+ </div>
28
+ </Surface>
29
+ );
30
+ }
31
+
32
+ export function CostsCardsSkeleton() {
33
+ return (
34
+ <div className="costs-cards" aria-busy="true" aria-label="Loading costs">
35
+ <FigureCard title={40} rows={2} />
36
+ <FigureCard title={64} rows={4} />
37
+ <FigureCard title={48} rows={4} />
38
+ </div>
39
+ );
40
+ }
41
+
42
+ /** One breakdown list: a title, then rows of name and figure over a share meter. */
43
+ function ListSkeleton({ rows }: { rows: number }) {
44
+ return (
45
+ <Surface as="section">
46
+ <div className="ui-kicker"><Skeleton as="span" inline width={90} height={10} /></div>
47
+ {Array.from({ length: rows }).map((_, i) => (
48
+ <div key={i} style={{ marginTop: 14 }}>
49
+ <KeyValueRow label={140 - (i % 3) * 20} value={56} />
50
+ <div className="ui-meter"><Skeleton height={6} style={{ flex: 1 }} /></div>
51
+ </div>
52
+ ))}
53
+ </Surface>
54
+ );
55
+ }
56
+
57
+ export function CostsBodySkeleton() {
58
+ return (
59
+ <div aria-hidden="true" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
60
+ <Surface as="section">
61
+ <div className="ui-kicker"><Skeleton as="span" inline width={120} height={10} /></div>
62
+ <Skeleton height={160} />
63
+ </Surface>
64
+ <div className="costs-lists">
65
+ <ListSkeleton rows={3} />
66
+ <ListSkeleton rows={3} />
67
+ </div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ /** The range switch (Today · 7 days · 30 days) for the route's loading state. */
73
+ export function CostsRangeSkeleton() {
74
+ return (
75
+ <div
76
+ aria-hidden="true"
77
+ style={{ display: 'flex', borderBottom: '1px solid var(--border)', marginBottom: 20, height: 44, alignItems: 'center' }}
78
+ >
79
+ {[36, 44, 50].map((w, i) => (
80
+ <div key={i} style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
81
+ <Skeleton width={w} height={11} />
82
+ </div>
83
+ ))}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ export const COSTS_DESCRIPTION = 'What each agent spent, as its own OpenClaw priced it with the rates Rev4a syncs. Days are UTC.';
@@ -0,0 +1,366 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { apiFetch } from '@/lib/apiFetch';
5
+ import { formatCost } from '@/lib/utils/format';
6
+ import { useRev4aTimezone } from '@/lib/hooks/useRev4aTimezone';
7
+ import { Meter, Page, PageHeader, Pill, Surface, Tabs, TimeSeriesChart } from '../components/ui';
8
+ import { COSTS_DESCRIPTION, CostsBodySkeleton, CostsCardsSkeleton } from './CostsSkeleton';
9
+
10
+ type Range = 'today' | '7d' | '30d';
11
+ const RANGES: { id: Range; label: string }[] = [
12
+ { id: 'today', label: 'Today' },
13
+ { id: '7d', label: '7 days' },
14
+ { id: '30d', label: '30 days' },
15
+ ];
16
+ /** The daemon reads the agents every 5 min; a minute keeps the page close behind it. */
17
+ const POLL_MS = 60_000;
18
+ const DAY_S = 86_400;
19
+
20
+ interface Totals {
21
+ cost: number; tokens: number; input: number; output: number; cacheRead: number; cacheWrite: number;
22
+ inputCost: number; outputCost: number; cacheReadCost: number; cacheWriteCost: number; missing: number;
23
+ }
24
+ interface AgentRow {
25
+ container: string; agentId: string | null; name: string | null; cost: number; tokens: number; missing: number;
26
+ collectedAt: number | null; attemptedAt: number | null; error: string | null;
27
+ }
28
+ interface ModelRow { provider: string; model: string; cost: number; tokens: number; calls: number }
29
+ interface SessionRow {
30
+ container: string; sessionId: string; sessionKey: string | null; label: string | null; agentName: string | null;
31
+ model: string | null; cost: number; tokens: number; missing: number; lastActivity: number | null;
32
+ }
33
+ interface CostsPayload {
34
+ available: boolean;
35
+ range: Range;
36
+ startDate: string;
37
+ endDate: string;
38
+ intervalS: number;
39
+ pricing: { vendor: string; band: 'peak' | 'off-peak' | null; nextChange: number | null; source: string } | null;
40
+ totals: Totals;
41
+ daily: { date: string; cost: number; tokens: number; missing: number }[];
42
+ byAgent: AgentRow[];
43
+ byModel: ModelRow[];
44
+ sessions: SessionRow[];
45
+ unpriced: { model: string; calls: number; agents: string[] }[];
46
+ reconciliation: Reconciliation[];
47
+ }
48
+ interface Reconciliation {
49
+ vendor: string; currency: string | null; from: number | null; to: number | null;
50
+ billed: number | null; priced: number | null; topUps: number; readings: number; comparable: boolean;
51
+ }
52
+
53
+ const VENDOR_LABEL: Record<string, string> = { deepseek: 'DeepSeek', openrouter: 'OpenRouter' };
54
+
55
+
56
+ function formatTokens(n: number): string {
57
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
58
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
59
+ return String(n);
60
+ }
61
+
62
+ function formatAge(ms: number): string {
63
+ const min = Math.round(ms / 60_000);
64
+ if (min < 1) return 'just now';
65
+ if (min < 60) return `${min} min ago`;
66
+ const h = Math.round(min / 60);
67
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
68
+ }
69
+
70
+ const dayStart = (date: string) => Date.parse(`${date}T00:00:00Z`) / 1000;
71
+ const utcDay = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' });
72
+ const utcTime = new Intl.DateTimeFormat('en-GB', { weekday: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone: 'UTC' });
73
+
74
+ function share(part: number, whole: number): number {
75
+ return whole > 0 ? (part / whole) * 100 : 0;
76
+ }
77
+
78
+ /** A breakdown row: name and figure, a share meter, and a muted detail line. */
79
+ function BreakdownRow({ name, figure, detail, percent, label }: { name: string; figure: string; detail: string; percent: number; label: string }) {
80
+ return (
81
+ <div style={{ marginTop: 14 }}>
82
+ <div className="ui-key-value" style={{ fontSize: 12 }}>
83
+ <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
84
+ <span>{figure}</span>
85
+ </div>
86
+ <Meter percent={percent} level="ok" label={label} />
87
+ <div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}>{detail}</div>
88
+ </div>
89
+ );
90
+ }
91
+
92
+ export default function CostsPageClient() {
93
+ const [range, setRange] = useState<Range>('7d');
94
+ const [data, setData] = useState<CostsPayload | null>(null);
95
+ const [loading, setLoading] = useState(true);
96
+ const [error, setError] = useState('');
97
+ const abortRef = useRef<AbortController | null>(null);
98
+ const timeZone = useRev4aTimezone();
99
+
100
+ const load = useCallback(async (r: Range) => {
101
+ abortRef.current?.abort();
102
+ const controller = new AbortController();
103
+ abortRef.current = controller;
104
+ setLoading(true);
105
+ try {
106
+ const res = await apiFetch(`/api/costs/usage?range=${r}`, { signal: controller.signal });
107
+ const body = await res.json();
108
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
109
+ setData(body as CostsPayload);
110
+ setError('');
111
+ } catch (e: any) {
112
+ if (e?.name === 'AbortError') return;
113
+ setError(e?.message || 'Could not load the costs');
114
+ } finally {
115
+ if (abortRef.current === controller) setLoading(false);
116
+ }
117
+ }, []);
118
+
119
+ useEffect(() => {
120
+ load(range);
121
+ const timer = setInterval(() => load(range), POLL_MS);
122
+ return () => {
123
+ clearInterval(timer);
124
+ abortRef.current?.abort();
125
+ };
126
+ }, [range, load]);
127
+
128
+ const firstLoad = data === null && !error;
129
+ const stale = loading && data !== null && data.range !== range;
130
+ const now = Date.now();
131
+ const t = data?.totals;
132
+ const sessionTime = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12: false, timeZone });
133
+
134
+ // Agents the daemon is still trying to read (a recent attempt) whose figures are not
135
+ // current. A stopped or deleted agent is no longer attempted and keeps its last figures
136
+ // on purpose: it is not flagged.
137
+ const interval = (data?.intervalS ?? 300) * 1000;
138
+ const lagging = (data?.byAgent ?? []).filter(
139
+ (a) => a.attemptedAt !== null && now - a.attemptedAt <= interval * 3
140
+ && (a.error !== null || !a.collectedAt || now - a.collectedAt > interval * 3),
141
+ );
142
+ const maxDaily = Math.max(0, ...(data?.daily ?? []).map((d) => d.cost));
143
+ // OpenClaw names an unpriced model `provider/model`, like the rows below.
144
+ const unpricedModels = new Set((data?.unpriced ?? []).map((u) => u.model));
145
+
146
+ const band = data?.pricing;
147
+ const bandPill = band?.band ? (
148
+ <Pill tone={band.band === 'off-peak' ? 'success' : 'neutral'}>
149
+ {band.vendor} {band.band}{band.nextChange ? ` · until ${utcTime.format(new Date(band.nextChange))} UTC` : ''}
150
+ </Pill>
151
+ ) : undefined;
152
+
153
+ return (
154
+ <Page>
155
+ <PageHeader eyebrow="Spend" title="Costs" description={COSTS_DESCRIPTION} action={bandPill} />
156
+
157
+ <Tabs items={RANGES.map((r) => ({ label: r.label, active: range === r.id, onClick: () => setRange(r.id) }))} />
158
+
159
+ {error && <Surface tone="danger"><span style={{ color: 'var(--red)', fontSize: 12 }}>{error}</span></Surface>}
160
+
161
+ {data && !data.available && (
162
+ <Surface tone="warning">
163
+ <div className="ui-kicker">No figures yet</div>
164
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
165
+ The Rev4a daemon reads every running agent each {Math.round(data.intervalS / 60)} min. If this stays empty, check that rev4a serve is running.
166
+ </p>
167
+ </Surface>
168
+ )}
169
+
170
+ {lagging.length > 0 && (
171
+ <Surface tone="warning">
172
+ <div className="ui-kicker">Not current</div>
173
+ <div className="ui-key-values" style={{ marginTop: 8 }}>
174
+ {lagging.map((a) => (
175
+ <div className="ui-key-value" key={a.container}>
176
+ <span>{a.name ?? a.container}</span>
177
+ <span className="ui-muted">
178
+ {a.error ?? 'not read recently'}
179
+ {a.collectedAt ? ` · last read ${formatAge(now - a.collectedAt)}` : ' · never read'}
180
+ </span>
181
+ </div>
182
+ ))}
183
+ </div>
184
+ </Surface>
185
+ )}
186
+
187
+ {data && data.unpriced.length > 0 && (
188
+ <Surface tone="warning">
189
+ <div className="ui-kicker">Calls without a price · last 30 days</div>
190
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
191
+ OpenClaw counted these calls but could not price them, so they are not in the figures: the model is no longer in the catalogue, or has no price in model-pricing.json.
192
+ </p>
193
+ <div className="ui-key-values" style={{ marginTop: 8 }}>
194
+ {data.unpriced.map((u) => (
195
+ <div className="ui-key-value" key={u.model}>
196
+ <span>{u.model}</span>
197
+ <span>{u.calls} call{u.calls === 1 ? '' : 's'} · {u.agents.join(', ')}</span>
198
+ </div>
199
+ ))}
200
+ </div>
201
+ </Surface>
202
+ )}
203
+
204
+ {firstLoad ? <CostsCardsSkeleton /> : (
205
+ <div className="costs-cards" style={{ opacity: stale ? 0.5 : 1 }}>
206
+ <Surface as="article">
207
+ <div className="ui-kicker">Spent</div>
208
+ <div className="ui-metric-value">{formatCost(t?.cost ?? 0)}</div>
209
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
210
+ <div className="ui-key-value"><span className="ui-muted">Tokens</span><span>{formatTokens(t?.tokens ?? 0)}</span></div>
211
+ <div className="ui-key-value">
212
+ <span className="ui-muted">Days</span>
213
+ <span>{data ? (data.startDate === data.endDate ? utcDay.format(new Date(data.endDate)) : `${utcDay.format(new Date(data.startDate))} – ${utcDay.format(new Date(data.endDate))}`) : '—'}</span>
214
+ </div>
215
+ </div>
216
+ </Surface>
217
+
218
+ <Surface as="article">
219
+ <div className="ui-kicker">Where the money went</div>
220
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
221
+ <div className="ui-key-value"><span className="ui-muted">Input</span><span>{formatCost(t?.inputCost ?? 0)}</span></div>
222
+ <div className="ui-key-value"><span className="ui-muted">Output</span><span>{formatCost(t?.outputCost ?? 0)}</span></div>
223
+ <div className="ui-key-value"><span className="ui-muted">Cache read</span><span>{formatCost(t?.cacheReadCost ?? 0)}</span></div>
224
+ <div className="ui-key-value"><span className="ui-muted">Cache write</span><span>{formatCost(t?.cacheWriteCost ?? 0)}</span></div>
225
+ </div>
226
+ </Surface>
227
+
228
+ <Surface as="article">
229
+ <div className="ui-kicker">Tokens</div>
230
+ <div className="ui-key-values" style={{ marginTop: 12 }}>
231
+ <div className="ui-key-value"><span className="ui-muted">Input</span><span>{formatTokens(t?.input ?? 0)}</span></div>
232
+ <div className="ui-key-value"><span className="ui-muted">Output</span><span>{formatTokens(t?.output ?? 0)}</span></div>
233
+ <div className="ui-key-value"><span className="ui-muted">Cache read</span><span>{formatTokens(t?.cacheRead ?? 0)}</span></div>
234
+ <div className="ui-key-value">
235
+ <span className="ui-muted">Input served from cache</span>
236
+ <span>{Math.round(share(t?.cacheRead ?? 0, (t?.input ?? 0) + (t?.cacheRead ?? 0) + (t?.cacheWrite ?? 0)))}%</span>
237
+ </div>
238
+ </div>
239
+ </Surface>
240
+ </div>
241
+ )}
242
+
243
+ {firstLoad ? <CostsBodySkeleton /> : data && (
244
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 12, opacity: stale ? 0.5 : 1 }}>
245
+ {data.range !== 'today' && (
246
+ <Surface as="section">
247
+ <div className="ui-kicker">Spent per day · UTC</div>
248
+ <TimeSeriesChart
249
+ label="Spent per day"
250
+ points={data.daily.map((d) => ({ ts: dayStart(d.date), value: d.cost }))}
251
+ from={dayStart(data.startDate)}
252
+ to={dayStart(data.endDate)}
253
+ bucket={DAY_S}
254
+ yMax={maxDaily > 0 ? maxDaily * 1.2 : 1}
255
+ formatValue={formatCost}
256
+ formatTime={(ts) => utcDay.format(new Date(ts * 1000))}
257
+ />
258
+ </Surface>
259
+ )}
260
+
261
+ <div className="costs-lists">
262
+ <Surface as="section">
263
+ <div className="ui-kicker">By agent</div>
264
+ {data.byAgent.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No agent has spent anything in this range.</p>}
265
+ {data.byAgent.map((a) => (
266
+ <BreakdownRow
267
+ key={a.container}
268
+ name={a.name ?? a.container}
269
+ figure={formatCost(a.cost)}
270
+ percent={share(a.cost, t?.cost ?? 0)}
271
+ label={`Share of the spend for ${a.name ?? a.container}`}
272
+ detail={`${a.container} · ${formatTokens(a.tokens)} tokens${a.missing ? ` · ${a.missing} unpriced` : ''}`}
273
+ />
274
+ ))}
275
+ </Surface>
276
+
277
+ <Surface as="section">
278
+ <div className="ui-kicker">By model</div>
279
+ {data.byModel.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No model calls in this range.</p>}
280
+ {data.byModel.map((m) => (
281
+ <BreakdownRow
282
+ key={`${m.provider}/${m.model}`}
283
+ name={m.model}
284
+ figure={formatCost(m.cost)}
285
+ percent={share(m.cost, t?.cost ?? 0)}
286
+ label={`Share of the spend for ${m.model}`}
287
+ detail={`${m.provider} · ${m.calls} call${m.calls === 1 ? '' : 's'} · ${formatTokens(m.tokens)} tokens${unpricedModels.has(`${m.provider}/${m.model}`) ? ' · no price, not counted' : ''}`}
288
+ />
289
+ ))}
290
+ </Surface>
291
+ </div>
292
+
293
+ {data.reconciliation.length > 0 && (
294
+ <Surface as="section">
295
+ <div className="ui-kicker">Against the bill</div>
296
+ <p className="ui-muted" style={{ fontSize: 12, marginTop: 8 }}>
297
+ What each vendor charged (its balance or usage, read every hour) next to what the agents priced, over the same readings.
298
+ The same key also pays for the assistant and any client outside Rev4a, and DeepSeek gives its balance to the cent: a small gap is normal.
299
+ </p>
300
+ <div className="ui-key-values" style={{ marginTop: 8 }}>
301
+ {data.reconciliation.map((r) => {
302
+ const name = VENDOR_LABEL[r.vendor] ?? r.vendor;
303
+ if (r.billed === null || r.priced === null || !r.from || !r.to) {
304
+ return (
305
+ <div className="ui-key-value" key={r.vendor}>
306
+ <span>{name}</span>
307
+ <span className="ui-muted">{r.readings ? 'one reading so far — the comparison starts with the next, within the hour' : 'no reading in this range'}</span>
308
+ </div>
309
+ );
310
+ }
311
+ if (!r.comparable) {
312
+ return (
313
+ <div className="ui-key-value" key={r.vendor}>
314
+ <span>{name}</span>
315
+ <span className="ui-muted">billed in {r.currency}, not comparable with dollars</span>
316
+ </div>
317
+ );
318
+ }
319
+ const gap = r.billed - r.priced;
320
+ return (
321
+ <div key={r.vendor} style={{ marginTop: 6 }}>
322
+ <div className="ui-key-value" style={{ fontSize: 12 }}>
323
+ <span>{name}</span>
324
+ <span>billed {formatCost(r.billed)} · priced {formatCost(r.priced)} · gap {gap < 0 ? '−' : '+'}{formatCost(Math.abs(gap))}</span>
325
+ </div>
326
+ <div className="ui-muted" style={{ fontSize: 11, marginTop: 4 }}>
327
+ {sessionTime.format(new Date(r.from))} → {sessionTime.format(new Date(r.to))} · {r.readings} readings
328
+ {r.topUps > 0 ? ` · ${formatCost(r.topUps)} added to the balance (not counted as spend)` : ''}
329
+ </div>
330
+ </div>
331
+ );
332
+ })}
333
+ </div>
334
+ </Surface>
335
+ )}
336
+
337
+ <Surface as="section">
338
+ <div className="ui-kicker">Most expensive sessions</div>
339
+ {data.sessions.length === 0 && <p className="ui-muted" style={{ fontSize: 12, marginTop: 12 }}>No session was active in this range.</p>}
340
+ {data.sessions.map((s) => (
341
+ <BreakdownRow
342
+ key={`${s.container}/${s.sessionId}`}
343
+ name={s.label || s.sessionKey || s.sessionId}
344
+ figure={formatCost(s.cost)}
345
+ percent={share(s.cost, data.sessions[0]?.cost ?? 0)}
346
+ label={`Cost of ${s.label || s.sessionId} relative to the most expensive session`}
347
+ detail={[
348
+ s.agentName ?? s.container,
349
+ s.model,
350
+ `${formatTokens(s.tokens)} tokens`,
351
+ s.missing ? `${s.missing} unpriced` : null,
352
+ s.lastActivity ? `last active ${sessionTime.format(new Date(s.lastActivity))}` : null,
353
+ ].filter(Boolean).join(' · ')}
354
+ />
355
+ ))}
356
+ {data.sessions.length > 0 && (
357
+ <p className="ui-muted" style={{ fontSize: 11, marginTop: 14 }}>
358
+ A session&apos;s figure is its total over the last 30 days, not only this range.
359
+ </p>
360
+ )}
361
+ </Surface>
362
+ </div>
363
+ )}
364
+ </Page>
365
+ );
366
+ }
@@ -0,0 +1,13 @@
1
+ import { Page, PageHeader } from '../components/ui';
2
+ import { COSTS_DESCRIPTION, CostsBodySkeleton, CostsCardsSkeleton, CostsRangeSkeleton } from './CostsSkeleton';
3
+
4
+ export default function CostsLoading() {
5
+ return (
6
+ <Page>
7
+ <PageHeader eyebrow="Spend" title="Costs" description={COSTS_DESCRIPTION} />
8
+ <CostsRangeSkeleton />
9
+ <CostsCardsSkeleton />
10
+ <CostsBodySkeleton />
11
+ </Page>
12
+ );
13
+ }
@@ -0,0 +1,5 @@
1
+ import CostsPageClient from './PageClient';
2
+
3
+ export default function CostsPage() {
4
+ return <CostsPageClient />;
5
+ }
package/app/globals.css CHANGED
@@ -684,6 +684,12 @@ html, body { height: 100%; height: 100dvh; background: var(--bg); color: var(--t
684
684
  .system-cards, .system-charts { grid-template-columns: minmax(0, 1fr); }
685
685
  }
686
686
 
687
+ .costs-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
688
+ .costs-lists { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
689
+ @media (max-width: 991px) {
690
+ .costs-cards, .costs-lists { grid-template-columns: minmax(0, 1fr); }
691
+ }
692
+
687
693
  .cockpit { overflow: auto; height: 100%; padding: 20px; }
688
694
  .cockpit__metrics { display: grid; grid-template-columns: minmax(260px, 1.2fr) repeat(3, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px; }
689
695
  .cockpit__machine-item { white-space: nowrap; color: var(--text); }