@flame0510/project-aether 1.9.0 → 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 (46) 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/agents/create/route.ts +6 -0
  5. package/app/api/assistant/route.ts +8 -1
  6. package/app/api/costs/agent/route.ts +56 -0
  7. package/app/api/costs/route.ts +19 -77
  8. package/app/api/costs/usage/route.ts +65 -0
  9. package/app/api/gateway/sync.ts +18 -4
  10. package/app/api/stats/route.ts +2 -3
  11. package/app/api/stats-since/route.ts +1 -1
  12. package/app/api/stream/route.ts +3 -5
  13. package/app/api/system-health/route.ts +33 -32
  14. package/app/components/CostBreakdown.tsx +1 -1
  15. package/app/components/Sidebar.tsx +10 -0
  16. package/app/components/SystemCockpit.tsx +1 -1
  17. package/app/components/ui/TimeSeriesChart.tsx +27 -9
  18. package/app/costs/CostsSkeleton.tsx +88 -0
  19. package/app/costs/PageClient.tsx +366 -0
  20. package/app/costs/loading.tsx +13 -0
  21. package/app/costs/page.tsx +5 -0
  22. package/app/globals.css +6 -0
  23. package/daemon.js +455 -41
  24. package/docs/ARCHITECTURE.md +39 -29
  25. package/docs/DESIGN-SYSTEM.md +2 -2
  26. package/docs/FRONTEND-ARCHITECTURE.md +4 -2
  27. package/docs/REV4A.md +4 -2
  28. package/docs/dev/API-REFERENCE.md +118 -22
  29. package/docs/dev/DATABASE.md +119 -19
  30. package/docs/dev/GATEWAY.md +53 -9
  31. package/docs/rag/DATA-FRESHNESS.md +19 -7
  32. package/docs/rag/GLOSSARY.md +7 -4
  33. package/docs/rag/REV4A-OVERVIEW.md +4 -1
  34. package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
  35. package/instrumentation.ts +11 -0
  36. package/lib/agent-costs.ts +172 -0
  37. package/lib/agent-recreate.ts +3 -0
  38. package/lib/cost-reconciliation.ts +78 -0
  39. package/lib/costs-db.ts +31 -0
  40. package/lib/model-pricing.ts +123 -3
  41. package/lib/price-schedule-sync.ts +133 -0
  42. package/lib/utils/format.ts +12 -0
  43. package/model-pricing.json +269 -121
  44. package/package.json +1 -1
  45. package/scripts/refresh-model-pricing.mjs +16 -4
  46. package/lib/billing.ts +0 -100
package/README.md CHANGED
@@ -18,9 +18,9 @@
18
18
  - **Live event stream** — Real-time session lineage, agent spawning, cost reporting
19
19
  - **Workspace browser** — Browse and edit agent files live from the dashboard
20
20
  - **Provider proxy** — OpenAI-compatible chat completions proxy that routes through a shared key vault
21
- - **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and usage cost, with recommendations
21
+ - **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and what the agents spent today, with recommendations
22
22
  - **System** — the host machine's CPU, memory, swap and storage, now and over 1h / 24h / 7d / 30d (sampled every 30 s, kept 30 days)
23
- - **Cost tracking** — Per-session, per-model, per-time-range usage and cost reporting
23
+ - **Costs** — what each agent spent, priced by its own OpenClaw with the prices Rev4a syncs (cache rates included, DeepSeek off-peak applied): by day, agent, model and session, with the calls that could not be priced
24
24
  - **PWA-ready** — Install on phone for mobile monitoring
25
25
 
26
26
  ---
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Costs section of the agent detail panel: what this agent spent, as its own OpenClaw
5
+ * priced it (GET /api/costs/agent, from costs.db). The Costs page has the whole fleet.
6
+ */
7
+ import Link from 'next/link';
8
+ import { useEffect, useRef, useState } from 'react';
9
+ import { apiFetch } from '@/lib/apiFetch';
10
+ import { formatCost } from '@/lib/utils/format';
11
+
12
+ interface AgentCosts {
13
+ available: boolean;
14
+ intervalS: number;
15
+ today?: number;
16
+ week?: number;
17
+ month?: number;
18
+ tokensMonth?: number;
19
+ topModel?: { model: string; cost: number } | null;
20
+ unpricedMonth?: number;
21
+ collectedAt?: number | null;
22
+ error?: string | null;
23
+ }
24
+
25
+
26
+ function formatAge(ms: number): string {
27
+ const min = Math.round(ms / 60_000);
28
+ if (min < 1) return 'just now';
29
+ if (min < 60) return `${min} min ago`;
30
+ const h = Math.round(min / 60);
31
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
32
+ }
33
+
34
+ const ROW = { display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 } as const;
35
+
36
+ export default function CostSection({ container }: { container: string }) {
37
+ const [data, setData] = useState<AgentCosts | null>(null);
38
+ const [error, setError] = useState('');
39
+ const abortRef = useRef<AbortController | null>(null);
40
+
41
+ useEffect(() => {
42
+ const load = async () => {
43
+ abortRef.current?.abort();
44
+ const controller = new AbortController();
45
+ abortRef.current = controller;
46
+ try {
47
+ const res = await apiFetch(`/api/costs/agent?container=${encodeURIComponent(container)}`, { signal: controller.signal });
48
+ const body = await res.json();
49
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
50
+ setData(body as AgentCosts);
51
+ setError('');
52
+ } catch (e: any) {
53
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the costs');
54
+ }
55
+ };
56
+ setData(null);
57
+ setError('');
58
+ load();
59
+ const timer = setInterval(load, 60_000);
60
+ return () => {
61
+ clearInterval(timer);
62
+ abortRef.current?.abort();
63
+ };
64
+ }, [container]);
65
+
66
+ if (error) return <div style={{ padding: '12px 14px', fontSize: 12, color: 'var(--red)' }}>{error}</div>;
67
+ if (!data) return <div style={{ padding: '12px 14px', fontSize: 12, color: '#888' }}>Loading…</div>;
68
+ if (!data.available) {
69
+ return <div style={{ padding: '12px 14px', fontSize: 12, color: '#888' }}>No figures yet: the Rev4a daemon reads the agents every {Math.round(data.intervalS / 60)} min.</div>;
70
+ }
71
+
72
+ const stale = !data.collectedAt || Date.now() - data.collectedAt > data.intervalS * 3 * 1000;
73
+ const rows: [string, string][] = [
74
+ ['Today (UTC)', formatCost(data.today)],
75
+ ['Last 7 days', formatCost(data.week)],
76
+ ['Last 30 days', `${formatCost(data.month)} · ${Math.round((data.tokensMonth ?? 0) / 1000)}k tokens`],
77
+ ['Top model (30 days)', data.topModel ? `${data.topModel.model} · ${formatCost(data.topModel.cost)}` : '-'],
78
+ ];
79
+ if (data.unpricedMonth) rows.push(['Calls without a price (30 days)', String(data.unpricedMonth)]);
80
+ rows.push(['Last read', data.collectedAt ? formatAge(Date.now() - data.collectedAt) : 'never']);
81
+
82
+ return (
83
+ <div>
84
+ {rows.map(([label, value]) => (
85
+ <div key={label} style={ROW}>
86
+ <span style={{ color: '#888' }}>{label}</span>
87
+ <span style={{ color: 'var(--text)', textAlign: 'right', maxWidth: '60%', wordBreak: 'break-word' }}>{value}</span>
88
+ </div>
89
+ ))}
90
+ {(stale || data.error) && (
91
+ <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
92
+ {data.error ?? 'Not read recently: a stopped agent keeps the figures of its last read.'}
93
+ </div>
94
+ )}
95
+ <div style={{ padding: '8px 14px', fontSize: 11 }}>
96
+ <Link href="/costs" className="ui-link">All agents on the Costs page →</Link>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -8,6 +8,7 @@ import PasswordInput from '../components/PasswordInput';
8
8
  import ImageDownloadBanner from './ImageDownloadBanner';
9
9
  import ChannelManager from './ChannelManager';
10
10
  import ModelSection from './ModelSection';
11
+ import CostSection from './CostSection';
11
12
  import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
12
13
  import UpdateSection from './UpdateSection';
13
14
  import { validatePortInput } from '@/lib/agent-ports';
@@ -1194,6 +1195,12 @@ function DetailPanel({ agent, busy, onDeleted, onChanged, onModelSaved, onAccess
1194
1195
  <ModelSection agentId={agent.agentId} onSaved={onModelSaved} agentBusy={busy} />
1195
1196
  </div>
1196
1197
 
1198
+ {/* Costs - what this agent spent, as its own OpenClaw priced it */}
1199
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1200
+ <SectionHeader icon={<BracesIcon size={12} />} label="COSTS" />
1201
+ <CostSection container={agent.name} />
1202
+ </div>
1203
+
1197
1204
  {/* Config - View openclaw.json */}
1198
1205
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1199
1206
  <SectionHeader icon={<BracesIcon size={12} />} label="CONFIG" />
@@ -221,7 +221,13 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
221
221
  // this is intentional (e.g. BOOTSTRAP.md only exists in custom/).
222
222
  const templateFiles = ['AGENTS.md', 'SOUL.md', 'IDENTITY.md', 'MEMORY.md', 'TOOLS.md', 'HEARTBEAT.md', 'BOOTSTRAP.md'];
223
223
 
224
+ // --init: Docker's tini runs as PID 1 and collects orphaned processes. Without it
225
+ // openclaw-gateway is PID 1 and never does, so every process a tool leaves behind
226
+ // (Chrome, builds, scripts) stays a zombie and counts against the container's pids
227
+ // limit until the container can no longer start anything. Same flag in
228
+ // lib/agent-recreate.ts.
224
229
  const cmd = `docker run -d \
230
+ --init \
225
231
  --name "${containerName}" \
226
232
  --network "${network}" \
227
233
  --restart unless-stopped \
@@ -19,7 +19,7 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
19
19
  '/': {
20
20
  title: 'Dashboard',
21
21
  description: 'Main overview with live session list, cost summary by model, health cards for Rev4a\'s own runtime, cron and lineage, and a real-time event feed. The Machine card shows the server\'s CPU, RAM and fullest disk right now, coloured past a threshold, with a link to the System page.',
22
- actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'View cost breakdown by model and time period', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
22
+ actions: ['Click any session row to open the Session Drawer for full details and tool call history', 'See what the agents spent today, by model (the Costs page has 7 and 30 days)', 'See live events as they happen', 'Take a screenshot of the dashboard (mobile)'],
23
23
  hints: 'The Session Drawer shows you the complete tool call history for any session — just click a row.',
24
24
  },
25
25
  '/agents': {
@@ -46,6 +46,12 @@ const PAGE_CONTEXT: Record<string, PageContext> = {
46
46
  actions: ['See all scheduled cron jobs at a glance', 'Check when each cron last ran and when it will run next', 'View job status (scheduled, running, completed, error)'],
47
47
  hints: 'Cron jobs use standard cron syntax. If a job shows "error" status, it means the last execution failed.',
48
48
  },
49
+ '/costs': {
50
+ title: 'Costs',
51
+ description: 'What each agent spent, as its own OpenClaw priced it with the prices Rev4a writes into every agent (input, output and cached input, DeepSeek at half price off-peak). Today, 7 days or 30 days in UTC days: the total, the split by input, output and cache, tokens and the share of input served from cache, the spend per day, by agent, by model, and the most expensive sessions. Read from the agents by the Rev4a daemon every 5 minutes.',
52
+ actions: ['Switch between Today, 7 days and 30 days', 'See which agent and which model cost the most', 'Find the most expensive sessions', 'Check which calls could not be priced, by model', 'See whether DeepSeek is billing peak or off-peak right now'],
53
+ hints: '"Against the bill" compares the agents\' figures with what DeepSeek and OpenRouter actually charged (balance or usage, read hourly); a small gap is normal because the same key also pays for this assistant. One agent\'s spend is also in its panel on the Agents page (COSTS). A model listed under "Calls without a price" has no price in Rev4a (it left the catalogue or has none yet): its calls are counted but not in the totals. "Not current" means the daemon could not read that agent recently; the reason is shown next to it.',
54
+ },
49
55
  '/system': {
50
56
  title: 'System',
51
57
  description: 'The machine Rev4a runs on: CPU (percent, cores, load average 1/5/15 min), memory (used, available, swap) and storage (each disk with used and free space, and whether it is the system disk, Docker\'s data or Rev4a\'s data), now and over 1h, 24h, 7d or 30d. Sampled by the Rev4a daemon every 30 seconds, kept 30 days.',
@@ -123,6 +129,7 @@ Navigation links inside Rev4a:
123
129
  - Gateway → /gateway
124
130
  - Cron Jobs → /crons
125
131
  - Containers → /containers
132
+ - Costs (what each agent spent) → /costs
126
133
  - System (CPU, RAM, disk of the server) → /system
127
134
  - Config → /config
128
135
  - Lineage → /lineage?period=7d
@@ -0,0 +1,56 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { openCostsDb } from '@/lib/costs-db';
3
+ import { COLLECT_INTERVAL_S, lastDays, readCostSummary } from '@/lib/agent-costs';
4
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+
6
+ export const dynamic = 'force-dynamic';
7
+
8
+ const CONTAINER_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
9
+
10
+ /**
11
+ * GET /api/costs/agent?container=<name> — one agent's spend for its panel: today, the
12
+ * last 7 and 30 UTC days, its top model and unpriced calls over 30 days, and when it
13
+ * was last read. From costs.db, like the Costs page.
14
+ */
15
+ export async function GET(request: NextRequest): Promise<NextResponse> {
16
+ const denied = await requireAuthJWT(request);
17
+ if (denied) return denied as NextResponse;
18
+
19
+ const container = request.nextUrl.searchParams.get('container') ?? '';
20
+ if (!CONTAINER_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
21
+
22
+ let db;
23
+ try {
24
+ db = openCostsDb();
25
+ } catch (e) {
26
+ return NextResponse.json({ error: `costs.db could not be read: ${(e as Error).message}` }, { status: 500 });
27
+ }
28
+ if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S });
29
+
30
+ try {
31
+ const dates = lastDays(30);
32
+ const s = readCostSummary(db, dates, { container, topSessions: 0 });
33
+ const sum = (n: number) => s.daily.slice(-n).reduce((acc, d) => acc + d.cost, 0);
34
+ // Straight from the sources table: an agent with no use in 30 days is still read.
35
+ const source = db.prepare('SELECT collected_at AS collectedAt, error FROM agent_cost_sources WHERE container = ?')
36
+ .get(container) as { collectedAt: number | null; error: string | null } | undefined;
37
+ const top = s.byModel[0] ?? null;
38
+ const unpriced = s.unpriced.reduce((acc, u) => acc + u.calls, 0);
39
+ return NextResponse.json({
40
+ available: true,
41
+ intervalS: COLLECT_INTERVAL_S,
42
+ today: sum(1),
43
+ week: sum(7),
44
+ month: s.totals.cost,
45
+ tokensMonth: s.totals.tokens,
46
+ topModel: top && top.cost > 0 ? { model: top.model, cost: top.cost } : null,
47
+ unpricedMonth: unpriced,
48
+ collectedAt: source?.collectedAt ?? null,
49
+ error: source?.error ?? null,
50
+ });
51
+ } catch (e) {
52
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
53
+ } finally {
54
+ db.close();
55
+ }
56
+ }
@@ -1,84 +1,26 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
- import { openDb } from '@/lib/db';
3
- import { isBillableDollarModel } from '@/lib/billing';
2
+ import { dashboardCosts } from '@/lib/agent-costs';
4
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
4
 
6
5
  export const dynamic = 'force-dynamic';
7
6
 
8
- // In-memory cache: 30 second TTL for cost aggregation
9
- let costsCache: { data: unknown; ts: number } | null = null;
10
- const CACHE_TTL_MS = 30_000;
11
-
12
- interface ModelCostRow {
13
- model: string | null;
14
- cost_usd: number;
15
- tokens_in: number;
16
- tokens_out: number;
17
- }
18
-
19
- function sumBillableCosts(rows: ModelCostRow[]): number {
20
- return rows.reduce(
21
- (sum, row) => (isBillableDollarModel(row.model) ? sum + Number(row.cost_usd ?? 0) : sum),
22
- 0,
23
- );
24
- }
25
-
26
- function getStartOfDaySeconds(): number {
27
- const now = new Date();
28
- now.setHours(0, 0, 0, 0);
29
- return Math.floor(now.getTime() / 1000);
30
- }
31
-
7
+ /**
8
+ * GET /api/costs — the dashboard's cost card and the Lineage cost panel: today's spend
9
+ * (UTC day), all-time spend and today's spend by model, as the agents' own OpenClaw
10
+ * priced it (costs.db, see lib/agent-costs.ts). The Costs page has the full picture.
11
+ */
32
12
  export async function GET(request: NextRequest): Promise<NextResponse> {
33
- const denied = await requireAuthJWT(request); if (denied) return denied as any;
34
- // Return cached result if fresh
35
- if (costsCache && Date.now() - costsCache.ts < CACHE_TTL_MS) {
36
- return NextResponse.json(costsCache.data);
37
- }
38
-
39
- try {
40
- const db = openDb();
41
- const startOfDay = getStartOfDaySeconds();
42
-
43
- const todayRows = db
44
- .prepare(
45
- `SELECT model,
46
- COALESCE(SUM(cost_usd), 0) AS cost_usd,
47
- COALESCE(SUM(tokens_in), 0) AS tokens_in,
48
- COALESCE(SUM(tokens_out), 0) AS tokens_out
49
- FROM sessions WHERE started_at >= ?
50
- GROUP BY model ORDER BY cost_usd DESC`,
51
- )
52
- .all(startOfDay) as ModelCostRow[];
53
-
54
- const allTimeRows = db
55
- .prepare(
56
- `SELECT model,
57
- COALESCE(SUM(cost_usd), 0) AS cost_usd,
58
- COALESCE(SUM(tokens_in), 0) AS tokens_in,
59
- COALESCE(SUM(tokens_out), 0) AS tokens_out
60
- FROM sessions
61
- GROUP BY model ORDER BY cost_usd DESC`,
62
- )
63
- .all() as ModelCostRow[];
64
-
65
- const today = sumBillableCosts(todayRows);
66
- const sessionsAllTime = sumBillableCosts(allTimeRows);
67
-
68
- const byModel = todayRows.filter((row) => isBillableDollarModel(row.model));
69
-
70
- db.close();
71
-
72
- const result = {
73
- today,
74
- allTime: sessionsAllTime,
75
- allTimeSource: 'sessions_billable',
76
- byModel,
77
- };
78
- costsCache = { data: result, ts: Date.now() };
79
-
80
- return NextResponse.json(result);
81
- } catch (e: unknown) {
82
- return NextResponse.json({ error: (e as Error).message }, { status: 500 });
83
- }
13
+ const denied = await requireAuthJWT(request);
14
+ if (denied) return denied as NextResponse;
15
+
16
+ const costs = dashboardCosts();
17
+ if (costs?.error) return NextResponse.json({ error: `costs.db could not be read: ${costs.error}` }, { status: 500 });
18
+ return NextResponse.json({
19
+ today: costs?.today ?? 0,
20
+ allTime: costs?.allTime ?? 0,
21
+ allTimeSource: 'agents_openclaw',
22
+ missingToday: costs?.missingToday ?? 0,
23
+ // Models that cost something today; the Costs page lists the free and unpriced ones.
24
+ byModel: (costs?.byModelToday ?? []).filter((m) => m.cost > 0).map((m) => ({ model: m.model, cost_usd: m.cost, tokens: m.tokens, calls: m.calls })),
25
+ });
84
26
  }
@@ -0,0 +1,65 @@
1
+ import { NextResponse, type NextRequest } from 'next/server';
2
+ import { openCostsDb } from '@/lib/costs-db';
3
+ import { COLLECT_INTERVAL_S, EMPTY_TOTALS, lastDays, readCostSummary } from '@/lib/agent-costs';
4
+ import { readReconciliation } from '@/lib/cost-reconciliation';
5
+ import { loadOfferedModels } from '@/lib/model-catalogue';
6
+ import { nextPriceChange, priceBandAt, priceScheduleFor } from '@/lib/model-pricing';
7
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
8
+
9
+ export const dynamic = 'force-dynamic';
10
+
11
+ /** Ranges in UTC days, today included. */
12
+ const RANGES = { today: 1, '7d': 7, '30d': 30 } as const;
13
+ type Range = keyof typeof RANGES;
14
+
15
+ /**
16
+ * GET /api/costs/usage?range=today|7d|30d — what the agents spent, as each agent's own
17
+ * OpenClaw priced it (collected by daemon.js into costs.db), and how that compares with
18
+ * what the vendors billed. Nothing here computes a cost.
19
+ */
20
+ export async function GET(request: NextRequest): Promise<NextResponse> {
21
+ const denied = await requireAuthJWT(request);
22
+ if (denied) return denied as NextResponse;
23
+
24
+ const param = request.nextUrl.searchParams.get('range') ?? '7d';
25
+ if (!Object.hasOwn(RANGES, param)) {
26
+ return NextResponse.json({ error: `range must be one of ${Object.keys(RANGES).join(', ')}` }, { status: 400 });
27
+ }
28
+ const range = param as Range;
29
+ const dates = lastDays(RANGES[range]);
30
+ const startDate = dates[0];
31
+ const endDate = dates[dates.length - 1];
32
+
33
+ // The band DeepSeek bills at right now, when a DeepSeek model is on offer.
34
+ const scheduled = loadOfferedModels().map((m) => m.id).filter((id) => priceScheduleFor(id));
35
+ const next = scheduled.length ? nextPriceChange(scheduled) : null;
36
+ const pricing = scheduled.length
37
+ ? { vendor: 'DeepSeek', band: priceBandAt(scheduled[0]), nextChange: next ? next.getTime() : null, source: priceScheduleFor(scheduled[0])!.source }
38
+ : null;
39
+
40
+ let db;
41
+ try {
42
+ db = openCostsDb();
43
+ } catch (e) {
44
+ return NextResponse.json({ error: `costs.db could not be read: ${(e as Error).message}` }, { status: 500 });
45
+ }
46
+ if (!db) {
47
+ return NextResponse.json({
48
+ available: false, range, startDate, endDate, intervalS: COLLECT_INTERVAL_S, pricing,
49
+ totals: EMPTY_TOTALS, daily: dates.map((date) => ({ date, cost: 0, tokens: 0, missing: 0 })),
50
+ byAgent: [], byModel: [], sessions: [], unpriced: [], reconciliation: [],
51
+ });
52
+ }
53
+
54
+ try {
55
+ const summary = readCostSummary(db, dates);
56
+ const reconciliation = readReconciliation(db, startDate, endDate);
57
+ return NextResponse.json({
58
+ available: true, range, startDate, endDate, intervalS: COLLECT_INTERVAL_S, pricing, ...summary, reconciliation,
59
+ });
60
+ } catch (e) {
61
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
62
+ } finally {
63
+ db.close();
64
+ }
65
+ }
@@ -24,6 +24,7 @@ import { activeUpdateAgentIds } from '@/lib/agent-update-state';
24
24
  import { activeRecreateAgentIds } from '@/lib/agent-recreate-state';
25
25
  import { activeRestoreAgentIds } from '@/lib/agent-restore-state';
26
26
  import { activeEditAgentIds } from '@/lib/agent-edit-state';
27
+ import { priceAt, type ModelCost } from '@/lib/model-pricing';
27
28
 
28
29
  const PROVIDER_GATEWAY_BASE_URL = detectProviderGatewayUrl();
29
30
 
@@ -83,11 +84,24 @@ function containerVersionSync(name: string): string | null {
83
84
  return null;
84
85
  }
85
86
 
86
- function getActiveRev4aModels(version?: string | null): { id: string; name: string; input?: string[] }[] {
87
+ /** One model of the `rev4a` block, as OpenClaw's `models.providers.*.models[]` takes it. */
88
+ export interface Rev4aModelEntry { id: string; name: string; input?: string[]; cost?: ModelCost }
89
+
90
+ /**
91
+ * `cost` is what makes OpenClaw price every call itself (per call, session and day, the
92
+ * cache split out): the rates in force now, from model-pricing.json. A model without a
93
+ * price gets none, and OpenClaw counts its calls as "missing cost" instead of $0. A
94
+ * vendor priced by time of day is re-synced at each change of rate
95
+ * (lib/price-schedule-sync.ts); OpenClaw keeps the cost it recorded at call time.
96
+ */
97
+ function getActiveRev4aModels(version?: string | null): Rev4aModelEntry[] {
98
+ const now = new Date();
87
99
  return loadOfferedModels()
88
100
  .map((m) => {
89
- const entry: { id: string; name: string; input?: string[] } = { id: m.id, name: m.name };
101
+ const entry: Rev4aModelEntry = { id: m.id, name: m.name };
90
102
  if (m.modality) entry.input = modalityToInput(m.modality, version);
103
+ const cost = priceAt(m.id, now);
104
+ if (cost) entry.cost = cost;
91
105
  return entry;
92
106
  });
93
107
  }
@@ -244,7 +258,7 @@ function writeContainerJson(container: AgentContainer, remotePath: string, data:
244
258
  * Shared between the create route and syncAllAgents — single source of truth
245
259
  * for the provider block injected into every agent container.
246
260
  */
247
- export function buildRev4aProviderConfig(version?: string | null): { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string; input?: string[] }[] } {
261
+ export function buildRev4aProviderConfig(version?: string | null): { baseUrl: string; apiKey: string; api: string; models: Rev4aModelEntry[] } {
248
262
  const providerKeys = readProviderKeys();
249
263
  const rev4aApiKey = providerKeys['rev4a'] || '';
250
264
  const activeModels = getActiveRev4aModels(version);
@@ -404,7 +418,7 @@ export function syncAllAgents(): SyncOutcome {
404
418
  */
405
419
  function syncContainer(
406
420
  container: AgentContainer,
407
- rev4aProviderConfig: { baseUrl: string; apiKey: string; api: string; models: { id: string; name: string }[] },
421
+ rev4aProviderConfig: { baseUrl: string; apiKey: string; api: string; models: Rev4aModelEntry[] },
408
422
  ): string {
409
423
  const remotePath = '/root/.openclaw/openclaw.json';
410
424
  // Read first. If this throws, the container is reported as failed and nothing
@@ -9,9 +9,8 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
9
9
  try {
10
10
  const db = openDb();
11
11
  const now = new Date();
12
- const startOfMonth = Math.floor(
13
- new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000,
14
- );
12
+ // sessions.started_at is in milliseconds (daemon.js).
13
+ const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
15
14
  const total = db
16
15
  .prepare(
17
16
  `SELECT COALESCE(SUM(cost_usd),0) as total, COALESCE(SUM(tokens_in),0) as total_in,
@@ -11,7 +11,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
11
11
  const db = openDb();
12
12
  const row = db
13
13
  .prepare('SELECT COALESCE(SUM(cost_usd),0) as total FROM sessions WHERE started_at >= ?')
14
- .get(Math.floor(ts / 1000)) as { total: number } | undefined;
14
+ .get(ts) as { total: number } | undefined; // both in milliseconds
15
15
  db.close();
16
16
  return NextResponse.json({ total: row?.total ?? 0 });
17
17
  } catch (e: unknown) {
@@ -2,6 +2,7 @@ import { type NextRequest } from 'next/server';
2
2
  import { DB_PATH } from '@/lib/db';
3
3
  import Database from 'better-sqlite3';
4
4
  import { requireAuthJWT } from '@/lib/rev4a-auth';
5
+ import { dashboardCosts } from '@/lib/agent-costs';
5
6
 
6
7
  export const dynamic = 'force-dynamic';
7
8
 
@@ -32,12 +33,9 @@ export async function GET(request: NextRequest): Promise<Response> {
32
33
  LEFT JOIN lineage l ON s.session_id = l.child_id
33
34
  ORDER BY s.started_at DESC LIMIT 100`)
34
35
  .all();
35
- const costs = db
36
- .prepare('SELECT SUM(cost_usd) AS today FROM sessions WHERE started_at >= ?')
37
- .get(Math.floor(new Date().setHours(0, 0, 0, 0) / 1000)) as
38
- | { today: number | null }
39
- | undefined;
40
36
  db.close();
37
+ // What the agents' OpenClaw priced today (UTC day), like GET /api/costs.
38
+ const costs = dashboardCosts();
41
39
 
42
40
  if (newEvents.length > 0) {
43
41
  lastEventId = newEvents[newEvents.length - 1].id;
@@ -2,13 +2,15 @@ import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { openDb } from '@/lib/db';
3
3
  import { latestMetricSample } from '@/lib/metrics-db';
4
4
  import { maybeSendAlert } from '@/lib/alerts';
5
- import { summarizeBilling } from '@/lib/billing';
5
+ import { dashboardCosts } from '@/lib/agent-costs';
6
6
  import { listOpenClawCronJobs } from '@/lib/openclaw-cron';
7
- import type { ModelCost } from '@/lib/types';
8
7
  import { requireAuthJWT } from '@/lib/rev4a-auth';
9
8
 
10
9
  export const dynamic = 'force-dynamic';
11
10
 
11
+ /** A day above this (US dollars, UTC day) raises the cost check to a warning. */
12
+ const HIGH_DAILY_SPEND_USD = 10;
13
+
12
14
  type Health = 'ok' | 'warning' | 'error';
13
15
  type Severity = 'info' | 'warning' | 'critical';
14
16
 
@@ -146,7 +148,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
146
148
  ? [columnExists(db, 'events', 'type') ? 'type' : null, columnExists(db, 'events', 'event') ? 'event' : null].filter(Boolean).join(", ''), COALESCE(")
147
149
  : '';
148
150
  const eventErrorCount = eventExpr
149
- ? (db.prepare(`SELECT COUNT(*) AS total FROM events WHERE LOWER(COALESCE(${eventExpr}, '')) LIKE '%error%' AND ts >= ?`).get(Math.floor(Date.now() / 1000) - 86400) as { total: number }).total
151
+ ? (db.prepare(`SELECT COUNT(*) AS total FROM events WHERE LOWER(COALESCE(${eventExpr}, '')) LIKE '%error%' AND ts >= ?`).get(Date.now() - 86_400_000) as { total: number }).total // events.ts is in milliseconds
150
152
  : 0;
151
153
  checks.push({
152
154
  id: 'runtime.errors24h',
@@ -168,54 +170,53 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
168
170
  });
169
171
  }
170
172
 
171
- const startOfDay = new Date();
172
- startOfDay.setHours(0, 0, 0, 0);
173
- const todayModels = db.prepare(
174
- `SELECT model,
175
- COALESCE(SUM(cost_usd), 0) AS cost_usd,
176
- COALESCE(SUM(tokens_in), 0) AS tokens_in,
177
- COALESCE(SUM(tokens_out), 0) AS tokens_out,
178
- COUNT(*) AS sessions
179
- FROM sessions WHERE started_at >= ?
180
- GROUP BY model ORDER BY cost_usd DESC`,
181
- ).all(Math.floor(startOfDay.getTime() / 1000)) as ModelCost[];
182
- const billing = summarizeBilling(todayModels);
173
+ db.close();
174
+ } catch (error) {
175
+ checks.push({ id: 'runtime.db', label: 'Rev4a DB', health: 'error', value: 'error', details: (error as Error).message, source: 'runtime' });
176
+ recommendations.push({ id: 'runtime.db-error', severity: 'critical', source: 'runtime', title: 'Rev4a DB unreadable', details: (error as Error).message, dismissible: false, createdAt });
177
+ }
178
+
179
+ // What the agents spent today (UTC day), as their own OpenClaw priced it (costs.db).
180
+ const spend = dashboardCosts();
181
+ if (spend?.error) {
182
+ checks.push({ id: 'cost.usageBasedToday', label: 'Spent today', health: 'warning', value: 'error', details: `costs.db unreadable: ${spend.error}`, source: 'cost' });
183
+ } else {
184
+ const today = spend?.today ?? 0;
185
+ const missing = spend?.missingToday ?? 0;
183
186
  checks.push({
184
187
  id: 'cost.usageBasedToday',
185
- label: 'Usage-based cost today',
186
- health: billing.usageBasedCost > 10 ? 'warning' : 'ok',
187
- value: `$${billing.usageBasedCost.toFixed(2)}`,
188
- details: `DB estimate $${billing.dbEstimatedCost.toFixed(2)}; fixed ${billing.buckets.fixed.sessions} sessions; credits ${billing.buckets.credits.sessions} sessions`,
188
+ label: 'Spent today',
189
+ health: today > HIGH_DAILY_SPEND_USD || missing > 0 ? 'warning' : 'ok',
190
+ value: `$${today.toFixed(2)}`,
191
+ details: spend
192
+ ? `Priced by the agents' OpenClaw, UTC day${missing ? `; ${missing} call(s) without a price` : ''}`
193
+ : 'No figures yet: the daemon reads the agents every 5 min',
189
194
  source: 'cost',
190
195
  });
191
- if (billing.usageBasedCost > 10) {
196
+ if (today > HIGH_DAILY_SPEND_USD) {
192
197
  recommendations.push({
193
198
  id: 'cost.review-usage-based',
194
199
  severity: 'warning',
195
200
  source: 'cost',
196
- title: 'High usage-based cost',
197
- details: `Provider usage-based today: $${billing.usageBasedCost.toFixed(2)}. Counts OpenRouter and the other pay-per-use providers, not the fixed plans.`,
198
- actionHref: '/gateway',
201
+ title: 'High spend today',
202
+ details: `The agents have spent $${today.toFixed(2)} today (UTC).`,
203
+ actionHref: '/costs',
199
204
  dismissible: true,
200
205
  createdAt,
201
206
  });
202
207
  }
203
- if (billing.unknownModels.length > 0 && billing.buckets.unknown.cost_usd > 1) {
208
+ if (missing > 0) {
204
209
  recommendations.push({
205
- id: 'cost.classify-unknown-models',
210
+ id: 'cost.unpriced-calls',
206
211
  severity: 'warning',
207
212
  source: 'cost',
208
- title: 'Unclassified models in billing',
209
- details: `${billing.unknownModels.length} unclassified models have DB estimate $${billing.buckets.unknown.cost_usd.toFixed(2)}.`,
210
- actionHref: '/gateway',
213
+ title: 'Calls without a price',
214
+ details: `${missing} model call(s) today could not be priced: their model has no price in Rev4a, or a dynamic one (OpenRouter Auto).`,
215
+ actionHref: '/costs',
211
216
  dismissible: true,
212
217
  createdAt,
213
218
  });
214
219
  }
215
- db.close();
216
- } catch (error) {
217
- checks.push({ id: 'runtime.db', label: 'Rev4a DB', health: 'error', value: 'error', details: (error as Error).message, source: 'runtime' });
218
- recommendations.push({ id: 'runtime.db-error', severity: 'critical', source: 'runtime', title: 'Rev4a DB unreadable', details: (error as Error).message, dismissible: false, createdAt });
219
220
  }
220
221
 
221
222
  try {
@@ -12,7 +12,7 @@ interface CostBreakdownProps {
12
12
  export default function CostBreakdown({ byModel, loading = false }: CostBreakdownProps) {
13
13
  return (
14
14
  <article className="panel cost-panel">
15
- <h2>Costs by Model (Today)</h2>
15
+ <h2>Spent by Model (Today, UTC)</h2>
16
16
  <div>
17
17
  {loading ? (
18
18
  <div style={{ padding: 12 }}>
@@ -59,6 +59,16 @@ const NAV: NavItem[] = [
59
59
  </svg>
60
60
  ),
61
61
  },
62
+ {
63
+ href: '/costs',
64
+ label: 'Costs',
65
+ icon: (
66
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
67
+ <circle cx="8" cy="8" r="6.5" stroke="currentColor" strokeWidth="1.3" />
68
+ <path d="M10 5.8C9.6 5.3 8.9 5 8 5c-1.2 0-2 .6-2 1.5S6.8 7.8 8 8s2 .6 2 1.5S9.2 11 8 11c-.9 0-1.6-.3-2-.8M8 3.8V5M8 11v1.2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
69
+ </svg>
70
+ ),
71
+ },
62
72
  {
63
73
  href: '/system',
64
74
  label: 'System',