@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.
- package/README.md +3 -3
- package/app/agents/CostSection.tsx +94 -0
- package/app/agents/PageClient.tsx +14 -0
- package/app/agents/PanelRow.tsx +9 -0
- package/app/agents/ResourceSection.tsx +105 -0
- package/app/api/assistant/route.ts +10 -3
- package/app/api/containers/route.ts +18 -2
- package/app/api/costs/agent/route.ts +55 -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/metrics/alerts/route.ts +47 -0
- package/app/api/metrics/containers/route.ts +54 -0
- package/app/api/metrics/route.ts +4 -6
- 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/Accordion.tsx +45 -0
- package/app/components/ui/TimeSeriesChart.tsx +47 -11
- package/app/components/ui/index.ts +1 -0
- package/app/containers/ContainersClient.tsx +48 -6
- 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 +42 -0
- package/app/system/AgentCharts.tsx +138 -0
- package/app/system/AgentsSection.tsx +281 -0
- package/app/system/PageClient.tsx +7 -7
- package/app/system/RecentAlerts.tsx +72 -0
- package/app/system/SystemSkeleton.tsx +53 -1
- package/app/system/loading.tsx +5 -1
- package/daemon.js +646 -41
- package/docs/ARCHITECTURE.md +72 -31
- package/docs/DESIGN-SYSTEM.md +2 -2
- package/docs/FRONTEND-ARCHITECTURE.md +14 -3
- package/docs/REV4A.md +6 -5
- package/docs/dev/API-REFERENCE.md +208 -38
- package/docs/dev/DATABASE.md +176 -20
- package/docs/dev/GATEWAY.md +53 -9
- package/docs/rag/DATA-FRESHNESS.md +34 -7
- package/docs/rag/GLOSSARY.md +8 -5
- package/docs/rag/REV4A-OVERVIEW.md +10 -3
- package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
- package/instrumentation.ts +11 -0
- package/lib/agent-costs.ts +172 -0
- package/lib/container-metrics.ts +340 -0
- package/lib/cost-reconciliation.ts +78 -0
- package/lib/costs-db.ts +31 -0
- package/lib/docker-socket-path.js +133 -0
- package/lib/docker-socket.ts +10 -99
- package/lib/docker-stats.js +284 -0
- package/lib/metrics-db.ts +15 -6
- package/lib/model-pricing.ts +123 -3
- package/lib/price-schedule-sync.ts +133 -0
- package/lib/utils/format.ts +38 -0
- package/model-pricing.json +269 -121
- package/package.json +1 -1
- package/scripts/refresh-model-pricing.mjs +16 -4
- package/scripts/test-docker-stats.mjs +270 -0
- package/lib/billing.ts +0 -100
package/daemon.js
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* Rev4a Daemon — SQLite event logger
|
|
4
|
-
* Polls OpenClaw sessions every 30s, writes events to SQLite,
|
|
5
|
-
*
|
|
4
|
+
* Polls OpenClaw sessions every 30s, writes events to SQLite, samples the host machine
|
|
5
|
+
* (CPU, RAM, swap, storage) every 30s, samples what each container consumes every 60s
|
|
6
|
+
* (metrics.db), and every 5 min reads what each running agent spent, as its OpenClaw priced
|
|
7
|
+
* it, into costs.db — each on a timer of its own.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
10
|
'use strict';
|
|
9
11
|
|
|
10
12
|
const os = require('os');
|
|
11
13
|
const fs = require('fs');
|
|
12
|
-
const { spawnSync, execSync } = require('child_process');
|
|
14
|
+
const { spawnSync, execSync, execFile } = require('child_process');
|
|
13
15
|
const Database = require('better-sqlite3');
|
|
14
16
|
const path = require('path');
|
|
17
|
+
// Shared with the dashboard (lib/docker-socket.ts): one copy of where the Docker socket is.
|
|
18
|
+
const { dockerRequestJson } = require('./lib/docker-socket-path.js');
|
|
19
|
+
const dockerStats = require('./lib/docker-stats.js');
|
|
15
20
|
|
|
16
21
|
// Same rule as lib/rev4a-paths.ts: REV4A_DB, else <data dir>/data/events.db, where the data
|
|
17
22
|
// dir is REV4A_DATA_DIR or ~/.config/rev4a — the API reads the files the daemon writes.
|
|
@@ -22,41 +27,8 @@ const DATA_DIR = process.env.REV4A_DATA_DIR
|
|
|
22
27
|
const DB_PATH = process.env.REV4A_DB || path.join(DATA_DIR, 'data', 'events.db');
|
|
23
28
|
const POLL_INTERVAL_MS = 30_000;
|
|
24
29
|
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
'claude-sonnet-4': { in: 3.00, out: 15.00 },
|
|
28
|
-
'claude-opus-4': { in: 15.00, out: 75.00 },
|
|
29
|
-
'gpt-5-mini': { in: 0.15, out: 0.60 },
|
|
30
|
-
'codex': { in: 3.00, out: 15.00 },
|
|
31
|
-
'gemini': { in: 0.075, out: 0.30 },
|
|
32
|
-
'flash': { in: 0.075, out: 0.30 },
|
|
33
|
-
'deepseek': { in: 0.55, out: 2.19 },
|
|
34
|
-
'default': { in: 3.00, out: 15.00 },
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
// Direct aliases for models that do not match by substring
|
|
38
|
-
const MODEL_ALIASES = {
|
|
39
|
-
'cheap': 'flash',
|
|
40
|
-
'fast': 'claude-sonnet-4',
|
|
41
|
-
'big': 'claude-opus-4',
|
|
42
|
-
'coder': 'codex',
|
|
43
|
-
'pro': 'gemini',
|
|
44
|
-
'reason': 'deepseek',
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
function estimateCost(model, tokensIn, tokensOut) {
|
|
49
|
-
if (!tokensIn && !tokensOut) return 0;
|
|
50
|
-
const m = (model || '').toLowerCase();
|
|
51
|
-
// Check direct aliases first (e.g. 'cheap' → 'flash')
|
|
52
|
-
const aliasKey = Object.keys(MODEL_ALIASES).find(a => m === a || m.endsWith('/' + a));
|
|
53
|
-
const resolved = aliasKey ? MODEL_ALIASES[aliasKey] : null;
|
|
54
|
-
const key = resolved
|
|
55
|
-
|| Object.keys(MODEL_PRICING).find(k => k !== 'default' && m.includes(k))
|
|
56
|
-
|| 'default';
|
|
57
|
-
const p = MODEL_PRICING[key];
|
|
58
|
-
return ((tokensIn || 0) / 1_000_000 * p.in) + ((tokensOut || 0) / 1_000_000 * p.out);
|
|
59
|
-
}
|
|
30
|
+
// No cost is estimated here. What agents spend is priced by each agent's own OpenClaw
|
|
31
|
+
// and read by the Agent Costs timer below into costs.db; sessions.cost_usd stays 0.
|
|
60
32
|
|
|
61
33
|
// ── DB Setup ────────────────────────────────────────────────────────────────
|
|
62
34
|
|
|
@@ -216,6 +188,57 @@ CREATE TABLE IF NOT EXISTS system_disks (
|
|
|
216
188
|
);
|
|
217
189
|
CREATE INDEX IF NOT EXISTS idx_disks_ts ON system_disks(ts);
|
|
218
190
|
CREATE INDEX IF NOT EXISTS idx_disks_mount_ts ON system_disks(mount, ts);
|
|
191
|
+
|
|
192
|
+
-- What each running container consumes, one row per container per sample (60 s). CPU is in
|
|
193
|
+
-- cores (CPU-seconds per second) and every rate in bytes per second, both null for the first
|
|
194
|
+
-- sample after a start and after a counter reset. Memory is the working set. A stopped
|
|
195
|
+
-- container has no rows: a gap, not zeros.
|
|
196
|
+
CREATE TABLE IF NOT EXISTS container_metrics (
|
|
197
|
+
ts INTEGER NOT NULL,
|
|
198
|
+
container TEXT NOT NULL,
|
|
199
|
+
cpu_cores REAL,
|
|
200
|
+
mem_mb INTEGER,
|
|
201
|
+
pids INTEGER,
|
|
202
|
+
net_rx_bps INTEGER,
|
|
203
|
+
net_tx_bps INTEGER,
|
|
204
|
+
blk_read_bps INTEGER,
|
|
205
|
+
blk_write_bps INTEGER
|
|
206
|
+
);
|
|
207
|
+
CREATE INDEX IF NOT EXISTS idx_container_metrics_ts ON container_metrics(ts);
|
|
208
|
+
CREATE INDEX IF NOT EXISTS idx_container_metrics_c_ts ON container_metrics(container, ts);
|
|
209
|
+
|
|
210
|
+
-- Who each container is: the agent's name (AGENT_NAME), whether it is an agent, and its named
|
|
211
|
+
-- volumes (the link to docker_storage).
|
|
212
|
+
CREATE TABLE IF NOT EXISTS container_sources (
|
|
213
|
+
container TEXT PRIMARY KEY,
|
|
214
|
+
agent_id TEXT,
|
|
215
|
+
name TEXT,
|
|
216
|
+
is_agent INTEGER NOT NULL DEFAULT 0,
|
|
217
|
+
volumes TEXT,
|
|
218
|
+
last_seen INTEGER NOT NULL
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
-- Docker's own cores and memory: the denominators for a container's share (on Docker Desktop
|
|
222
|
+
-- that is its VM, not the Mac).
|
|
223
|
+
CREATE TABLE IF NOT EXISTS docker_host (
|
|
224
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
225
|
+
ncpu INTEGER,
|
|
226
|
+
mem_total_mb INTEGER,
|
|
227
|
+
updated_at INTEGER
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
-- Disk Docker holds, read from /system/df every 10 min: kind 'volume' (name = the volume;
|
|
231
|
+
-- reclaimable_mb = its size when no container uses it), 'layer' (a container's writable layer),
|
|
232
|
+
-- 'images' and 'build_cache' (totals, with what could be freed).
|
|
233
|
+
CREATE TABLE IF NOT EXISTS docker_storage (
|
|
234
|
+
ts INTEGER NOT NULL,
|
|
235
|
+
kind TEXT NOT NULL,
|
|
236
|
+
name TEXT NOT NULL DEFAULT '',
|
|
237
|
+
size_mb INTEGER,
|
|
238
|
+
reclaimable_mb INTEGER
|
|
239
|
+
);
|
|
240
|
+
CREATE INDEX IF NOT EXISTS idx_docker_storage_ts ON docker_storage(ts);
|
|
241
|
+
CREATE INDEX IF NOT EXISTS idx_docker_storage_kind_ts ON docker_storage(kind, name, ts);
|
|
219
242
|
`);
|
|
220
243
|
return {
|
|
221
244
|
db: mdb,
|
|
@@ -232,6 +255,26 @@ CREATE INDEX IF NOT EXISTS idx_disks_mount_ts ON system_disks(mount, ts);
|
|
|
232
255
|
pruneMetrics: mdb.prepare('DELETE FROM system_metrics WHERE ts < ?'),
|
|
233
256
|
pruneDisks: mdb.prepare('DELETE FROM system_disks WHERE ts < ?'),
|
|
234
257
|
getLastTwoMetrics: mdb.prepare('SELECT cpu_percent, ram_used_mb, ram_total_mb FROM system_metrics ORDER BY ts DESC LIMIT 2'),
|
|
258
|
+
insertContainerMetric: mdb.prepare(`
|
|
259
|
+
INSERT INTO container_metrics (ts, container, cpu_cores, mem_mb, pids, net_rx_bps, net_tx_bps, blk_read_bps, blk_write_bps)
|
|
260
|
+
VALUES (@ts, @container, @cpu_cores, @mem_mb, @pids, @net_rx_bps, @net_tx_bps, @blk_read_bps, @blk_write_bps)
|
|
261
|
+
`),
|
|
262
|
+
upsertContainerSource: mdb.prepare(`
|
|
263
|
+
INSERT INTO container_sources (container, agent_id, name, is_agent, volumes, last_seen)
|
|
264
|
+
VALUES (@container, @agent_id, @name, @is_agent, @volumes, @last_seen)
|
|
265
|
+
ON CONFLICT(container) DO UPDATE SET agent_id = excluded.agent_id, name = excluded.name,
|
|
266
|
+
is_agent = excluded.is_agent, volumes = excluded.volumes, last_seen = excluded.last_seen
|
|
267
|
+
`),
|
|
268
|
+
upsertDockerHost: mdb.prepare(`
|
|
269
|
+
INSERT INTO docker_host (id, ncpu, mem_total_mb, updated_at) VALUES (1, @ncpu, @mem_total_mb, @updated_at)
|
|
270
|
+
ON CONFLICT(id) DO UPDATE SET ncpu = excluded.ncpu, mem_total_mb = excluded.mem_total_mb, updated_at = excluded.updated_at
|
|
271
|
+
`),
|
|
272
|
+
insertDockerStorage: mdb.prepare(`
|
|
273
|
+
INSERT INTO docker_storage (ts, kind, name, size_mb, reclaimable_mb) VALUES (@ts, @kind, @name, @size_mb, @reclaimable_mb)
|
|
274
|
+
`),
|
|
275
|
+
pruneContainerMetrics: mdb.prepare('DELETE FROM container_metrics WHERE ts < ?'),
|
|
276
|
+
pruneContainerSources: mdb.prepare('DELETE FROM container_sources WHERE last_seen < ?'),
|
|
277
|
+
pruneDockerStorage: mdb.prepare('DELETE FROM docker_storage WHERE ts < ?'),
|
|
235
278
|
};
|
|
236
279
|
} catch (err) {
|
|
237
280
|
log(`[METRICS] disabled: cannot open ${METRICS_DB_PATH}: ${err.message}`);
|
|
@@ -448,6 +491,519 @@ function collectSystemMetrics() {
|
|
|
448
491
|
}
|
|
449
492
|
}
|
|
450
493
|
|
|
494
|
+
// ── Container consumption ────────────────────────────────────────────────────
|
|
495
|
+
//
|
|
496
|
+
// What each running container uses — CPU, memory, processes, network, block I/O — and the
|
|
497
|
+
// disk Docker holds for them, so the System page can say *who* is using the machine. Read from
|
|
498
|
+
// the Docker Engine API (lib/docker-stats.js); written to metrics.db, next to the machine's
|
|
499
|
+
// own numbers. Every running container, agents and the rest alike: the page tells them apart.
|
|
500
|
+
|
|
501
|
+
const CONTAINERS_INTERVAL_MS = 60_000;
|
|
502
|
+
/** The first pass only primes the CPU counters; the second, this long after, is the first with a CPU figure. */
|
|
503
|
+
const CONTAINERS_FIRST_DELAY_MS = 6_000;
|
|
504
|
+
const CONTAINERS_SECOND_DELAY_MS = 15_000;
|
|
505
|
+
const STORAGE_INTERVAL_MS = 10 * 60_000;
|
|
506
|
+
const STORAGE_FIRST_DELAY_MS = 25_000;
|
|
507
|
+
|
|
508
|
+
// A rate over a gap longer than three intervals (a laptop that slept, skipped passes) is no rate.
|
|
509
|
+
const containerSampler = dockerStats.createContainerSampler({
|
|
510
|
+
request: dockerRequestJson,
|
|
511
|
+
maxWindowS: (3 * CONTAINERS_INTERVAL_MS) / 1000,
|
|
512
|
+
});
|
|
513
|
+
let containersRunning = false;
|
|
514
|
+
let dockerContainersDown = false;
|
|
515
|
+
|
|
516
|
+
async function collectContainerMetrics() {
|
|
517
|
+
if (!metrics || containersRunning) return;
|
|
518
|
+
containersRunning = true;
|
|
519
|
+
try {
|
|
520
|
+
let sample;
|
|
521
|
+
try {
|
|
522
|
+
sample = await containerSampler.sample();
|
|
523
|
+
dockerContainersDown = false;
|
|
524
|
+
} catch (e) {
|
|
525
|
+
// Docker down or not yet up: said once, not every minute; it recovers by itself.
|
|
526
|
+
if (!dockerContainersDown) log(`[CONTAINERS] Docker unreachable: ${e.message}`);
|
|
527
|
+
dockerContainersDown = true;
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
metrics.db.transaction(() => {
|
|
531
|
+
for (const row of sample.rows) metrics.insertContainerMetric.run({ ts: sample.ts, ...row });
|
|
532
|
+
for (const source of sample.sources) metrics.upsertContainerSource.run({ ...source, last_seen: sample.ts });
|
|
533
|
+
if (sample.host) metrics.upsertDockerHost.run({ ncpu: sample.host.ncpu, mem_total_mb: sample.host.memTotalMb, updated_at: sample.ts });
|
|
534
|
+
metrics.pruneContainerMetrics.run(sample.ts - METRICS_RETENTION_S);
|
|
535
|
+
metrics.pruneContainerSources.run(sample.ts - METRICS_RETENTION_S);
|
|
536
|
+
})();
|
|
537
|
+
} finally {
|
|
538
|
+
containersRunning = false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
let storageRunning = false;
|
|
543
|
+
let dockerStorageFailing = false;
|
|
544
|
+
|
|
545
|
+
async function collectDockerStorage() {
|
|
546
|
+
if (!metrics || storageRunning) return;
|
|
547
|
+
storageRunning = true;
|
|
548
|
+
try {
|
|
549
|
+
let df;
|
|
550
|
+
try {
|
|
551
|
+
df = dockerStats.parseStorage(await dockerRequestJson('GET', '/system/df', dockerStats.DF_TIMEOUT_MS));
|
|
552
|
+
if (!df) throw new Error('not a /system/df answer');
|
|
553
|
+
dockerStorageFailing = false;
|
|
554
|
+
} catch (e) {
|
|
555
|
+
// The last reading stays in the table: a failed one records nothing.
|
|
556
|
+
if (!dockerStorageFailing) log(`[CONTAINERS] Docker storage unreadable: ${e.message}`);
|
|
557
|
+
dockerStorageFailing = true;
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
561
|
+
const mb = (bytes) => (bytes === null ? null : Math.round(bytes / dockerStats.MB));
|
|
562
|
+
metrics.db.transaction(() => {
|
|
563
|
+
for (const v of df.volumes) {
|
|
564
|
+
metrics.insertDockerStorage.run({ ts, kind: 'volume', name: v.name, size_mb: mb(v.sizeBytes), reclaimable_mb: v.unused ? mb(v.sizeBytes) : 0 });
|
|
565
|
+
}
|
|
566
|
+
for (const l of df.layers) {
|
|
567
|
+
metrics.insertDockerStorage.run({ ts, kind: 'layer', name: l.container, size_mb: mb(l.sizeBytes), reclaimable_mb: null });
|
|
568
|
+
}
|
|
569
|
+
metrics.insertDockerStorage.run({ ts, kind: 'images', name: '', size_mb: mb(df.images.sizeBytes), reclaimable_mb: mb(df.images.reclaimableBytes) });
|
|
570
|
+
metrics.insertDockerStorage.run({ ts, kind: 'build_cache', name: '', size_mb: mb(df.buildCache.sizeBytes), reclaimable_mb: mb(df.buildCache.reclaimableBytes) });
|
|
571
|
+
metrics.pruneDockerStorage.run(ts - METRICS_RETENTION_S);
|
|
572
|
+
})();
|
|
573
|
+
} finally {
|
|
574
|
+
storageRunning = false;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ── Agent Costs ──────────────────────────────────────────────────────────────
|
|
579
|
+
//
|
|
580
|
+
// What each agent spent, as its own OpenClaw computed it. Rev4a writes every model's
|
|
581
|
+
// price into the synced `rev4a` provider block (app/api/gateway/sync.ts); OpenClaw then
|
|
582
|
+
// prices each call, session and day itself. This timer reads those figures from each
|
|
583
|
+
// running agent's Gateway and keeps them, so a stopped or deleted agent keeps its
|
|
584
|
+
// history. Nothing here computes a cost.
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Its own database next to events.db, like metrics.db: the daemon is the only writer;
|
|
588
|
+
* the API reads it through lib/costs-db.ts (same path) — the Costs page, the dashboard,
|
|
589
|
+
* system-health and the agent panel. Days are UTC dates, as
|
|
590
|
+
* OpenClaw buckets them by default and as DeepSeek schedules its rates.
|
|
591
|
+
*
|
|
592
|
+
* Opened in a try: a costs.db that cannot be opened turns collection off with a log line.
|
|
593
|
+
*/
|
|
594
|
+
const COSTS_DB_PATH = path.join(path.dirname(DB_PATH), 'costs.db');
|
|
595
|
+
const costs = openCostsStore();
|
|
596
|
+
|
|
597
|
+
function openCostsStore() {
|
|
598
|
+
try {
|
|
599
|
+
const cdb = new Database(COSTS_DB_PATH);
|
|
600
|
+
cdb.pragma('journal_mode = WAL');
|
|
601
|
+
cdb.pragma('synchronous = NORMAL');
|
|
602
|
+
cdb.exec(`
|
|
603
|
+
CREATE TABLE IF NOT EXISTS agent_cost_daily (
|
|
604
|
+
container TEXT NOT NULL,
|
|
605
|
+
date TEXT NOT NULL,
|
|
606
|
+
input INTEGER NOT NULL DEFAULT 0,
|
|
607
|
+
output INTEGER NOT NULL DEFAULT 0,
|
|
608
|
+
cache_read INTEGER NOT NULL DEFAULT 0,
|
|
609
|
+
cache_write INTEGER NOT NULL DEFAULT 0,
|
|
610
|
+
total_tokens INTEGER NOT NULL DEFAULT 0,
|
|
611
|
+
cost REAL NOT NULL DEFAULT 0,
|
|
612
|
+
input_cost REAL NOT NULL DEFAULT 0,
|
|
613
|
+
output_cost REAL NOT NULL DEFAULT 0,
|
|
614
|
+
cache_read_cost REAL NOT NULL DEFAULT 0,
|
|
615
|
+
cache_write_cost REAL NOT NULL DEFAULT 0,
|
|
616
|
+
missing INTEGER NOT NULL DEFAULT 0,
|
|
617
|
+
PRIMARY KEY (container, date)
|
|
618
|
+
);
|
|
619
|
+
CREATE TABLE IF NOT EXISTS agent_cost_model_daily (
|
|
620
|
+
container TEXT NOT NULL,
|
|
621
|
+
date TEXT NOT NULL,
|
|
622
|
+
provider TEXT NOT NULL,
|
|
623
|
+
model TEXT NOT NULL,
|
|
624
|
+
tokens INTEGER NOT NULL DEFAULT 0,
|
|
625
|
+
cost REAL NOT NULL DEFAULT 0,
|
|
626
|
+
calls INTEGER NOT NULL DEFAULT 0,
|
|
627
|
+
PRIMARY KEY (container, date, provider, model)
|
|
628
|
+
);
|
|
629
|
+
CREATE TABLE IF NOT EXISTS agent_cost_sessions (
|
|
630
|
+
container TEXT NOT NULL,
|
|
631
|
+
session_id TEXT NOT NULL,
|
|
632
|
+
session_key TEXT,
|
|
633
|
+
label TEXT,
|
|
634
|
+
agent_id TEXT,
|
|
635
|
+
provider TEXT,
|
|
636
|
+
model TEXT,
|
|
637
|
+
tokens INTEGER NOT NULL DEFAULT 0,
|
|
638
|
+
cost REAL NOT NULL DEFAULT 0,
|
|
639
|
+
missing INTEGER NOT NULL DEFAULT 0,
|
|
640
|
+
first_activity INTEGER,
|
|
641
|
+
last_activity INTEGER,
|
|
642
|
+
PRIMARY KEY (container, session_id)
|
|
643
|
+
);
|
|
644
|
+
CREATE INDEX IF NOT EXISTS idx_cost_sessions_last ON agent_cost_sessions(last_activity);
|
|
645
|
+
-- The vendor's own figure next to what the agents priced, taken together (see
|
|
646
|
+
-- collectVendorBalances): the difference between two rows compares the same interval.
|
|
647
|
+
CREATE TABLE IF NOT EXISTS vendor_balance (
|
|
648
|
+
ts INTEGER NOT NULL,
|
|
649
|
+
vendor TEXT NOT NULL,
|
|
650
|
+
currency TEXT,
|
|
651
|
+
balance REAL,
|
|
652
|
+
usage REAL,
|
|
653
|
+
agents_cost REAL NOT NULL DEFAULT 0,
|
|
654
|
+
PRIMARY KEY (vendor, ts)
|
|
655
|
+
);
|
|
656
|
+
CREATE TABLE IF NOT EXISTS agent_cost_sources (
|
|
657
|
+
container TEXT PRIMARY KEY,
|
|
658
|
+
agent_id TEXT,
|
|
659
|
+
name TEXT,
|
|
660
|
+
collected_at INTEGER,
|
|
661
|
+
attempted_at INTEGER,
|
|
662
|
+
error TEXT,
|
|
663
|
+
missing_by_model TEXT
|
|
664
|
+
);
|
|
665
|
+
`);
|
|
666
|
+
return {
|
|
667
|
+
db: cdb,
|
|
668
|
+
clearDaily: cdb.prepare('DELETE FROM agent_cost_daily WHERE container = ? AND date >= ?'),
|
|
669
|
+
clearModelDaily: cdb.prepare('DELETE FROM agent_cost_model_daily WHERE container = ? AND date >= ?'),
|
|
670
|
+
insertDaily: cdb.prepare(`
|
|
671
|
+
INSERT INTO agent_cost_daily (container, date, input, output, cache_read, cache_write, total_tokens,
|
|
672
|
+
cost, input_cost, output_cost, cache_read_cost, cache_write_cost, missing)
|
|
673
|
+
VALUES (@container, @date, @input, @output, @cache_read, @cache_write, @total_tokens,
|
|
674
|
+
@cost, @input_cost, @output_cost, @cache_read_cost, @cache_write_cost, @missing)
|
|
675
|
+
`),
|
|
676
|
+
insertModelDaily: cdb.prepare(`
|
|
677
|
+
INSERT OR REPLACE INTO agent_cost_model_daily (container, date, provider, model, tokens, cost, calls)
|
|
678
|
+
VALUES (@container, @date, @provider, @model, @tokens, @cost, @calls)
|
|
679
|
+
`),
|
|
680
|
+
upsertSession: cdb.prepare(`
|
|
681
|
+
INSERT INTO agent_cost_sessions (container, session_id, session_key, label, agent_id, provider, model,
|
|
682
|
+
tokens, cost, missing, first_activity, last_activity)
|
|
683
|
+
VALUES (@container, @session_id, @session_key, @label, @agent_id, @provider, @model,
|
|
684
|
+
@tokens, @cost, @missing, @first_activity, @last_activity)
|
|
685
|
+
ON CONFLICT(container, session_id) DO UPDATE SET
|
|
686
|
+
session_key = excluded.session_key, label = excluded.label, agent_id = excluded.agent_id,
|
|
687
|
+
provider = excluded.provider, model = excluded.model, tokens = excluded.tokens, cost = excluded.cost,
|
|
688
|
+
missing = excluded.missing, first_activity = excluded.first_activity, last_activity = excluded.last_activity
|
|
689
|
+
`),
|
|
690
|
+
insertBalance: cdb.prepare(`
|
|
691
|
+
INSERT OR REPLACE INTO vendor_balance (ts, vendor, currency, balance, usage, agents_cost)
|
|
692
|
+
VALUES (@ts, @vendor, @currency, @balance, @usage, @agents_cost)
|
|
693
|
+
`),
|
|
694
|
+
lastBalanceTs: cdb.prepare('SELECT MAX(ts) AS ts FROM vendor_balance WHERE vendor = ?'),
|
|
695
|
+
pruneBalances: cdb.prepare('DELETE FROM vendor_balance WHERE ts < ?'),
|
|
696
|
+
agentsCostFor: cdb.prepare(`SELECT COALESCE(SUM(cost), 0) AS cost FROM agent_cost_model_daily WHERE model LIKE ? || '/%'`),
|
|
697
|
+
upsertSource: cdb.prepare(`
|
|
698
|
+
INSERT INTO agent_cost_sources (container, agent_id, name, collected_at, attempted_at, error, missing_by_model)
|
|
699
|
+
VALUES (@container, @agent_id, @name, @collected_at, @attempted_at, @error, @missing_by_model)
|
|
700
|
+
ON CONFLICT(container) DO UPDATE SET
|
|
701
|
+
agent_id = excluded.agent_id, name = excluded.name,
|
|
702
|
+
collected_at = COALESCE(excluded.collected_at, agent_cost_sources.collected_at),
|
|
703
|
+
attempted_at = excluded.attempted_at, error = excluded.error,
|
|
704
|
+
missing_by_model = COALESCE(excluded.missing_by_model, agent_cost_sources.missing_by_model)
|
|
705
|
+
`),
|
|
706
|
+
};
|
|
707
|
+
} catch (err) {
|
|
708
|
+
log(`[COSTS] disabled: cannot open ${COSTS_DB_PATH}: ${err.message}`);
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const COSTS_INTERVAL_MS = 5 * 60 * 1000;
|
|
714
|
+
const COSTS_FIRST_DELAY_MS = 20_000;
|
|
715
|
+
/** Days re-read on every pass: late calls and repriced ones land in the right day. */
|
|
716
|
+
const COSTS_WINDOW_DAYS = 30;
|
|
717
|
+
/** The most recent sessions kept per pass; older ones stay as last read. */
|
|
718
|
+
const COSTS_SESSIONS_LIMIT = 50;
|
|
719
|
+
/**
|
|
720
|
+
* OpenClaw answers from an index of its transcripts, rebuilt in the background after a
|
|
721
|
+
* price change or a restart; until then it answers zeros with `cacheStatus` not `fresh`.
|
|
722
|
+
* Such an answer is never stored: ask again a little later, a few times. Measured on a
|
|
723
|
+
* VPS (2026-09-27): about 60 s to rebuild, from 4 to 290 transcript files alike.
|
|
724
|
+
*/
|
|
725
|
+
const COSTS_FRESH_ATTEMPTS = 6;
|
|
726
|
+
const COSTS_FRESH_WAIT_MS = 20_000;
|
|
727
|
+
const COSTS_CALL_TIMEOUT_MS = 60_000;
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Runs `openclaw "$@"` against the agent's own Gateway on port 3000, with the token
|
|
731
|
+
* resolved inside the container as the entrypoint does (lib/agent-devices.ts, same
|
|
732
|
+
* script): it never reaches argv on the host. Arguments stay positional.
|
|
733
|
+
*/
|
|
734
|
+
const COSTS_GATEWAY_CLI = [
|
|
735
|
+
'export OPENCLAW_GATEWAY_PORT=3000',
|
|
736
|
+
'if [ -f /root/.agent-token ]; then OPENCLAW_GATEWAY_TOKEN=$(head -1 /root/.agent-token | tr -d "[:space:]"); export OPENCLAW_GATEWAY_TOKEN; fi',
|
|
737
|
+
'exec openclaw "$@"',
|
|
738
|
+
].join('; ');
|
|
739
|
+
|
|
740
|
+
const EXEC_MAX_BUFFER = 32 * 1024 * 1024;
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Why a command failed, in words a person can act on. `openclaw gateway call --json`
|
|
744
|
+
* prints its error as JSON on stdout (`{"ok":false,"error":{"message":…}}`) and exits 1,
|
|
745
|
+
* with stderr often empty; Node's own message is the whole command line. So: the JSON
|
|
746
|
+
* error, else the last stderr line, else the kind of failure — never the argv.
|
|
747
|
+
*/
|
|
748
|
+
function failureReason(err, stdout, stderr) {
|
|
749
|
+
if (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') return 'answer larger than the read limit';
|
|
750
|
+
if (err.killed) return 'timed out';
|
|
751
|
+
const out = String(stdout || '');
|
|
752
|
+
const start = out.indexOf('{');
|
|
753
|
+
if (start >= 0) {
|
|
754
|
+
try {
|
|
755
|
+
const j = JSON.parse(out.slice(start));
|
|
756
|
+
const m = j?.error?.message ?? (typeof j?.error === 'string' ? j.error : null);
|
|
757
|
+
if (m) return String(m).split('\n')[0];
|
|
758
|
+
} catch { /* not JSON: fall through */ }
|
|
759
|
+
}
|
|
760
|
+
const line = String(stderr || '').trim().split('\n').filter(Boolean).pop();
|
|
761
|
+
return line || `exited with code ${err.code ?? 'unknown'}`;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function execFileAsync(cmd, args, timeoutMs) {
|
|
765
|
+
return new Promise((resolve, reject) => {
|
|
766
|
+
execFile(cmd, args, { timeout: timeoutMs, maxBuffer: EXEC_MAX_BUFFER, encoding: 'utf-8' }, (err, stdout, stderr) => {
|
|
767
|
+
if (err) return reject(new Error(failureReason(err, stdout, stderr)));
|
|
768
|
+
resolve(stdout);
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async function gatewayCall(container, method, params) {
|
|
774
|
+
const out = await execFileAsync('docker', ['exec', container, 'sh', '-c', COSTS_GATEWAY_CLI, 'sh',
|
|
775
|
+
'gateway', 'call', method, '--json', '--timeout', String(COSTS_CALL_TIMEOUT_MS - 5000), '--params', JSON.stringify(params)],
|
|
776
|
+
COSTS_CALL_TIMEOUT_MS);
|
|
777
|
+
const start = out.indexOf('{');
|
|
778
|
+
if (start < 0) throw new Error(`${method}: no JSON in the answer`);
|
|
779
|
+
return JSON.parse(out.slice(start));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/** A Docker container name, as `docker ps` prints it; anything else is never exec'd (one rule: lib/docker-stats.js). */
|
|
783
|
+
const CONTAINER_NAME_RE = dockerStats.CONTAINER_NAME_RE;
|
|
784
|
+
/**
|
|
785
|
+
* Each field quoted and escaped by Docker itself (`json`, `printf "%q"`), so a display
|
|
786
|
+
* name holding a tab or a newline stays one field of one line. Only AGENT_NAME is read
|
|
787
|
+
* from the environment.
|
|
788
|
+
*/
|
|
789
|
+
const INSPECT_FORMAT = '{{json .Name}}\t{{json (index .Config.Labels "AGENT_ID")}}\t'
|
|
790
|
+
+ '{{range .Config.Env}}{{if eq (index (split . "=") 0) "AGENT_NAME"}}{{printf "%q" .}}{{end}}{{end}}';
|
|
791
|
+
|
|
792
|
+
function parseQuoted(field) {
|
|
793
|
+
if (!field) return null;
|
|
794
|
+
try { return JSON.parse(field); } catch { return null; }
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** Running agent containers with their AGENT_ID and AGENT_NAME. */
|
|
798
|
+
async function listRunningAgents() {
|
|
799
|
+
const names = (await execFileAsync('docker', ['ps', '--filter', 'label=AGENT_ID', '--format', '{{.Names}}'], 10_000))
|
|
800
|
+
.split('\n').map((s) => s.trim()).filter((n) => CONTAINER_NAME_RE.test(n));
|
|
801
|
+
if (!names.length) return [];
|
|
802
|
+
let raw;
|
|
803
|
+
try {
|
|
804
|
+
raw = await execFileAsync('docker', ['inspect', '--format', INSPECT_FORMAT, ...names], 10_000);
|
|
805
|
+
} catch {
|
|
806
|
+
// One container removed between `ps` and `inspect` fails the batch: ask one by one.
|
|
807
|
+
const parts = [];
|
|
808
|
+
for (const n of names) {
|
|
809
|
+
parts.push(await execFileAsync('docker', ['inspect', '--format', INSPECT_FORMAT, n], 10_000).catch(() => ''));
|
|
810
|
+
}
|
|
811
|
+
raw = parts.join('\n');
|
|
812
|
+
}
|
|
813
|
+
return raw.split('\n').filter(Boolean).map((line) => {
|
|
814
|
+
const [name, agentId, env] = line.split('\t');
|
|
815
|
+
const container = String(parseQuoted(name) ?? '').replace(/^\//, '');
|
|
816
|
+
const agentName = String(parseQuoted(env) ?? '').replace(/^AGENT_NAME=/, '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
|
|
817
|
+
return { container, agentId: parseQuoted(agentId) || null, name: agentName || null };
|
|
818
|
+
}).filter((a) => CONTAINER_NAME_RE.test(a.container));
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
const utcDate = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
822
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* An answer to store: its index is `fresh`, or there is no index to speak of — an agent
|
|
826
|
+
* with nothing in the range answers without `cacheStatus` at all.
|
|
827
|
+
*/
|
|
828
|
+
function isFresh(answer) {
|
|
829
|
+
const status = answer?.cacheStatus?.status;
|
|
830
|
+
return status === undefined || status === 'fresh';
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* The shape the store step relies on. It deletes the 30-day window before inserting, so
|
|
835
|
+
* an answer missing its lists (an error object that somehow exited 0) must never reach
|
|
836
|
+
* it: that would wipe the window and store nothing.
|
|
837
|
+
*/
|
|
838
|
+
function wellFormed(cost, usage) {
|
|
839
|
+
return cost?.ok !== false && usage?.ok !== false
|
|
840
|
+
&& Array.isArray(cost?.daily) && Array.isArray(usage?.sessions)
|
|
841
|
+
&& usage?.aggregates !== null && typeof usage?.aggregates === 'object';
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function collectAgentCosts(agent) {
|
|
845
|
+
const endDate = utcDate(Date.now());
|
|
846
|
+
const startDate = utcDate(Date.now() - (COSTS_WINDOW_DAYS - 1) * 86_400_000);
|
|
847
|
+
const range = { agentScope: 'all', startDate, endDate };
|
|
848
|
+
let cost = null;
|
|
849
|
+
let usage = null;
|
|
850
|
+
let status = null;
|
|
851
|
+
for (let attempt = 0; attempt < COSTS_FRESH_ATTEMPTS; attempt++) {
|
|
852
|
+
if (attempt) await new Promise((r) => setTimeout(r, COSTS_FRESH_WAIT_MS));
|
|
853
|
+
cost = await gatewayCall(agent.container, 'usage.cost', range);
|
|
854
|
+
usage = await gatewayCall(agent.container, 'sessions.usage', { ...range, limit: COSTS_SESSIONS_LIMIT });
|
|
855
|
+
if (isFresh(cost) && isFresh(usage)) break;
|
|
856
|
+
status = (isFresh(cost) ? usage : cost)?.cacheStatus?.status ?? null;
|
|
857
|
+
cost = null;
|
|
858
|
+
}
|
|
859
|
+
if (!cost) {
|
|
860
|
+
throw new Error(`OpenClaw's usage index still "${status}" after ${COSTS_FRESH_ATTEMPTS} tries; the previous figures are kept`);
|
|
861
|
+
}
|
|
862
|
+
if (!wellFormed(cost, usage)) throw new Error('unexpected answer from OpenClaw; the previous figures are kept');
|
|
863
|
+
|
|
864
|
+
const container = agent.container;
|
|
865
|
+
costs.db.transaction(() => {
|
|
866
|
+
costs.clearDaily.run(container, startDate);
|
|
867
|
+
for (const d of Array.isArray(cost.daily) ? cost.daily : []) {
|
|
868
|
+
if (!num(d.totalTokens) && !num(d.missingCostEntries) && !num(d.totalCost)) continue;
|
|
869
|
+
costs.insertDaily.run({
|
|
870
|
+
container, date: String(d.date),
|
|
871
|
+
input: num(d.input), output: num(d.output), cache_read: num(d.cacheRead), cache_write: num(d.cacheWrite),
|
|
872
|
+
total_tokens: num(d.totalTokens), cost: num(d.totalCost), input_cost: num(d.inputCost),
|
|
873
|
+
output_cost: num(d.outputCost), cache_read_cost: num(d.cacheReadCost), cache_write_cost: num(d.cacheWriteCost),
|
|
874
|
+
missing: num(d.missingCostEntries),
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
costs.clearModelDaily.run(container, startDate);
|
|
878
|
+
for (const m of Array.isArray(usage.aggregates?.modelDaily) ? usage.aggregates.modelDaily : []) {
|
|
879
|
+
costs.insertModelDaily.run({
|
|
880
|
+
container, date: String(m.date), provider: String(m.provider ?? 'unknown'), model: String(m.model ?? 'unknown'),
|
|
881
|
+
tokens: num(m.tokens), cost: num(m.cost), calls: num(m.count),
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
for (const s of Array.isArray(usage.sessions) ? usage.sessions : []) {
|
|
885
|
+
const u = s.usage || {};
|
|
886
|
+
const id = s.sessionId || u.sessionId || s.key;
|
|
887
|
+
if (!id) continue;
|
|
888
|
+
costs.upsertSession.run({
|
|
889
|
+
container, session_id: String(id), session_key: s.key ?? null, label: s.label ?? null,
|
|
890
|
+
agent_id: s.agentId ?? null, provider: s.modelProvider ?? null, model: s.model ?? null,
|
|
891
|
+
tokens: num(u.totalTokens), cost: num(u.totalCost), missing: num(u.missingCostEntries),
|
|
892
|
+
first_activity: u.firstActivity ?? null, last_activity: u.lastActivity ?? s.updatedAt ?? null,
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
costs.upsertSource.run({
|
|
896
|
+
container, agent_id: agent.agentId, name: agent.name, collected_at: Date.now(), attempted_at: Date.now(),
|
|
897
|
+
error: null, missing_by_model: JSON.stringify(cost.totals?.missingCostByModel || {}),
|
|
898
|
+
});
|
|
899
|
+
})();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── Vendor balances ──
|
|
903
|
+
//
|
|
904
|
+
// The check against the bill: what the vendor says it charged, next to what the agents'
|
|
905
|
+
// OpenClaw priced, over the same interval. Right after a pass of collectCosts — at most
|
|
906
|
+
// once an hour — the daemon reads each vendor's own figure and, in the same row, the
|
|
907
|
+
// agents' cumulative priced total for that vendor's models. Between two rows the balance
|
|
908
|
+
// drop (DeepSeek) or the usage growth (OpenRouter) is what the vendor billed, and the
|
|
909
|
+
// agents_cost growth is what the agents priced. Other users of the same key (the
|
|
910
|
+
// assistant, clients outside Rev4a) and top-ups show up as a difference; the API names
|
|
911
|
+
// both. Read-only calls, never billed.
|
|
912
|
+
|
|
913
|
+
const BALANCE_INTERVAL_MS = 60 * 60 * 1000;
|
|
914
|
+
const BALANCE_RETENTION_MS = 400 * 24 * 3600 * 1000;
|
|
915
|
+
const PROVIDER_KEYS_PATH = path.join(DATA_DIR, 'data', 'provider-keys.json');
|
|
916
|
+
|
|
917
|
+
const VENDOR_BALANCES = {
|
|
918
|
+
/** { currency, balance } — DeepSeek's total balance (topped-up plus granted). */
|
|
919
|
+
deepseek: async (key) => {
|
|
920
|
+
const r = await vendorFetch('https://api.deepseek.com/user/balance', key);
|
|
921
|
+
if (!r.ok) throw new Error(`DeepSeek balance API returned ${r.status}`);
|
|
922
|
+
const j = await r.json();
|
|
923
|
+
const info = (j.balance_infos || []).find((i) => i.currency === 'USD') || (j.balance_infos || [])[0];
|
|
924
|
+
if (!info) throw new Error('DeepSeek returned no balance');
|
|
925
|
+
return { currency: info.currency, balance: Number(info.total_balance), usage: null };
|
|
926
|
+
},
|
|
927
|
+
/** OpenRouter's lifetime usage, which only grows: the cleanest measure of spend. */
|
|
928
|
+
openrouter: async (key) => {
|
|
929
|
+
const r = await vendorFetch('https://openrouter.ai/api/v1/credits', key);
|
|
930
|
+
if (!r.ok) throw new Error(`OpenRouter credits API returned ${r.status}`);
|
|
931
|
+
const d = (await r.json()).data || {};
|
|
932
|
+
return { currency: 'USD', balance: Number(d.total_credits) - Number(d.total_usage), usage: Number(d.total_usage) };
|
|
933
|
+
},
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/**
|
|
937
|
+
* A request that failed before any HTTP answer (network, invalid header) keeps only its
|
|
938
|
+
* kind: Node's message for an invalid header value quotes the header — the key itself,
|
|
939
|
+
* when a pasted key carries a line break.
|
|
940
|
+
*/
|
|
941
|
+
async function vendorFetch(url, key) {
|
|
942
|
+
try {
|
|
943
|
+
return await fetch(url, { headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000) });
|
|
944
|
+
} catch (e) {
|
|
945
|
+
throw new Error(e?.name === 'TimeoutError' ? 'request timed out' : 'request failed (network, or a malformed key)');
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
async function collectVendorBalances() {
|
|
950
|
+
let keys = {};
|
|
951
|
+
try { keys = JSON.parse(fs.readFileSync(PROVIDER_KEYS_PATH, 'utf8')); } catch { return; }
|
|
952
|
+
const ts = Date.now();
|
|
953
|
+
for (const [vendor, read] of Object.entries(VENDOR_BALANCES)) {
|
|
954
|
+
if (!keys[vendor]) continue;
|
|
955
|
+
// Per vendor: one that failed last time is asked again next pass, not next hour.
|
|
956
|
+
const last = costs.lastBalanceTs.get(vendor).ts;
|
|
957
|
+
if (last && Date.now() - last < BALANCE_INTERVAL_MS - 60_000) continue;
|
|
958
|
+
try {
|
|
959
|
+
const b = await read(keys[vendor]);
|
|
960
|
+
if (!Number.isFinite(b.balance)) throw new Error('no numeric balance');
|
|
961
|
+
costs.insertBalance.run({ ts, vendor, currency: b.currency, balance: b.balance, usage: b.usage,
|
|
962
|
+
agents_cost: costs.agentsCostFor.get(vendor).cost });
|
|
963
|
+
} catch (e) {
|
|
964
|
+
log(`[COSTS] ${vendor} balance: ${e.message}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
costs.pruneBalances.run(ts - BALANCE_RETENTION_MS);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
let costsRunning = false;
|
|
971
|
+
async function collectCosts() {
|
|
972
|
+
if (!costs || costsRunning) return;
|
|
973
|
+
costsRunning = true;
|
|
974
|
+
try {
|
|
975
|
+
let agents;
|
|
976
|
+
try {
|
|
977
|
+
agents = await listRunningAgents();
|
|
978
|
+
} catch (e) {
|
|
979
|
+
log(`[COSTS] Docker unreachable: ${e.message}`);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
// Wake every agent first: the first call starts its index rebuild, and the rebuilds
|
|
983
|
+
// then run in parallel (about a minute each) instead of one after the other — after a
|
|
984
|
+
// price change every agent rebuilds, and in turn a pass would outlast the interval.
|
|
985
|
+
for (const agent of agents) {
|
|
986
|
+
await gatewayCall(agent.container, 'usage.cost', { agentScope: 'all', startDate: utcDate(Date.now()), endDate: utcDate(Date.now()) })
|
|
987
|
+
.catch(() => { /* the read below reports it */ });
|
|
988
|
+
}
|
|
989
|
+
// Then one agent at a time: each call is a short-lived CLI in that container.
|
|
990
|
+
for (const agent of agents) {
|
|
991
|
+
try {
|
|
992
|
+
await collectAgentCosts(agent);
|
|
993
|
+
} catch (e) {
|
|
994
|
+
costs.upsertSource.run({
|
|
995
|
+
container: agent.container, agent_id: agent.agentId, name: agent.name, collected_at: null,
|
|
996
|
+
attempted_at: Date.now(), error: String(e.message).slice(0, 300), missing_by_model: null,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
// Only after the agents were read, so both sides of the row describe the same moment.
|
|
1001
|
+
await collectVendorBalances().catch((e) => log(`[COSTS] balances: ${e.message}`));
|
|
1002
|
+
} finally {
|
|
1003
|
+
costsRunning = false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
451
1007
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
452
1008
|
|
|
453
1009
|
function log(msg) {
|
|
@@ -609,7 +1165,7 @@ function pollSessions() {
|
|
|
609
1165
|
const model = s.model || null;
|
|
610
1166
|
const tokens_in = s.inputTokens || 0;
|
|
611
1167
|
const tokens_out = s.outputTokens || 0;
|
|
612
|
-
const cost_usd =
|
|
1168
|
+
const cost_usd = 0;
|
|
613
1169
|
const started_at = s.updatedAt ? s.updatedAt - (s.ageMs || 0) : now;
|
|
614
1170
|
const updated_at = s.updatedAt || now;
|
|
615
1171
|
|
|
@@ -660,14 +1216,14 @@ function pollSessions() {
|
|
|
660
1216
|
ts: now,
|
|
661
1217
|
session_id,
|
|
662
1218
|
type: 'complete',
|
|
663
|
-
data: JSON.stringify({
|
|
1219
|
+
data: JSON.stringify({ tokens: tokens_in + tokens_out }),
|
|
664
1220
|
});
|
|
665
1221
|
} else if (prevSnapshot && prevSnapshot.status !== 'error' && status === 'error') {
|
|
666
1222
|
pendingEvents.push({
|
|
667
1223
|
ts: now,
|
|
668
1224
|
session_id,
|
|
669
1225
|
type: 'fail',
|
|
670
|
-
data: JSON.stringify({
|
|
1226
|
+
data: JSON.stringify({ tokens: tokens_in + tokens_out, reason: 'openclaw_error_signal' }),
|
|
671
1227
|
});
|
|
672
1228
|
}
|
|
673
1229
|
|
|
@@ -821,14 +1377,57 @@ const firstSample = metrics
|
|
|
821
1377
|
: null;
|
|
822
1378
|
if (metrics) log(`Machine metrics every ${METRICS_INTERVAL_MS / 1000}s into ${METRICS_DB_PATH}`);
|
|
823
1379
|
|
|
1380
|
+
// Container consumption: a priming pass, a short wait for the first CPU figure, then every
|
|
1381
|
+
// interval; the disk Docker holds on a slower timer. Off when metrics.db failed.
|
|
1382
|
+
function sampleContainers() {
|
|
1383
|
+
collectContainerMetrics().catch((e) => log(`[CONTAINERS ERROR] ${e.message}`));
|
|
1384
|
+
}
|
|
1385
|
+
function sampleStorage() {
|
|
1386
|
+
collectDockerStorage().catch((e) => log(`[CONTAINERS ERROR] ${e.message}`));
|
|
1387
|
+
}
|
|
1388
|
+
let containersTimer = null;
|
|
1389
|
+
let containersPasses = 0;
|
|
1390
|
+
function scheduleContainers(delay) {
|
|
1391
|
+
containersTimer = setTimeout(() => {
|
|
1392
|
+
sampleContainers();
|
|
1393
|
+
containersPasses++;
|
|
1394
|
+
scheduleContainers(containersPasses === 1 ? CONTAINERS_SECOND_DELAY_MS : CONTAINERS_INTERVAL_MS);
|
|
1395
|
+
}, delay);
|
|
1396
|
+
}
|
|
1397
|
+
let storageTimer = null;
|
|
1398
|
+
const firstStorage = metrics
|
|
1399
|
+
? setTimeout(() => { sampleStorage(); storageTimer = setInterval(sampleStorage, STORAGE_INTERVAL_MS); }, STORAGE_FIRST_DELAY_MS)
|
|
1400
|
+
: null;
|
|
1401
|
+
if (metrics) {
|
|
1402
|
+
scheduleContainers(CONTAINERS_FIRST_DELAY_MS);
|
|
1403
|
+
log(`Container consumption every ${CONTAINERS_INTERVAL_MS / 1000}s, Docker storage every ${STORAGE_INTERVAL_MS / 60000} min, into ${METRICS_DB_PATH}`);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Agent costs: first pass shortly after start (agents may still be booting), then every
|
|
1407
|
+
// interval. Off when costs.db failed.
|
|
1408
|
+
function sampleCosts() {
|
|
1409
|
+
collectCosts().catch((e) => log(`[COSTS ERROR] ${e.message}`));
|
|
1410
|
+
}
|
|
1411
|
+
let costsTimer = null;
|
|
1412
|
+
const firstCosts = costs
|
|
1413
|
+
? setTimeout(() => { sampleCosts(); costsTimer = setInterval(sampleCosts, COSTS_INTERVAL_MS); }, COSTS_FIRST_DELAY_MS)
|
|
1414
|
+
: null;
|
|
1415
|
+
if (costs) log(`Agent costs every ${COSTS_INTERVAL_MS / 60000} min into ${COSTS_DB_PATH}`);
|
|
1416
|
+
|
|
824
1417
|
// Graceful shutdown
|
|
825
1418
|
process.on('SIGTERM', () => {
|
|
826
1419
|
log('SIGTERM received, shutting down...');
|
|
827
1420
|
clearInterval(timer);
|
|
828
1421
|
clearTimeout(firstSample);
|
|
829
1422
|
clearInterval(metricsTimer);
|
|
1423
|
+
clearTimeout(containersTimer);
|
|
1424
|
+
clearTimeout(firstStorage);
|
|
1425
|
+
clearInterval(storageTimer);
|
|
1426
|
+
clearTimeout(firstCosts);
|
|
1427
|
+
clearInterval(costsTimer);
|
|
830
1428
|
db.close();
|
|
831
1429
|
metrics?.db.close();
|
|
1430
|
+
costs?.db.close();
|
|
832
1431
|
process.exit(0);
|
|
833
1432
|
});
|
|
834
1433
|
|
|
@@ -837,7 +1436,13 @@ process.on('SIGINT', () => {
|
|
|
837
1436
|
clearInterval(timer);
|
|
838
1437
|
clearTimeout(firstSample);
|
|
839
1438
|
clearInterval(metricsTimer);
|
|
1439
|
+
clearTimeout(containersTimer);
|
|
1440
|
+
clearTimeout(firstStorage);
|
|
1441
|
+
clearInterval(storageTimer);
|
|
1442
|
+
clearTimeout(firstCosts);
|
|
1443
|
+
clearInterval(costsTimer);
|
|
840
1444
|
db.close();
|
|
841
1445
|
metrics?.db.close();
|
|
1446
|
+
costs?.db.close();
|
|
842
1447
|
process.exit(0);
|
|
843
1448
|
});
|