@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.
- package/README.md +2 -2
- package/app/agents/CostSection.tsx +100 -0
- package/app/agents/PageClient.tsx +7 -0
- package/app/api/assistant/route.ts +8 -1
- package/app/api/costs/agent/route.ts +56 -0
- package/app/api/costs/route.ts +19 -77
- package/app/api/costs/usage/route.ts +65 -0
- package/app/api/gateway/sync.ts +18 -4
- package/app/api/stats/route.ts +2 -3
- package/app/api/stats-since/route.ts +1 -1
- package/app/api/stream/route.ts +3 -5
- package/app/api/system-health/route.ts +33 -32
- package/app/components/CostBreakdown.tsx +1 -1
- package/app/components/Sidebar.tsx +10 -0
- package/app/components/SystemCockpit.tsx +1 -1
- package/app/components/ui/TimeSeriesChart.tsx +27 -9
- package/app/costs/CostsSkeleton.tsx +88 -0
- package/app/costs/PageClient.tsx +366 -0
- package/app/costs/loading.tsx +13 -0
- package/app/costs/page.tsx +5 -0
- package/app/globals.css +6 -0
- package/daemon.js +455 -41
- package/docs/ARCHITECTURE.md +39 -29
- package/docs/DESIGN-SYSTEM.md +2 -2
- package/docs/FRONTEND-ARCHITECTURE.md +4 -2
- package/docs/REV4A.md +3 -2
- package/docs/dev/API-REFERENCE.md +118 -22
- package/docs/dev/DATABASE.md +119 -19
- package/docs/dev/GATEWAY.md +53 -9
- package/docs/rag/DATA-FRESHNESS.md +19 -7
- package/docs/rag/GLOSSARY.md +7 -4
- package/docs/rag/REV4A-OVERVIEW.md +4 -1
- package/docs/rag/WHAT-I-CAN-ANSWER.md +3 -3
- package/instrumentation.ts +11 -0
- package/lib/agent-costs.ts +172 -0
- package/lib/cost-reconciliation.ts +78 -0
- package/lib/costs-db.ts +31 -0
- package/lib/model-pricing.ts +123 -3
- package/lib/price-schedule-sync.ts +133 -0
- package/lib/utils/format.ts +12 -0
- package/model-pricing.json +269 -121
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +16 -4
- 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
|
|
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
|
-
- **
|
|
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" />
|
|
@@ -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', '
|
|
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
|
+
}
|
package/app/api/costs/route.ts
CHANGED
|
@@ -1,84 +1,26 @@
|
|
|
1
1
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
-
import {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
package/app/api/gateway/sync.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
package/app/api/stats/route.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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(
|
|
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) {
|
package/app/api/stream/route.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
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: '
|
|
186
|
-
health:
|
|
187
|
-
value: `$${
|
|
188
|
-
details:
|
|
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 (
|
|
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
|
|
197
|
-
details: `
|
|
198
|
-
actionHref: '/
|
|
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 (
|
|
208
|
+
if (missing > 0) {
|
|
204
209
|
recommendations.push({
|
|
205
|
-
id: 'cost.
|
|
210
|
+
id: 'cost.unpriced-calls',
|
|
206
211
|
severity: 'warning',
|
|
207
212
|
source: 'cost',
|
|
208
|
-
title: '
|
|
209
|
-
details: `${
|
|
210
|
-
actionHref: '/
|
|
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>
|
|
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',
|
|
@@ -201,7 +201,7 @@ export default function SystemCockpit({
|
|
|
201
201
|
<Link href="/system" className="ui-link">Open System →</Link>
|
|
202
202
|
</div>
|
|
203
203
|
</Surface>
|
|
204
|
-
<Metric title="
|
|
204
|
+
<Metric title="Spent today" value={String(costCheck?.value ?? fmtMoney(costs.today))} subtitle={costCheck?.details ?? 'Priced by the agents\' OpenClaw, UTC day'} tone="accent" loading={systemLoading} />
|
|
205
205
|
</div>
|
|
206
206
|
|
|
207
207
|
<div className="cockpit__main">
|