@flame0510/project-aether 1.9.1 → 1.11.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 (65) hide show
  1. package/README.md +3 -3
  2. package/app/agents/CostSection.tsx +94 -0
  3. package/app/agents/PageClient.tsx +14 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +10 -3
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +55 -0
  9. package/app/api/costs/route.ts +19 -77
  10. package/app/api/costs/usage/route.ts +65 -0
  11. package/app/api/gateway/sync.ts +18 -4
  12. package/app/api/metrics/alerts/route.ts +47 -0
  13. package/app/api/metrics/containers/route.ts +54 -0
  14. package/app/api/metrics/route.ts +4 -6
  15. package/app/api/stats/route.ts +2 -3
  16. package/app/api/stats-since/route.ts +1 -1
  17. package/app/api/stream/route.ts +3 -5
  18. package/app/api/system-health/route.ts +33 -32
  19. package/app/components/CostBreakdown.tsx +1 -1
  20. package/app/components/Sidebar.tsx +10 -0
  21. package/app/components/SystemCockpit.tsx +1 -1
  22. package/app/components/ui/Accordion.tsx +45 -0
  23. package/app/components/ui/TimeSeriesChart.tsx +47 -11
  24. package/app/components/ui/index.ts +1 -0
  25. package/app/containers/ContainersClient.tsx +48 -6
  26. package/app/costs/CostsSkeleton.tsx +88 -0
  27. package/app/costs/PageClient.tsx +366 -0
  28. package/app/costs/loading.tsx +13 -0
  29. package/app/costs/page.tsx +5 -0
  30. package/app/globals.css +42 -0
  31. package/app/system/AgentCharts.tsx +138 -0
  32. package/app/system/AgentsSection.tsx +281 -0
  33. package/app/system/PageClient.tsx +7 -7
  34. package/app/system/RecentAlerts.tsx +72 -0
  35. package/app/system/SystemSkeleton.tsx +53 -1
  36. package/app/system/loading.tsx +5 -1
  37. package/daemon.js +646 -41
  38. package/docs/ARCHITECTURE.md +72 -31
  39. package/docs/DESIGN-SYSTEM.md +2 -2
  40. package/docs/FRONTEND-ARCHITECTURE.md +14 -3
  41. package/docs/REV4A.md +6 -5
  42. package/docs/dev/API-REFERENCE.md +208 -38
  43. package/docs/dev/DATABASE.md +176 -20
  44. package/docs/dev/GATEWAY.md +53 -9
  45. package/docs/rag/DATA-FRESHNESS.md +34 -7
  46. package/docs/rag/GLOSSARY.md +8 -5
  47. package/docs/rag/REV4A-OVERVIEW.md +10 -3
  48. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
  49. package/instrumentation.ts +11 -0
  50. package/lib/agent-costs.ts +172 -0
  51. package/lib/container-metrics.ts +340 -0
  52. package/lib/cost-reconciliation.ts +78 -0
  53. package/lib/costs-db.ts +31 -0
  54. package/lib/docker-socket-path.js +133 -0
  55. package/lib/docker-socket.ts +10 -99
  56. package/lib/docker-stats.js +284 -0
  57. package/lib/metrics-db.ts +15 -6
  58. package/lib/model-pricing.ts +123 -3
  59. package/lib/price-schedule-sync.ts +133 -0
  60. package/lib/utils/format.ts +38 -0
  61. package/model-pricing.json +269 -121
  62. package/package.json +1 -1
  63. package/scripts/refresh-model-pricing.mjs +16 -4
  64. package/scripts/test-docker-stats.mjs +270 -0
  65. 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
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
21
+ - **System health** — runtime sessions, daemon heartbeat and errors, cron jobs and what the agents spent today, with recommendations
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); what each agent and container consumes (CPU, memory, storage, processes — charts per container) and what Docker holds on disk; the machine's recent alerts
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,94 @@
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
+ import { PanelRow } from './PanelRow';
12
+
13
+ interface AgentCosts {
14
+ available: boolean;
15
+ intervalS: number;
16
+ today?: number;
17
+ week?: number;
18
+ month?: number;
19
+ tokensMonth?: number;
20
+ topModel?: { model: string; cost: number } | null;
21
+ unpricedMonth?: number;
22
+ collectedAt?: number | null;
23
+ error?: string | null;
24
+ }
25
+
26
+
27
+ function formatAge(ms: number): string {
28
+ const min = Math.round(ms / 60_000);
29
+ if (min < 1) return 'just now';
30
+ if (min < 60) return `${min} min ago`;
31
+ const h = Math.round(min / 60);
32
+ return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
33
+ }
34
+
35
+ export default function CostSection({ container }: { container: string }) {
36
+ const [data, setData] = useState<AgentCosts | null>(null);
37
+ const [error, setError] = useState('');
38
+ const abortRef = useRef<AbortController | null>(null);
39
+
40
+ useEffect(() => {
41
+ const load = async () => {
42
+ abortRef.current?.abort();
43
+ const controller = new AbortController();
44
+ abortRef.current = controller;
45
+ try {
46
+ const res = await apiFetch(`/api/costs/agent?container=${encodeURIComponent(container)}`, { signal: controller.signal });
47
+ const body = await res.json();
48
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
49
+ setData(body as AgentCosts);
50
+ setError('');
51
+ } catch (e: any) {
52
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the costs');
53
+ }
54
+ };
55
+ setData(null);
56
+ setError('');
57
+ load();
58
+ const timer = setInterval(load, 60_000);
59
+ return () => {
60
+ clearInterval(timer);
61
+ abortRef.current?.abort();
62
+ };
63
+ }, [container]);
64
+
65
+ if (error) return <div style={{ padding: '12px 14px', fontSize: 12, color: 'var(--red)' }}>{error}</div>;
66
+ if (!data) return <div style={{ padding: '12px 14px', fontSize: 12, color: '#888' }}>Loading…</div>;
67
+ if (!data.available) {
68
+ 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>;
69
+ }
70
+
71
+ const stale = !data.collectedAt || Date.now() - data.collectedAt > data.intervalS * 3 * 1000;
72
+ const rows: [string, string][] = [
73
+ ['Today (UTC)', formatCost(data.today)],
74
+ ['Last 7 days', formatCost(data.week)],
75
+ ['Last 30 days', `${formatCost(data.month)} · ${Math.round((data.tokensMonth ?? 0) / 1000)}k tokens`],
76
+ ['Top model (30 days)', data.topModel ? `${data.topModel.model} · ${formatCost(data.topModel.cost)}` : '-'],
77
+ ];
78
+ if (data.unpricedMonth) rows.push(['Calls without a price (30 days)', String(data.unpricedMonth)]);
79
+ rows.push(['Last read', data.collectedAt ? formatAge(Date.now() - data.collectedAt) : 'never']);
80
+
81
+ return (
82
+ <div>
83
+ {rows.map(([label, value]) => <PanelRow key={label} label={label} value={value} />)}
84
+ {(stale || data.error) && (
85
+ <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
86
+ {data.error ?? 'Not read recently: a stopped agent keeps the figures of its last read.'}
87
+ </div>
88
+ )}
89
+ <div style={{ padding: '8px 14px', fontSize: 11 }}>
90
+ <Link href="/costs" className="ui-link">All agents on the Costs page →</Link>
91
+ </div>
92
+ </div>
93
+ );
94
+ }
@@ -8,6 +8,8 @@ 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';
12
+ import ResourceSection from './ResourceSection';
11
13
  import BrowserAccessSection, { OpenControlUiButton } from './BrowserAccessSection';
12
14
  import UpdateSection from './UpdateSection';
13
15
  import { validatePortInput } from '@/lib/agent-ports';
@@ -1194,6 +1196,18 @@ function DetailPanel({ agent, busy, onDeleted, onChanged, onModelSaved, onAccess
1194
1196
  <ModelSection agentId={agent.agentId} onSaved={onModelSaved} agentBusy={busy} />
1195
1197
  </div>
1196
1198
 
1199
+ {/* Costs - what this agent spent, as its own OpenClaw priced it */}
1200
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1201
+ <SectionHeader icon={<BracesIcon size={12} />} label="COSTS" />
1202
+ <CostSection container={agent.name} />
1203
+ </div>
1204
+
1205
+ {/* Resources - what this agent's container consumes (history on the System page) */}
1206
+ <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1207
+ <SectionHeader icon={<BracesIcon size={12} />} label="RESOURCES" />
1208
+ <ResourceSection container={agent.name} />
1209
+ </div>
1210
+
1197
1211
  {/* Config - View openclaw.json */}
1198
1212
  <div style={{ border: '1px solid var(--border)', borderRadius: 0, background: 'var(--bg)', overflow: 'hidden' }}>
1199
1213
  <SectionHeader icon={<BracesIcon size={12} />} label="CONFIG" />
@@ -0,0 +1,9 @@
1
+ /** One label/value row of an agent-panel section (COSTS and RESOURCES): the label dim on the left, the value right. */
2
+ export function PanelRow({ label, value }: { label: string; value: string }) {
3
+ return (
4
+ <div style={{ display: 'flex', justifyContent: 'space-between', padding: '7px 14px', borderBottom: '1px solid var(--border)', fontSize: 12 }}>
5
+ <span style={{ color: '#888' }}>{label}</span>
6
+ <span style={{ color: 'var(--text)', textAlign: 'right', maxWidth: '60%', wordBreak: 'break-word' }}>{value}</span>
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Resources section of the agent detail panel: what this agent's container consumes now and
5
+ * over the last 24 h (GET /api/metrics/containers, from metrics.db). The history charts are
6
+ * on the System page — one place, linked from here.
7
+ */
8
+ import Link from 'next/link';
9
+ import { useEffect, useRef, useState } from 'react';
10
+ import { apiFetch } from '@/lib/apiFetch';
11
+ import { formatMb, formatRate, formatSharePercent } from '@/lib/utils/format';
12
+ import { PanelRow } from './PanelRow';
13
+
14
+ interface Overview {
15
+ available: boolean;
16
+ interval_s: number;
17
+ age_s?: number | null;
18
+ containers?: {
19
+ container: string;
20
+ running: boolean;
21
+ now: { cpu_cores: number | null; cpu_percent: number | null; mem_mb: number | null; mem_percent: number | null; pids: number | null; net_rx_bps: number | null; net_tx_bps: number | null } | null;
22
+ range: { cpu_avg_percent: number | null; cpu_max_percent: number | null; mem_avg_mb: number | null; mem_max_mb: number | null };
23
+ storage: { volume_mb: number | null; layer_mb: number | null; total_mb: number | null };
24
+ }[];
25
+ }
26
+
27
+ const note = (text: string) => <div style={{ padding: '12px 14px', fontSize: 12, color: '#888' }}>{text}</div>;
28
+
29
+ export default function ResourceSection({ container }: { container: string }) {
30
+ const [data, setData] = useState<Overview | null>(null);
31
+ const [error, setError] = useState('');
32
+ const abortRef = useRef<AbortController | null>(null);
33
+
34
+ useEffect(() => {
35
+ const load = async () => {
36
+ abortRef.current?.abort();
37
+ const controller = new AbortController();
38
+ abortRef.current = controller;
39
+ try {
40
+ const res = await apiFetch('/api/metrics/containers?range=24h', { signal: controller.signal });
41
+ const body = await res.json();
42
+ if (!res.ok) throw new Error(body?.error || `Error ${res.status}`);
43
+ setData(body as Overview);
44
+ setError('');
45
+ } catch (e: any) {
46
+ if (e?.name !== 'AbortError') setError(e?.message || 'Could not load the resources');
47
+ }
48
+ };
49
+ setData(null);
50
+ setError('');
51
+ load();
52
+ const timer = setInterval(load, 60_000);
53
+ return () => {
54
+ clearInterval(timer);
55
+ abortRef.current?.abort();
56
+ };
57
+ }, [container]);
58
+
59
+ if (error && !data) return <div style={{ padding: '12px 14px', fontSize: 12, color: 'var(--red)' }}>{error}</div>;
60
+ if (!data) return note('Loading…');
61
+ if (!data.available) return note(`No samples yet: the Rev4a daemon samples the containers every ${data.interval_s} s.`);
62
+
63
+ const row = data.containers?.find((c) => c.container === container);
64
+ if (!row) return note('Not sampled in the last 24 h: the agent was not running.');
65
+ // The same three-intervals rule as the System page: a daemon that stopped must not present
66
+ // hours-old figures as "now".
67
+ const stale = data.age_s != null && data.age_s > data.interval_s * 3;
68
+ const r = row.range;
69
+ const n = row.now;
70
+
71
+ return (
72
+ <div>
73
+ {error && (
74
+ <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
75
+ Could not refresh ({error}): showing the last answer.
76
+ </div>
77
+ )}
78
+ {stale ? (
79
+ <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
80
+ Not collecting: the latest sample is {Math.round((data.age_s ?? 0) / 60)} min old. Check that the Rev4a daemon is running; the figures below are from when it last answered.
81
+ </div>
82
+ ) : row.running && n ? (
83
+ <>
84
+ <PanelRow label="CPU now" value={`${formatSharePercent(n.cpu_percent)} of Docker's cores${n.cpu_cores !== null ? ` · ${n.cpu_cores.toFixed(3)} cores` : ''}`} />
85
+ <PanelRow label="Memory now" value={`${formatMb(n.mem_mb)}${n.mem_percent !== null ? ` · ${n.mem_percent}% of Docker's memory` : ''}`} />
86
+ <PanelRow label="Processes" value={String(n.pids ?? '—')} />
87
+ <PanelRow label="Network" value={`↓ ${formatRate(n.net_rx_bps)} ↑ ${formatRate(n.net_tx_bps)}`} />
88
+ </>
89
+ ) : (
90
+ <div style={{ padding: '8px 14px', fontSize: 11, color: 'var(--yellow)', borderBottom: '1px solid var(--border)' }}>
91
+ Stopped: the figures below are from when it last ran.
92
+ </div>
93
+ )}
94
+ <PanelRow label="CPU · last 24 h" value={`avg ${formatSharePercent(r.cpu_avg_percent)} · peak ${formatSharePercent(r.cpu_max_percent)}`} />
95
+ <PanelRow label="Memory · last 24 h" value={`avg ${formatMb(r.mem_avg_mb)} · peak ${formatMb(r.mem_max_mb)}`} />
96
+ <PanelRow
97
+ label="Storage"
98
+ value={`${formatMb(row.storage.total_mb)}${row.storage.volume_mb !== null ? ` · volume ${formatMb(row.storage.volume_mb)}` : ''}${row.storage.layer_mb !== null ? ` · layer ${formatMb(row.storage.layer_mb)}` : ''}`}
99
+ />
100
+ <div style={{ padding: '8px 14px', fontSize: 11 }}>
101
+ <Link href={`/system?agent=${encodeURIComponent(container)}`} className="ui-link">Charts on the System page →</Link>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
@@ -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,16 +46,22 @@ 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.',
52
58
  actions: ['Read the current CPU, memory, swap and disk usage', 'Switch the charts between 1h, 24h, 7d and 30d', 'Hover a chart (or focus it and use the arrow keys) to read the average and peak at a point', 'Open a chart\'s Table view for the numbers'],
53
- hints: 'Bars turn yellow then red past their thresholds: CPU and memory at 85% and 95%, disks at 80% and 90%. If the page says "Not collecting", the Rev4a daemon is not sampling: check that rev4a serve is running.',
59
+ hints: 'Below the charts, "Agents and containers" shows what Docker holds on disk and one row per container (sorted by CPU) with its CPU share, memory, storage and processes; open a row for its own CPU, Memory and Storage charts, which follow the same range. "Recent alerts" lists what crossed its limit and when. Bars turn yellow then red past their thresholds: CPU and memory at 85% and 95%, disks at 80% and 90%. If the page says "Not collecting", the Rev4a daemon is not sampling: check that rev4a serve is running.',
54
60
  },
55
61
  '/containers': {
56
62
  title: 'Containers',
57
63
  description: 'List of all Docker containers on the server, running or stopped (not just agents). Shows container ID, name, image, status, state, ports, and IP address.',
58
- actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports (no CPU or memory figures are shown)'],
64
+ actions: ['View every container, running or stopped, with its details', 'Click "Terminal" on any container to open an interactive shell', 'Check each container\'s status, image, IP and ports, and what a running one consumes now (CPU share, memory, processes), with a link to its charts on the System page'],
59
65
  hints: 'Containers marked with an agentId belong to OpenClaw agents. Others are infrastructure containers like databases or reverse proxies.',
60
66
  },
61
67
  '/containers/terminal/[id]': {
@@ -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
@@ -2,6 +2,7 @@ import { type NextRequest } from "next/server";
2
2
  import { NextResponse } from 'next/server';
3
3
  import { requireAuthJWT } from '@/lib/rev4a-auth';
4
4
  import { dockerAvailable, dockerFetch } from '@/lib/docker-socket';
5
+ import { primaryName } from '@/lib/docker-stats';
5
6
 
6
7
  export const dynamic = 'force-dynamic';
7
8
 
@@ -22,7 +23,9 @@ export async function GET(request: NextRequest) {
22
23
  const ip = Object.values(networks)[0]?.IPAddress || null;
23
24
  return {
24
25
  id: c.Id?.slice(0, 12),
25
- name: (c.Names?.[0] || '').replace(/^\//, ''),
26
+ // The daemon's rule (the name with one leading slash), so the usage row and the
27
+ // /system?agent= link key on the same name the metrics store uses.
28
+ name: primaryName(c?.Names),
26
29
  image: c.Image,
27
30
  status: c.Status,
28
31
  state: c.State,
@@ -33,7 +36,20 @@ export async function GET(request: NextRequest) {
33
36
  created: c.Labels?.created || null,
34
37
  };
35
38
  } catch {
36
- return { id: c.Id?.slice(0, 12), name: (c.Names?.[0] || '').replace(/^\//, ''), status: c.Status, error: 'inspect failed' };
39
+ // Identity comes from the list: a failed inspect must neither drop the container nor
40
+ // give it a name the metrics store does not key on. Only the inspect-only fields are lost.
41
+ return {
42
+ id: c.Id?.slice(0, 12),
43
+ name: primaryName(c?.Names),
44
+ image: c.Image,
45
+ status: c.Status,
46
+ state: c.State,
47
+ ports: c.Ports?.map((p: any) => `${p.PublicPort || '?'}:${p.PrivatePort || '?'}`).join(', ') || '',
48
+ ip: null,
49
+ agentId: c.Labels?.AGENT_ID || null,
50
+ displayName: null,
51
+ created: c.Labels?.created || null,
52
+ };
37
53
  }
38
54
  }));
39
55
 
@@ -0,0 +1,55 @@
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 { CONTAINER_NAME_RE } from '@/lib/docker-stats';
5
+ import { requireAuthJWT } from '@/lib/rev4a-auth';
6
+
7
+ export const dynamic = 'force-dynamic';
8
+
9
+ /**
10
+ * GET /api/costs/agent?container=<name> — one agent's spend for its panel: today, the
11
+ * last 7 and 30 UTC days, its top model and unpriced calls over 30 days, and when it
12
+ * was last read. From costs.db, like the Costs page.
13
+ */
14
+ export async function GET(request: NextRequest): Promise<NextResponse> {
15
+ const denied = await requireAuthJWT(request);
16
+ if (denied) return denied as NextResponse;
17
+
18
+ const container = request.nextUrl.searchParams.get('container') ?? '';
19
+ if (!CONTAINER_NAME_RE.test(container)) return NextResponse.json({ error: 'Invalid container name' }, { status: 400 });
20
+
21
+ let db;
22
+ try {
23
+ db = openCostsDb();
24
+ } catch (e) {
25
+ return NextResponse.json({ error: `costs.db could not be read: ${(e as Error).message}` }, { status: 500 });
26
+ }
27
+ if (!db) return NextResponse.json({ available: false, intervalS: COLLECT_INTERVAL_S });
28
+
29
+ try {
30
+ const dates = lastDays(30);
31
+ const s = readCostSummary(db, dates, { container, topSessions: 0 });
32
+ const sum = (n: number) => s.daily.slice(-n).reduce((acc, d) => acc + d.cost, 0);
33
+ // Straight from the sources table: an agent with no use in 30 days is still read.
34
+ const source = db.prepare('SELECT collected_at AS collectedAt, error FROM agent_cost_sources WHERE container = ?')
35
+ .get(container) as { collectedAt: number | null; error: string | null } | undefined;
36
+ const top = s.byModel[0] ?? null;
37
+ const unpriced = s.unpriced.reduce((acc, u) => acc + u.calls, 0);
38
+ return NextResponse.json({
39
+ available: true,
40
+ intervalS: COLLECT_INTERVAL_S,
41
+ today: sum(1),
42
+ week: sum(7),
43
+ month: s.totals.cost,
44
+ tokensMonth: s.totals.tokens,
45
+ topModel: top && top.cost > 0 ? { model: top.model, cost: top.cost } : null,
46
+ unpricedMonth: unpriced,
47
+ collectedAt: source?.collectedAt ?? null,
48
+ error: source?.error ?? null,
49
+ });
50
+ } catch (e) {
51
+ return NextResponse.json({ error: (e as Error).message }, { status: 500 });
52
+ } finally {
53
+ db.close();
54
+ }
55
+ }
@@ -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