@flame0510/project-aether 1.9.1 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +3 -3
  2. package/app/agents/CostSection.tsx +94 -0
  3. package/app/agents/PageClient.tsx +14 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +10 -3
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +55 -0
  9. package/app/api/costs/route.ts +19 -77
  10. package/app/api/costs/usage/route.ts +65 -0
  11. package/app/api/gateway/sync.ts +18 -4
  12. package/app/api/metrics/alerts/route.ts +47 -0
  13. package/app/api/metrics/containers/route.ts +54 -0
  14. package/app/api/metrics/route.ts +4 -6
  15. package/app/api/stats/route.ts +2 -3
  16. package/app/api/stats-since/route.ts +1 -1
  17. package/app/api/stream/route.ts +3 -5
  18. package/app/api/system-health/route.ts +33 -32
  19. package/app/components/CostBreakdown.tsx +1 -1
  20. package/app/components/Sidebar.tsx +10 -0
  21. package/app/components/SystemCockpit.tsx +1 -1
  22. package/app/components/ui/Accordion.tsx +45 -0
  23. package/app/components/ui/TimeSeriesChart.tsx +47 -11
  24. package/app/components/ui/index.ts +1 -0
  25. package/app/containers/ContainersClient.tsx +48 -6
  26. package/app/costs/CostsSkeleton.tsx +88 -0
  27. package/app/costs/PageClient.tsx +366 -0
  28. package/app/costs/loading.tsx +13 -0
  29. package/app/costs/page.tsx +5 -0
  30. package/app/globals.css +42 -0
  31. package/app/system/AgentCharts.tsx +138 -0
  32. package/app/system/AgentsSection.tsx +281 -0
  33. package/app/system/PageClient.tsx +7 -7
  34. package/app/system/RecentAlerts.tsx +72 -0
  35. package/app/system/SystemSkeleton.tsx +53 -1
  36. package/app/system/loading.tsx +5 -1
  37. package/daemon.js +646 -41
  38. package/docs/ARCHITECTURE.md +72 -31
  39. package/docs/DESIGN-SYSTEM.md +2 -2
  40. package/docs/FRONTEND-ARCHITECTURE.md +14 -3
  41. package/docs/REV4A.md +6 -5
  42. package/docs/dev/API-REFERENCE.md +208 -38
  43. package/docs/dev/DATABASE.md +176 -20
  44. package/docs/dev/GATEWAY.md +53 -9
  45. package/docs/rag/DATA-FRESHNESS.md +34 -7
  46. package/docs/rag/GLOSSARY.md +8 -5
  47. package/docs/rag/REV4A-OVERVIEW.md +10 -3
  48. package/docs/rag/WHAT-I-CAN-ANSWER.md +6 -3
  49. package/instrumentation.ts +11 -0
  50. package/lib/agent-costs.ts +172 -0
  51. package/lib/container-metrics.ts +340 -0
  52. package/lib/cost-reconciliation.ts +78 -0
  53. package/lib/costs-db.ts +31 -0
  54. package/lib/docker-socket-path.js +133 -0
  55. package/lib/docker-socket.ts +10 -99
  56. package/lib/docker-stats.js +284 -0
  57. package/lib/metrics-db.ts +15 -6
  58. package/lib/model-pricing.ts +123 -3
  59. package/lib/price-schedule-sync.ts +133 -0
  60. package/lib/utils/format.ts +38 -0
  61. package/model-pricing.json +269 -121
  62. package/package.json +1 -1
  63. package/scripts/refresh-model-pricing.mjs +16 -4
  64. package/scripts/test-docker-stats.mjs +270 -0
  65. package/lib/billing.ts +0 -100
@@ -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
- * OpenRouter models are NOT included here; their pricing is fetched live
5
- * via the OpenRouter API and merged client-side.
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
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Re-sync the agents' prices when a vendor's rate changes with the time of day.
3
+ *
4
+ * OpenClaw's `cost` has no notion of time: it prices each call with the rates in its
5
+ * config at that moment and keeps that cost. DeepSeek bills half price off-peak, so at
6
+ * every change of band the `rev4a` block is written again with the rates now in force
7
+ * (`priceAt()` in `buildRev4aProviderConfig()`). A config patch of `models` applies
8
+ * without a gateway restart.
9
+ *
10
+ * A check, not one long timer: at most every minute it compares the bands in force with
11
+ * the ones last written, and syncs only when they differ. A single timeout to the next
12
+ * change would fire late after the machine sleeps (a laptop), leaving every agent on
13
+ * the other band's rates; the check costs a little arithmetic and no I/O. When the next
14
+ * change is less than a minute away the check is set for that moment, so a change is
15
+ * applied within a couple of seconds.
16
+ *
17
+ * The check runs whether or not a scheduled model is on offer, and skips the sync when
18
+ * none is: enabling a DeepSeek model later needs no restart to be priced right. A sync
19
+ * that did not reach every agent is retried every few minutes, not every check — a full
20
+ * sync holds the server for a few seconds — and at most a few times: an agent busy with
21
+ * an update, recreate, restore or edit writes the prices itself when it finishes, and one
22
+ * that stays broken must not keep the server syncing every agent around the clock. A new
23
+ * change of band is applied at once, whatever retry was pending.
24
+ */
25
+ import { syncAllAgents } from '@/app/api/gateway/sync';
26
+ import { loadModelsConfig, loadOfferedModels } from '@/lib/model-catalogue';
27
+ import { PRICE_SCHEDULES, nextPriceChange, priceBandAt, priceScheduleFor } from '@/lib/model-pricing';
28
+
29
+ const CHECK_MS = 60_000;
30
+ /** Past the hour mark, so the band has changed when the sync reads the clock. */
31
+ const AFTER_CHANGE_MS = 2_000;
32
+ const RETRY_MS = 5 * 60_000;
33
+ /** Syncs tried for one band before waiting for the next change (or the next save or restart). */
34
+ const MAX_TRIES = 3;
35
+
36
+ interface State {
37
+ timer?: ReturnType<typeof setTimeout>;
38
+ /**
39
+ * The bands last written to the agents, e.g. `deepseek:off-peak`. Set by every sync,
40
+ * complete or not: a sync that misses one agent still writes every other one.
41
+ */
42
+ applied?: string;
43
+ /** Syncs tried for `applied` that missed an agent. */
44
+ tries?: number;
45
+ /** When to retry for the agents the last sync missed; unset when nothing is owed. */
46
+ retryAfter?: number;
47
+ }
48
+ // One timer per process, across dev-mode module reloads.
49
+ const g = globalThis as unknown as { __rev4aPriceSchedule?: State };
50
+ const state: State = (g.__rev4aPriceSchedule ??= {});
51
+
52
+ /** The band of every scheduled vendor right now, as one comparable string. */
53
+ function bandsNow(at: Date): string {
54
+ return Object.keys(PRICE_SCHEDULES)
55
+ .map((vendor) => `${vendor}:${priceBandAt(`${vendor}/`, at)}`)
56
+ .join(',');
57
+ }
58
+
59
+ function schedule(): void {
60
+ if (state.timer) clearTimeout(state.timer);
61
+ const next = nextPriceChange(loadModelsConfig().map((m) => m.id));
62
+ const untilChange = next ? next.getTime() - Date.now() + AFTER_CHANGE_MS : Infinity;
63
+ state.timer = setTimeout(check, Math.max(1_000, Math.min(CHECK_MS, untilChange)));
64
+ state.timer.unref?.();
65
+ }
66
+
67
+ /** Write `bands` into every agent; on a miss, owe a retry until MAX_TRIES. */
68
+ function sync(bands: string): void {
69
+ let ok = false;
70
+ let summary = '';
71
+ try {
72
+ const outcome = syncAllAgents();
73
+ ok = outcome.ok;
74
+ summary = outcome.summary;
75
+ } catch (e) {
76
+ summary = (e as Error).message;
77
+ }
78
+ state.applied = bands;
79
+ if (ok) {
80
+ state.tries = 0;
81
+ state.retryAfter = undefined;
82
+ console.log(`[rev4a] Price band now ${bands}: ${summary}`);
83
+ return;
84
+ }
85
+ state.tries = (state.tries ?? 0) + 1;
86
+ if (state.tries < MAX_TRIES) {
87
+ state.retryAfter = Date.now() + RETRY_MS;
88
+ console.warn(`[rev4a] Price band now ${bands}, sync incomplete: ${summary} Retrying in ${RETRY_MS / 60_000} min.`);
89
+ } else {
90
+ state.retryAfter = undefined;
91
+ console.warn(`[rev4a] Price band now ${bands}, sync incomplete after ${state.tries} tries: ${summary} Not retried until the next change; Sync All Agents applies it sooner.`);
92
+ }
93
+ }
94
+
95
+ function check(): void {
96
+ try {
97
+ const bands = bandsNow(new Date());
98
+ const offered = loadOfferedModels().some((m) => priceScheduleFor(m.id));
99
+ if (bands !== state.applied) {
100
+ // A change of band: write it now, whatever retry was owed for the previous one.
101
+ state.tries = 0;
102
+ state.retryAfter = undefined;
103
+ if (offered) sync(bands);
104
+ else state.applied = bands; // nothing on offer is priced by time of day
105
+ } else if (state.retryAfter && Date.now() >= state.retryAfter) {
106
+ if (offered) sync(bands);
107
+ else state.retryAfter = undefined;
108
+ }
109
+ } catch (e) {
110
+ console.warn(`[rev4a] Price band check failed: ${(e as Error).message}`);
111
+ } finally {
112
+ try {
113
+ schedule();
114
+ } catch (e) {
115
+ // Never let the chain die: check again in a minute.
116
+ console.warn(`[rev4a] Price band check could not be scheduled: ${(e as Error).message}`);
117
+ state.timer = setTimeout(check, CHECK_MS);
118
+ state.timer.unref?.();
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Start the checks; called once at server start, after the startup sync. When that sync
125
+ * reached every agent, the bands it wrote count as applied; otherwise the first check
126
+ * (a minute later) writes them.
127
+ */
128
+ export function startPriceScheduleSync(startupSynced: boolean): void {
129
+ state.applied = startupSynced ? bandsNow(new Date()) : undefined;
130
+ state.tries = 0;
131
+ state.retryAfter = undefined;
132
+ schedule();
133
+ }
@@ -4,6 +4,44 @@ export function formatUsd(value: number | null | undefined): string {
4
4
  return `$${Number(value ?? 0).toFixed(2)}`;
5
5
  }
6
6
 
7
+ /** Megabytes as MB, GB or TB with the precision a reader wants: '692 MB', '1.6 GB'. */
8
+ export function formatMb(mb: number | null | undefined): string {
9
+ if (mb === null || mb === undefined || !Number.isFinite(mb)) return '—';
10
+ if (mb >= 1024 * 1024) return `${(mb / 1024 / 1024).toFixed(2)} TB`;
11
+ if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`;
12
+ return `${Math.round(mb)} MB`;
13
+ }
14
+
15
+ /** A CPU share in percent, with the decimals a small figure needs (an idle agent is 0.02 % of Docker's cores). */
16
+ export function formatSharePercent(percent: number | null | undefined): string {
17
+ if (percent === null || percent === undefined || !Number.isFinite(percent)) return '—';
18
+ if (percent === 0) return '0%';
19
+ if (percent < 0.01) return '<0.01%';
20
+ if (percent < 1) return `${percent.toFixed(2)}%`;
21
+ if (percent < 10) return `${percent.toFixed(1)}%`;
22
+ return `${Math.round(percent)}%`;
23
+ }
24
+
25
+ /** Bytes per second as B/s, KB/s or MB/s. */
26
+ export function formatRate(bytesPerSecond: number | null | undefined): string {
27
+ if (bytesPerSecond === null || bytesPerSecond === undefined || !Number.isFinite(bytesPerSecond)) return '—';
28
+ if (bytesPerSecond >= 1024 * 1024) return `${(bytesPerSecond / 1024 / 1024).toFixed(1)} MB/s`;
29
+ if (bytesPerSecond >= 1024) return `${(bytesPerSecond / 1024).toFixed(1)} KB/s`;
30
+ return `${Math.round(bytesPerSecond)} B/s`;
31
+ }
32
+
33
+ /**
34
+ * Dollars with the precision a small figure needs: an agent's day can be a fraction of a
35
+ * cent, which formatUsd would show as $0.00. Used wherever agent costs are shown.
36
+ */
37
+ export function formatCost(value: number | null | undefined): string {
38
+ const v = Number(value ?? 0);
39
+ if (!v) return '$0.00';
40
+ if (v < 0.01) return `$${v.toFixed(4)}`;
41
+ if (v < 1) return `$${v.toFixed(3)}`;
42
+ return `$${v.toFixed(2)}`;
43
+ }
44
+
7
45
  export function formatUsdOrDash(value: number | null | undefined): string {
8
46
  if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-';
9
47
  return formatUsd(value);