@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Data Freshness in Rev4a
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-27
|
|
4
4
|
|
|
5
5
|
Understanding how current the data in Rev4a is — and what is truly real-time vs. periodically updated.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ Understanding how current the data in Rev4a is — and what is truly real-time v
|
|
|
11
11
|
**Update frequency:** every 30 seconds
|
|
12
12
|
|
|
13
13
|
The daemon polls `openclaw sessions --json --all-agents` on a fixed 30-second
|
|
14
|
-
timer. Session statuses
|
|
14
|
+
timer. Session statuses and token counts are therefore up to 30 seconds
|
|
15
15
|
behind reality.
|
|
16
16
|
|
|
17
17
|
The interval is fixed: it does not speed up while a session is `working`. Do not tell
|
|
@@ -56,12 +56,24 @@ the collector is not running instead of showing stale numbers as current.
|
|
|
56
56
|
|
|
57
57
|
---
|
|
58
58
|
|
|
59
|
-
##
|
|
59
|
+
## Agent costs (Costs page)
|
|
60
60
|
|
|
61
|
-
**Update frequency:** every
|
|
61
|
+
**Update frequency:** every 5 minutes. The Rev4a daemon asks each running agent
|
|
62
|
+
what it spent and keeps the figures in their own database (`costs.db`); the page
|
|
63
|
+
refreshes every minute. An agent whose figures could not be read recently is listed
|
|
64
|
+
under **Not current** with the reason.
|
|
62
65
|
|
|
63
|
-
The
|
|
64
|
-
|
|
66
|
+
The costs are computed by each agent's own OpenClaw, with the prices Rev4a writes
|
|
67
|
+
into the agent for every model. A call to a model without a price is counted but not
|
|
68
|
+
priced: the page lists those under **Calls without a price**. Days are UTC. DeepSeek
|
|
69
|
+
bills half price off-peak; Rev4a switches the agents' prices at each change, so each
|
|
70
|
+
call is priced at the rate it ran at.
|
|
71
|
+
|
|
72
|
+
## Cost totals (dashboard)
|
|
73
|
+
|
|
74
|
+
The dashboard's "Spent today" card and "Spent by model" list show the same figures as the
|
|
75
|
+
Costs page, for today (UTC day): up to 5 minutes behind the agents. The vendor readings
|
|
76
|
+
behind *Against the bill* on the Costs page are taken once an hour.
|
|
65
77
|
|
|
66
78
|
**Cost override:** `/api/cost-override` exists, but no page in the dashboard
|
|
67
79
|
reads or writes it. There is no UI for setting one, so do not direct a user to
|
|
@@ -143,7 +155,7 @@ The tracked set is `USER.md`, `MEMORY.md`, `AGENTS.md`, `SOUL.md`, `HEARTBEAT.md
|
|
|
143
155
|
|
|
144
156
|
System health is a card on the **Dashboard**, not a page of its own. Its checks
|
|
145
157
|
cover session runtime, the daemon heartbeat, errors in the last 24 hours, today's
|
|
146
|
-
|
|
158
|
+
what the agents spent today, and cron jobs. CPU, RAM and disk are on the System page (`/system`)
|
|
147
159
|
and the Dashboard's Machine card instead.
|
|
148
160
|
|
|
149
161
|
To check directly on the server:
|
package/docs/rag/GLOSSARY.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Rev4a Glossary
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-27
|
|
4
4
|
|
|
5
5
|
Terms you'll encounter while using the Rev4a dashboard.
|
|
6
6
|
|
|
@@ -31,7 +31,10 @@ A communication channel (Telegram) configured on an agent. Channels allow users
|
|
|
31
31
|
Modal in the agent detail panel for configuring Telegram channels. Connect with bot token, restart agent, manage pairings (approve pending codes, revoke approved senders). Pairings are polled every 5 seconds with automatic cancellation of stale requests via AbortController.
|
|
32
32
|
|
|
33
33
|
## Cost
|
|
34
|
-
|
|
34
|
+
What an agent spent in USD. Each agent's OpenClaw prices every model call itself, with the prices Rev4a writes into it (input, output, cached input), and Rev4a reads those figures every 5 minutes. The Costs page (`/costs`) shows them for today, 7 or 30 days — by day, by agent, by model, and the most expensive sessions. The dashboard's "Spent today" card shows the same figures for today, and each agent's panel on the Agents page has a COSTS section with its own. The Costs page also compares the agents' figures with what DeepSeek and OpenRouter actually billed (their balance or usage, read every hour).
|
|
35
|
+
|
|
36
|
+
## Off-peak
|
|
37
|
+
DeepSeek bills half price outside its peak hours (peak: 01:00–04:00 and 06:00–10:00 UTC, Monday–Friday). Rev4a writes the lower prices into the agents when off-peak starts and the full ones when it ends, so each call is priced at the rate in force when it ran. The Costs page header shows the current band.
|
|
35
38
|
|
|
36
39
|
## Cost Override
|
|
37
40
|
A manual correction for a month's total cost, stored through `/api/cost-override`. There is currently no screen for it: no page in the dashboard reads or writes an override, so it can only be set by calling the API directly.
|
|
@@ -85,7 +88,7 @@ Replace an agent's persistent volume with a previously created backup. The conta
|
|
|
85
88
|
Undo an agent's latest OpenClaw update: the backup taken just before the update is put back and the agent starts again on its previous version. Anything the agent did after the update is lost. Offered in the agent's OPENCLAW VERSION section while that backup exists.
|
|
86
89
|
|
|
87
90
|
## Session
|
|
88
|
-
One instance of an agent doing work. Tracks: start/end time, tokens used,
|
|
91
|
+
One instance of an agent doing work. Tracks: start/end time, tokens used, model, status, and task description; what a session cost is on the Costs page (most expensive sessions). A session starts when an agent receives a task and ends when it completes or fails.
|
|
89
92
|
|
|
90
93
|
## Pairing
|
|
91
94
|
A security mechanism for Telegram channels. When a user sends `/start` to the bot, a 4-digit pairing code is generated. An admin must approve this code in the Channel Manager to add the user to the allowlist. Approved senders can be revoked at any time.
|
|
@@ -134,6 +137,6 @@ The directory where an agent's operational files live (config, memory files, ski
|
|
|
134
137
|
The page with the machine Rev4a runs on: CPU (with cores and load), memory and swap, and each disk's used and free space, now and over 1 hour, 24 hours, 7 days or 30 days. A bar turns yellow and then red when it reaches a threshold (CPU and memory 85 % / 95 %, disk 80 % / 90 %), and the level is written next to it.
|
|
135
138
|
|
|
136
139
|
## System Health
|
|
137
|
-
A card on the Dashboard reporting whether Rev4a itself is working, not the hardware it runs on. `/api/system-health` returns checks for session runtime, the daemon heartbeat (how old its latest machine sample is), errors in the last 24 hours, today's
|
|
140
|
+
A card on the Dashboard reporting whether Rev4a itself is working, not the hardware it runs on. `/api/system-health` returns checks for session runtime, the daemon heartbeat (how old its latest machine sample is), errors in the last 24 hours, today's what the agents spent today, and cron jobs, each with a health of `ok`, `warning` or `error`, plus a list of recommendations.
|
|
138
141
|
|
|
139
142
|
It reports no CPU, RAM, disk or load average: those are on the System page (`/system`), sampled every 30 seconds by the daemon and kept for 30 days.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What is Rev4a?
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-27
|
|
4
4
|
|
|
5
5
|
Rev4a is the control panel for your AI agent infrastructure. It shows you everything your agents are doing, how much they cost, and whether the system is healthy — all in one dashboard.
|
|
6
6
|
|
|
@@ -81,6 +81,9 @@ Full list of all Docker containers on the server, including stopped ones. Each r
|
|
|
81
81
|
|
|
82
82
|
The page shows no CPU or memory figures and has no start, stop, or restart buttons: it is read-only apart from the terminal link. Container lifecycle is managed from the Agents page.
|
|
83
83
|
|
|
84
|
+
### Costs (`/costs`)
|
|
85
|
+
What each agent spent, as its own OpenClaw priced it with the prices Rev4a syncs. Pick today, 7 days or 30 days (UTC days): the total, where the money went (input, output, cache), the tokens and how much input came from cache, a chart of the spend per day, the spend by agent and by model, and the most expensive sessions. Calls that could not be priced are listed by model, and agents whose figures are not current are flagged. When a DeepSeek model is on offer, the header shows whether DeepSeek is billing peak or off-peak (half price) and until when. Figures are read from the agents every 5 minutes. *Against the bill* compares them with what DeepSeek and OpenRouter actually charged (their balance or usage, read every hour); a small gap is normal, since the same key may pay for the assistant too. Each agent's own spend is also in its panel on the Agents page (COSTS).
|
|
86
|
+
|
|
84
87
|
### System (`/system`)
|
|
85
88
|
The machine Rev4a runs on. Three cards — CPU (percent, cores, load average), memory (used, available, swap) and storage (each disk with its used and free space, and whether it is the system disk, where Docker keeps agents, or where Rev4a keeps its data) — then charts of CPU, memory and each disk over 1 hour, 24 hours, 7 days or 30 days (for CPU and memory the average line with the peak shaded). Hover a chart (or focus it and use the arrow keys) to read a point; each chart has a Table view. Bars turn yellow and red when they reach their thresholds. Figures refresh every 30 seconds; history is kept 30 days.
|
|
86
89
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# What PULSE Can Answer
|
|
2
2
|
|
|
3
|
-
> **Last updated:** 2026-09-
|
|
3
|
+
> **Last updated:** 2026-09-27
|
|
4
4
|
|
|
5
5
|
PULSE is the in-dashboard AI concierge for Rev4a. This document defines what she can and cannot answer.
|
|
6
6
|
|
|
@@ -20,7 +20,7 @@ PULSE is the in-dashboard AI concierge for Rev4a. This document defines what she
|
|
|
20
20
|
- "How does the custom agent bootstrap work?"
|
|
21
21
|
- "Where is the lineage graph?"
|
|
22
22
|
- "How do I open the file explorer?"
|
|
23
|
-
- "Which page shows costs?"
|
|
23
|
+
- "Which page shows costs?" — The Costs page (`/costs`): what each agent spent, by day, agent, model and session.
|
|
24
24
|
- "Where do I manage credentials (GitHub, Trello, etc.)?"
|
|
25
25
|
- "How do I connect an agent to Telegram?"
|
|
26
26
|
- "How do I approve a Telegram pairing?"
|
|
@@ -93,7 +93,7 @@ PULSE is the in-dashboard AI concierge for Rev4a. This document defines what she
|
|
|
93
93
|
## Troubleshooting Questions
|
|
94
94
|
|
|
95
95
|
- "Why is my agent not showing up?"
|
|
96
|
-
- "Why are costs missing for some sessions?"
|
|
96
|
+
- "Why are costs missing for some sessions?" — On the Costs page, calls to a model without a price are listed under "Calls without a price": the model left the catalogue or has no price yet.
|
|
97
97
|
- "Why can't I connect to the container terminal?"
|
|
98
98
|
- "Why is the gateway sync not working?"
|
|
99
99
|
- "How do I check if the daemon is running?"
|
package/instrumentation.ts
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export async function register() {
|
|
9
9
|
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
|
10
|
+
let startupSynced = false;
|
|
10
11
|
try {
|
|
11
12
|
const { syncAllAgents } = await import('./app/api/gateway/sync');
|
|
12
13
|
// The outcome used to be discarded. syncAllAgents reports failures instead of
|
|
13
14
|
// throwing, so the catch below never saw a sync that reached no container —
|
|
14
15
|
// Docker not yet up at boot left the fleet stale with nothing in the log.
|
|
15
16
|
const outcome = syncAllAgents();
|
|
17
|
+
startupSynced = outcome.ok;
|
|
16
18
|
if (outcome.ok) {
|
|
17
19
|
console.log(`[rev4a] Startup sync: ${outcome.summary}`);
|
|
18
20
|
} else {
|
|
@@ -22,6 +24,15 @@ export async function register() {
|
|
|
22
24
|
// Best-effort — don't block startup if sync fails
|
|
23
25
|
console.warn('[rev4a] Failed to sync agents on startup');
|
|
24
26
|
}
|
|
27
|
+
// Prices that change with the time of day (DeepSeek off-peak): write the new rates
|
|
28
|
+
// into every agent at each change, so OpenClaw prices each call at the rate it ran at.
|
|
29
|
+
// A startup sync that missed an agent is written again by the first check.
|
|
30
|
+
try {
|
|
31
|
+
const { startPriceScheduleSync } = await import('./lib/price-schedule-sync');
|
|
32
|
+
startPriceScheduleSync(startupSynced);
|
|
33
|
+
} catch {
|
|
34
|
+
console.warn('[rev4a] Could not start the price schedule re-sync');
|
|
35
|
+
}
|
|
25
36
|
// Updates cut off by the restart: no job runs in a fresh process, so any update
|
|
26
37
|
// still marked active becomes `interrupted` and waits for the operator.
|
|
27
38
|
try {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the agents spent, read from costs.db — the figures each agent's own OpenClaw
|
|
3
|
+
* recorded, copied by daemon.js (see docs/dev/DATABASE.md, Costs Database). Nothing here
|
|
4
|
+
* computes a cost. The spend queries of every reader live here — the Costs page, the
|
|
5
|
+
* dashboard (GET /api/costs, the SSE stream, the system-health cost check) and the
|
|
6
|
+
* agent panel; the vendor readings are in lib/cost-reconciliation.ts.
|
|
7
|
+
*
|
|
8
|
+
* Days are UTC dates, as OpenClaw buckets them.
|
|
9
|
+
*/
|
|
10
|
+
import type Database from 'better-sqlite3';
|
|
11
|
+
import { openCostsDb } from './costs-db';
|
|
12
|
+
|
|
13
|
+
/** How often daemon.js reads the agents; a figure older than 3× this is not current. */
|
|
14
|
+
export const COLLECT_INTERVAL_S = 300;
|
|
15
|
+
|
|
16
|
+
export const utcDate = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
|
17
|
+
|
|
18
|
+
/** The UTC dates of the last `days` days, today included, oldest first. */
|
|
19
|
+
export function lastDays(days: number, now = Date.now()): string[] {
|
|
20
|
+
return Array.from({ length: days }, (_, i) => utcDate(now - (days - 1 - i) * 86_400_000));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CostTotals {
|
|
24
|
+
cost: number; tokens: number; input: number; output: number; cacheRead: number; cacheWrite: number;
|
|
25
|
+
inputCost: number; outputCost: number; cacheReadCost: number; cacheWriteCost: number; missing: number;
|
|
26
|
+
}
|
|
27
|
+
export interface AgentCostRow {
|
|
28
|
+
container: string; agentId: string | null; name: string | null; cost: number; tokens: number; missing: number;
|
|
29
|
+
collectedAt: number | null; attemptedAt: number | null; error: string | null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Rows without tokens or cost are left out: OpenClaw also counts internal entries as
|
|
33
|
+
* models (`gateway-injected`, messages the Gateway adds itself) that cost nothing.
|
|
34
|
+
*/
|
|
35
|
+
export interface ModelCostRow { provider: string; model: string; cost: number; tokens: number; calls: number }
|
|
36
|
+
export interface SessionCostRow {
|
|
37
|
+
container: string; agentName: string | null; sessionId: string; sessionKey: string | null; label: string | null;
|
|
38
|
+
agentId: string | null; model: string | null; cost: number; tokens: number; missing: number; lastActivity: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface CostSummary {
|
|
41
|
+
totals: CostTotals;
|
|
42
|
+
daily: { date: string; cost: number; tokens: number; missing: number }[];
|
|
43
|
+
byAgent: AgentCostRow[];
|
|
44
|
+
byModel: ModelCostRow[];
|
|
45
|
+
sessions: SessionCostRow[];
|
|
46
|
+
/** Calls OpenClaw could not price, by model, over each agent's last 30 days. */
|
|
47
|
+
unpriced: { model: string; calls: number; agents: string[] }[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface SourceRow {
|
|
51
|
+
container: string; agent_id: string | null; name: string | null;
|
|
52
|
+
collected_at: number | null; attempted_at: number | null; error: string | null; missing_by_model: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const EMPTY_TOTALS: CostTotals = {
|
|
56
|
+
cost: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
57
|
+
inputCost: 0, outputCost: 0, cacheReadCost: 0, cacheWriteCost: 0, missing: 0,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const TOTALS_SQL = `
|
|
61
|
+
SELECT COALESCE(SUM(cost), 0) AS cost, COALESCE(SUM(total_tokens), 0) AS tokens,
|
|
62
|
+
COALESCE(SUM(input), 0) AS input, COALESCE(SUM(output), 0) AS output,
|
|
63
|
+
COALESCE(SUM(cache_read), 0) AS cacheRead, COALESCE(SUM(cache_write), 0) AS cacheWrite,
|
|
64
|
+
COALESCE(SUM(input_cost), 0) AS inputCost, COALESCE(SUM(output_cost), 0) AS outputCost,
|
|
65
|
+
COALESCE(SUM(cache_read_cost), 0) AS cacheReadCost, COALESCE(SUM(cache_write_cost), 0) AS cacheWriteCost,
|
|
66
|
+
COALESCE(SUM(missing), 0) AS missing
|
|
67
|
+
FROM agent_cost_daily`;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Everything the Costs page shows for a range of UTC dates. `container` narrows it to
|
|
71
|
+
* one agent (the agent panel).
|
|
72
|
+
*/
|
|
73
|
+
export function readCostSummary(
|
|
74
|
+
db: Database.Database,
|
|
75
|
+
dates: string[],
|
|
76
|
+
opts: { container?: string; topSessions?: number } = {},
|
|
77
|
+
): CostSummary {
|
|
78
|
+
const startDate = dates[0];
|
|
79
|
+
const endDate = dates[dates.length - 1];
|
|
80
|
+
const where = `date BETWEEN @startDate AND @endDate${opts.container ? ' AND container = @container' : ''}`;
|
|
81
|
+
const params = { startDate, endDate, container: opts.container ?? null };
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
|
|
84
|
+
const totals = db.prepare(`${TOTALS_SQL} WHERE ${where}`).get(params) as CostTotals;
|
|
85
|
+
|
|
86
|
+
const dailyRows = db.prepare(`
|
|
87
|
+
SELECT date, SUM(cost) AS cost, SUM(total_tokens) AS tokens, SUM(missing) AS missing
|
|
88
|
+
FROM agent_cost_daily WHERE ${where} GROUP BY date`).all(params) as CostSummary['daily'];
|
|
89
|
+
const byDate = new Map(dailyRows.map((r) => [r.date, r]));
|
|
90
|
+
const daily = dates.map((date) => byDate.get(date) ?? { date, cost: 0, tokens: 0, missing: 0 });
|
|
91
|
+
|
|
92
|
+
const sources = (db.prepare('SELECT * FROM agent_cost_sources').all() as SourceRow[])
|
|
93
|
+
.filter((s) => !opts.container || s.container === opts.container);
|
|
94
|
+
const spend = new Map(
|
|
95
|
+
(db.prepare(`
|
|
96
|
+
SELECT container, SUM(cost) AS cost, SUM(total_tokens) AS tokens, SUM(missing) AS missing
|
|
97
|
+
FROM agent_cost_daily WHERE ${where} GROUP BY container`).all(params) as
|
|
98
|
+
{ container: string; cost: number; tokens: number; missing: number }[]).map((r) => [r.container, r]),
|
|
99
|
+
);
|
|
100
|
+
const byAgent = sources
|
|
101
|
+
.map((s) => ({
|
|
102
|
+
container: s.container,
|
|
103
|
+
agentId: s.agent_id,
|
|
104
|
+
name: s.name,
|
|
105
|
+
cost: spend.get(s.container)?.cost ?? 0,
|
|
106
|
+
tokens: spend.get(s.container)?.tokens ?? 0,
|
|
107
|
+
missing: spend.get(s.container)?.missing ?? 0,
|
|
108
|
+
collectedAt: s.collected_at,
|
|
109
|
+
attemptedAt: s.attempted_at,
|
|
110
|
+
error: s.error,
|
|
111
|
+
}))
|
|
112
|
+
.filter((a) => a.tokens > 0 || a.missing > 0 || a.error || (a.attemptedAt ?? 0) > now - 3 * COLLECT_INTERVAL_S * 1000)
|
|
113
|
+
.sort((a, b) => b.cost - a.cost || b.tokens - a.tokens);
|
|
114
|
+
|
|
115
|
+
const byModel = db.prepare(`
|
|
116
|
+
SELECT provider, model, SUM(cost) AS cost, SUM(tokens) AS tokens, SUM(calls) AS calls
|
|
117
|
+
FROM agent_cost_model_daily WHERE ${where}
|
|
118
|
+
GROUP BY provider, model HAVING SUM(tokens) > 0 OR SUM(cost) > 0 ORDER BY cost DESC, tokens DESC`).all(params) as ModelCostRow[];
|
|
119
|
+
|
|
120
|
+
const names = new Map(sources.map((s) => [s.container, s.name]));
|
|
121
|
+
const since = Date.parse(`${startDate}T00:00:00Z`);
|
|
122
|
+
const sessions = (db.prepare(`
|
|
123
|
+
SELECT container, session_id AS sessionId, session_key AS sessionKey, label, agent_id AS agentId, model,
|
|
124
|
+
cost, tokens, missing, last_activity AS lastActivity
|
|
125
|
+
FROM agent_cost_sessions WHERE last_activity >= @since${opts.container ? ' AND container = @container' : ''}
|
|
126
|
+
ORDER BY cost DESC, tokens DESC LIMIT @limit`).all({ since, container: opts.container ?? null, limit: opts.topSessions ?? 20 }) as
|
|
127
|
+
Omit<SessionCostRow, 'agentName'>[])
|
|
128
|
+
.map((s) => ({ ...s, agentName: names.get(s.container) ?? null }));
|
|
129
|
+
|
|
130
|
+
const unpricedMap = new Map<string, { model: string; calls: number; agents: string[] }>();
|
|
131
|
+
for (const s of sources) {
|
|
132
|
+
let parsed: Record<string, number> = {};
|
|
133
|
+
try { parsed = JSON.parse(s.missing_by_model || '{}'); } catch { /* keep empty */ }
|
|
134
|
+
for (const [model, calls] of Object.entries(parsed)) {
|
|
135
|
+
if (!calls) continue;
|
|
136
|
+
const entry = unpricedMap.get(model) ?? { model, calls: 0, agents: [] };
|
|
137
|
+
entry.calls += calls;
|
|
138
|
+
entry.agents.push(s.name ?? s.container);
|
|
139
|
+
unpricedMap.set(model, entry);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const unpriced = [...unpricedMap.values()].sort((a, b) => b.calls - a.calls);
|
|
143
|
+
|
|
144
|
+
return { totals, daily, byAgent, byModel, sessions, unpriced };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Today's spend (UTC day) and all-time spend, by model today — for the dashboard. Null
|
|
149
|
+
* when there is nothing to read yet; never throws, so a broken costs.db cannot fail a
|
|
150
|
+
* page about something else (the reason is in `error`).
|
|
151
|
+
*/
|
|
152
|
+
export function dashboardCosts(): {
|
|
153
|
+
today: number; allTime: number; missingToday: number;
|
|
154
|
+
byModelToday: ModelCostRow[]; error?: string;
|
|
155
|
+
} | null {
|
|
156
|
+
let db: Database.Database | null = null;
|
|
157
|
+
try {
|
|
158
|
+
db = openCostsDb();
|
|
159
|
+
if (!db) return null;
|
|
160
|
+
const today = utcDate(Date.now());
|
|
161
|
+
const t = db.prepare(`${TOTALS_SQL} WHERE date = ?`).get(today) as CostTotals;
|
|
162
|
+
const all = db.prepare(`${TOTALS_SQL}`).get() as CostTotals;
|
|
163
|
+
const byModelToday = db.prepare(`
|
|
164
|
+
SELECT provider, model, SUM(cost) AS cost, SUM(tokens) AS tokens, SUM(calls) AS calls
|
|
165
|
+
FROM agent_cost_model_daily WHERE date = ? GROUP BY provider, model HAVING SUM(tokens) > 0 OR SUM(cost) > 0 ORDER BY cost DESC, tokens DESC`).all(today) as ModelCostRow[];
|
|
166
|
+
return { today: t.cost, allTime: all.cost, missingToday: t.missing, byModelToday };
|
|
167
|
+
} catch (e) {
|
|
168
|
+
return { today: 0, allTime: 0, missingToday: 0, byModelToday: [], error: (e as Error).message };
|
|
169
|
+
} finally {
|
|
170
|
+
db?.close();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check against the bill: what each vendor charged, next to what the agents' OpenClaw
|
|
3
|
+
* priced, over the same interval. daemon.js writes `vendor_balance` rows in costs.db, each
|
|
4
|
+
* holding the vendor's figure and the agents' cumulative priced total for that vendor's
|
|
5
|
+
* models, read at the same moment. Between two rows:
|
|
6
|
+
*
|
|
7
|
+
* - billed — DeepSeek: the balance drops (a rise is a top-up, counted apart);
|
|
8
|
+
* OpenRouter: the growth of its lifetime usage.
|
|
9
|
+
* - priced — the growth of the agents' total.
|
|
10
|
+
*
|
|
11
|
+
* A difference is expected when the same key serves more than the agents (the assistant,
|
|
12
|
+
* clients outside Rev4a), when a vendor bills late, and from DeepSeek's balance being
|
|
13
|
+
* given to the cent. Nothing here corrects either side.
|
|
14
|
+
*/
|
|
15
|
+
import type Database from 'better-sqlite3';
|
|
16
|
+
|
|
17
|
+
export interface Reconciliation {
|
|
18
|
+
vendor: string;
|
|
19
|
+
currency: string | null;
|
|
20
|
+
/** Unix ms of the two readings compared; null when there are not two yet. */
|
|
21
|
+
from: number | null;
|
|
22
|
+
to: number | null;
|
|
23
|
+
billed: number | null;
|
|
24
|
+
priced: number | null;
|
|
25
|
+
/** Balance rises between the readings (DeepSeek): money added, not spent. */
|
|
26
|
+
topUps: number;
|
|
27
|
+
readings: number;
|
|
28
|
+
/** False when the vendor's figure is in another currency than the agents' dollars. */
|
|
29
|
+
comparable: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Row { ts: number; vendor: string; currency: string | null; balance: number | null; usage: number | null; agents_cost: number }
|
|
33
|
+
|
|
34
|
+
const round = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
35
|
+
|
|
36
|
+
export function readReconciliation(db: Database.Database, startDate: string, endDate: string): Reconciliation[] {
|
|
37
|
+
const hasTable = db
|
|
38
|
+
.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name = 'vendor_balance'")
|
|
39
|
+
.get() as { n: number };
|
|
40
|
+
if (!hasTable.n) return [];
|
|
41
|
+
const start = Date.parse(`${startDate}T00:00:00Z`);
|
|
42
|
+
const end = Date.parse(`${endDate}T00:00:00Z`) + 86_400_000;
|
|
43
|
+
const vendors = (db.prepare('SELECT DISTINCT vendor FROM vendor_balance ORDER BY vendor').all() as { vendor: string }[]).map((v) => v.vendor);
|
|
44
|
+
|
|
45
|
+
return vendors.map((vendor) => {
|
|
46
|
+
// The reading at or just before the start of the range, else the first inside it.
|
|
47
|
+
const before = db.prepare('SELECT * FROM vendor_balance WHERE vendor = ? AND ts <= ? ORDER BY ts DESC LIMIT 1').get(vendor, start) as Row | undefined;
|
|
48
|
+
const inside = db.prepare('SELECT * FROM vendor_balance WHERE vendor = ? AND ts > ? AND ts < ? ORDER BY ts').all(vendor, start, end) as Row[];
|
|
49
|
+
const rows = before ? [before, ...inside] : inside;
|
|
50
|
+
const currency = rows[rows.length - 1]?.currency ?? null;
|
|
51
|
+
const empty: Reconciliation = {
|
|
52
|
+
vendor, currency, from: rows[0]?.ts ?? null, to: null, billed: null, priced: null, topUps: 0,
|
|
53
|
+
readings: rows.length, comparable: currency === 'USD',
|
|
54
|
+
};
|
|
55
|
+
if (rows.length < 2) return empty;
|
|
56
|
+
|
|
57
|
+
const a = rows[0];
|
|
58
|
+
const b = rows[rows.length - 1];
|
|
59
|
+
let billed = 0;
|
|
60
|
+
let topUps = 0;
|
|
61
|
+
if (vendor === 'openrouter') {
|
|
62
|
+
billed = (b.usage ?? 0) - (a.usage ?? 0);
|
|
63
|
+
} else {
|
|
64
|
+
for (let i = 1; i < rows.length; i++) {
|
|
65
|
+
const delta = (rows[i - 1].balance ?? 0) - (rows[i].balance ?? 0);
|
|
66
|
+
if (delta >= 0) billed += delta;
|
|
67
|
+
else topUps += -delta;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
...empty,
|
|
72
|
+
to: b.ts,
|
|
73
|
+
billed: round(billed),
|
|
74
|
+
priced: round(b.agents_cost - a.agents_cost),
|
|
75
|
+
topUps: round(topUps),
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
}
|
package/lib/costs-db.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Read access to the agent costs database (daemon.js writes it).
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import Database from 'better-sqlite3';
|
|
5
|
+
import { DB_PATH } from './db';
|
|
6
|
+
|
|
7
|
+
/** Next to events.db, as daemon.js places it; its own file, like metrics.db. */
|
|
8
|
+
export const COSTS_DB_PATH = path.join(path.dirname(DB_PATH), 'costs.db');
|
|
9
|
+
|
|
10
|
+
const TABLES = ['agent_cost_daily', 'agent_cost_model_daily', 'agent_cost_sessions', 'agent_cost_sources'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Open the costs database read-only, or null when there is nothing to read yet: no file
|
|
14
|
+
* (first start, or a daemon that never ran) or one without the daemon's tables. The
|
|
15
|
+
* daemon owns the schema.
|
|
16
|
+
*/
|
|
17
|
+
export function openCostsDb(): Database.Database | null {
|
|
18
|
+
if (!fs.existsSync(COSTS_DB_PATH)) return null;
|
|
19
|
+
const db = new Database(COSTS_DB_PATH, { readonly: true, fileMustExist: true });
|
|
20
|
+
try {
|
|
21
|
+
const row = db
|
|
22
|
+
.prepare(`SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`)
|
|
23
|
+
.get(...TABLES) as { n: number };
|
|
24
|
+
if (row.n === TABLES.length) return db;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
db.close();
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
db.close();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
package/lib/model-pricing.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Model pricing data — static, from model-pricing.json in the project root
|
|
2
|
+
* Model pricing data — static, from model-pricing.json in the project root, in $ per
|
|
3
|
+
* 1M tokens. `npm run refresh:pricing` regenerates the OpenRouter entries; the direct
|
|
4
|
+
* vendors' entries are kept by hand from each vendor's own pricing page.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
+
* Besides the display, these prices are what every agent's OpenClaw computes its costs
|
|
7
|
+
* with: `buildRev4aProviderConfig()` writes `priceAt()` into each model of the synced
|
|
8
|
+
* `rev4a` block, and a vendor with time-of-day pricing (PRICE_SCHEDULES) is re-synced
|
|
9
|
+
* at each change of rate (lib/price-schedule-sync.ts).
|
|
6
10
|
*/
|
|
7
11
|
import * as fs from 'fs';
|
|
8
12
|
import * as path from 'path';
|
|
@@ -10,8 +14,48 @@ import * as path from 'path';
|
|
|
10
14
|
export interface ModelPricing {
|
|
11
15
|
input: number;
|
|
12
16
|
output: number;
|
|
17
|
+
/** Cached input read. Absent when the vendor's cache rate is not known. */
|
|
18
|
+
cacheRead?: number;
|
|
19
|
+
/** Cached input written. Absent when not known or not billed apart from input. */
|
|
20
|
+
cacheWrite?: number;
|
|
13
21
|
}
|
|
14
22
|
|
|
23
|
+
/** The four rates OpenClaw's `models.providers.*.models[].cost` takes, $ per 1M tokens. */
|
|
24
|
+
export interface ModelCost {
|
|
25
|
+
input: number;
|
|
26
|
+
output: number;
|
|
27
|
+
cacheRead: number;
|
|
28
|
+
cacheWrite: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A vendor whose rates depend on the time of day: `peak` windows at the listed rates,
|
|
33
|
+
* every other hour at `offPeakFactor` times them. Hours are UTC, `[from, to)`; days are
|
|
34
|
+
* UTC weekdays, 0 = Sunday.
|
|
35
|
+
*/
|
|
36
|
+
export interface PriceSchedule {
|
|
37
|
+
source: string;
|
|
38
|
+
peak: { days: number[]; hours: [number, number][] };
|
|
39
|
+
offPeakFactor: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Keyed by the first segment of our model id: `deepseek/deepseek-flash` is DeepSeek's
|
|
44
|
+
* own API. `openrouter/deepseek/…` is OpenRouter's price and has no schedule.
|
|
45
|
+
*
|
|
46
|
+
* DeepSeek (checked 2026-09-27): "Off-peak rates are half of the peak rates. Peak hours
|
|
47
|
+
* are 01:00 - 04:00 and 06:00 - 10:00 UTC, Monday through Friday, excluding Chinese
|
|
48
|
+
* public holidays." Those holidays are not modelled: a call on one is priced at peak, a
|
|
49
|
+
* small overestimate on a few days a year.
|
|
50
|
+
*/
|
|
51
|
+
export const PRICE_SCHEDULES: Record<string, PriceSchedule> = {
|
|
52
|
+
deepseek: {
|
|
53
|
+
source: 'https://api-docs.deepseek.com/quick_start/pricing',
|
|
54
|
+
peak: { days: [1, 2, 3, 4, 5], hours: [[1, 4], [6, 10]] },
|
|
55
|
+
offPeakFactor: 0.5,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
15
59
|
let _cache: Record<string, ModelPricing> | null = null;
|
|
16
60
|
let _cacheMtime = 0;
|
|
17
61
|
|
|
@@ -61,3 +105,79 @@ export function formatPricing(p: ModelPricing | null | undefined): string {
|
|
|
61
105
|
};
|
|
62
106
|
return `${fmt(p.input)} / ${fmt(p.output)}`;
|
|
63
107
|
}
|
|
108
|
+
|
|
109
|
+
/** The time-of-day schedule a model is billed on, or null for a flat price. */
|
|
110
|
+
export function priceScheduleFor(modelId: string): PriceSchedule | null {
|
|
111
|
+
return PRICE_SCHEDULES[modelId.split('/')[0]] ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isPeak(schedule: PriceSchedule, at: Date): boolean {
|
|
115
|
+
if (!schedule.peak.days.includes(at.getUTCDay())) return false;
|
|
116
|
+
const h = at.getUTCHours();
|
|
117
|
+
return schedule.peak.hours.some(([from, to]) => h >= from && h < to);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 'peak' or 'off-peak' for a model on a schedule, null for a flat price. */
|
|
121
|
+
export function priceBandAt(modelId: string, at: Date = new Date()): 'peak' | 'off-peak' | null {
|
|
122
|
+
const schedule = priceScheduleFor(modelId);
|
|
123
|
+
if (!schedule) return null;
|
|
124
|
+
return isPeak(schedule, at) ? 'peak' : 'off-peak';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const round6 = (n: number) => Math.round(n * 1e6) / 1e6;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* What a free model is written with. OpenClaw treats an all-zero cost as "no price" and
|
|
131
|
+
* counts every call as unpriced, which would flag free models as missing a price. One
|
|
132
|
+
* millionth of a dollar per 1M tokens makes them priced and, in practice, free: a billion
|
|
133
|
+
* tokens come to $0.001.
|
|
134
|
+
*/
|
|
135
|
+
const FREE_RATE = 0.000001;
|
|
136
|
+
/**
|
|
137
|
+
* A cache write with no published rate, as a multiple of the input rate. Vendors that
|
|
138
|
+
* bill cache writes apart charge more than input (Anthropic: 1.25× for its default
|
|
139
|
+
* five-minute cache); the ones that do not report cache writes at all never use it.
|
|
140
|
+
*/
|
|
141
|
+
const CACHE_WRITE_FALLBACK = 1.25;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The rates in force for a model at a moment, in the shape OpenClaw's config takes.
|
|
145
|
+
*
|
|
146
|
+
* Null when there is nothing to write: no entry, or a dynamic router price (-1), whose
|
|
147
|
+
* calls OpenClaw then counts as unpriced. A free model (all zero) gets FREE_RATE. A cache
|
|
148
|
+
* rate the file does not carry falls back to a rate that does not undercount: the input
|
|
149
|
+
* rate for a cache read (as if the cache gave no discount), CACHE_WRITE_FALLBACK × input
|
|
150
|
+
* for a cache write.
|
|
151
|
+
*/
|
|
152
|
+
export function priceAt(modelId: string, at: Date = new Date()): ModelCost | null {
|
|
153
|
+
const p = getPricing(modelId);
|
|
154
|
+
if (!p || p.input < 0 || p.output < 0) return null;
|
|
155
|
+
if (p.input === 0 && p.output === 0 && !p.cacheRead && !p.cacheWrite) {
|
|
156
|
+
return { input: FREE_RATE, output: FREE_RATE, cacheRead: FREE_RATE, cacheWrite: FREE_RATE };
|
|
157
|
+
}
|
|
158
|
+
const schedule = priceScheduleFor(modelId);
|
|
159
|
+
const factor = schedule && !isPeak(schedule, at) ? schedule.offPeakFactor : 1;
|
|
160
|
+
return {
|
|
161
|
+
input: round6(p.input * factor),
|
|
162
|
+
output: round6(p.output * factor),
|
|
163
|
+
cacheRead: round6((p.cacheRead ?? p.input) * factor),
|
|
164
|
+
cacheWrite: round6((p.cacheWrite ?? p.input * CACHE_WRITE_FALLBACK) * factor),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The next moment any of these models changes rate, or null when none is on a schedule.
|
|
170
|
+
* Every schedule changes on the hour, so the search walks hour marks (up to 8 days).
|
|
171
|
+
*/
|
|
172
|
+
export function nextPriceChange(modelIds: string[], from: Date = new Date()): Date | null {
|
|
173
|
+
const schedules = [...new Set(modelIds.map(priceScheduleFor).filter((s): s is PriceSchedule => s !== null))];
|
|
174
|
+
if (!schedules.length) return null;
|
|
175
|
+
const now = schedules.map((s) => isPeak(s, from));
|
|
176
|
+
const t = new Date(from);
|
|
177
|
+
t.setUTCMinutes(0, 0, 0);
|
|
178
|
+
for (let i = 0; i < 8 * 24; i++) {
|
|
179
|
+
t.setUTCHours(t.getUTCHours() + 1);
|
|
180
|
+
if (schedules.some((s, j) => isPeak(s, t) !== now[j])) return new Date(t);
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|