@flame0510/project-aether 1.10.0 → 1.11.1

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 (57) hide show
  1. package/README.md +6 -2
  2. package/app/agents/CostSection.tsx +12 -10
  3. package/app/agents/PageClient.tsx +7 -0
  4. package/app/agents/PanelRow.tsx +9 -0
  5. package/app/agents/ResourceSection.tsx +105 -0
  6. package/app/api/assistant/route.ts +2 -2
  7. package/app/api/containers/route.ts +18 -2
  8. package/app/api/costs/agent/route.ts +20 -3
  9. package/app/api/gateway/provider/route.ts +20 -2
  10. package/app/api/metrics/alerts/route.ts +47 -0
  11. package/app/api/metrics/containers/route.ts +54 -0
  12. package/app/api/metrics/route.ts +4 -6
  13. package/app/api/update-check/route.ts +20 -11
  14. package/app/components/ui/Accordion.tsx +45 -0
  15. package/app/components/ui/TimeSeriesChart.tsx +20 -2
  16. package/app/components/ui/index.ts +1 -0
  17. package/app/containers/ContainersClient.tsx +48 -6
  18. package/app/gateway/ModelDetailsModal.tsx +4 -2
  19. package/app/gateway/PageClient.tsx +86 -36
  20. package/app/globals.css +36 -0
  21. package/app/system/AgentCharts.tsx +138 -0
  22. package/app/system/AgentsSection.tsx +281 -0
  23. package/app/system/PageClient.tsx +11 -9
  24. package/app/system/RecentAlerts.tsx +72 -0
  25. package/app/system/SystemSkeleton.tsx +53 -1
  26. package/app/system/loading.tsx +5 -1
  27. package/bin/postinstall.js +7 -4
  28. package/bin/rev4a.js +6 -7
  29. package/daemon.js +195 -4
  30. package/docs/ARCHITECTURE.md +36 -5
  31. package/docs/FRONTEND-ARCHITECTURE.md +13 -4
  32. package/docs/REV4A.md +9 -6
  33. package/docs/dev/API-REFERENCE.md +116 -30
  34. package/docs/dev/DATABASE.md +58 -2
  35. package/docs/dev/GATEWAY.md +13 -4
  36. package/docs/rag/DATA-FRESHNESS.md +16 -1
  37. package/docs/rag/GLOSSARY.md +3 -3
  38. package/docs/rag/REV4A-OVERVIEW.md +9 -5
  39. package/docs/rag/WHAT-I-CAN-ANSWER.md +4 -1
  40. package/lib/container-metrics.ts +340 -0
  41. package/lib/docker-socket-path.d.ts +9 -0
  42. package/lib/docker-socket-path.js +133 -0
  43. package/lib/docker-socket.ts +10 -99
  44. package/lib/docker-stats.d.ts +88 -0
  45. package/lib/docker-stats.js +284 -0
  46. package/lib/metrics-db.ts +15 -6
  47. package/lib/model-details.ts +3 -3
  48. package/lib/model-pricing.ts +6 -4
  49. package/lib/utils/format.ts +29 -3
  50. package/model-details.json +2799 -2274
  51. package/model-pricing.json +63 -48
  52. package/models.config.json +41 -10
  53. package/npm-shrinkwrap.json +1979 -0
  54. package/package.json +11 -9
  55. package/scripts/check-language.mjs +1 -1
  56. package/scripts/check-package-types.mjs +74 -0
  57. package/scripts/test-docker-stats.mjs +270 -0
@@ -0,0 +1,88 @@
1
+ /** Types for the CommonJS Docker stats parser shared with daemon.js. */
2
+
3
+ export const CONTAINER_NAME_RE: RegExp;
4
+ export const DF_TIMEOUT_MS: number;
5
+ export const MB: number;
6
+
7
+ export function cleanName(value: unknown, fallback?: string | null): string | null;
8
+ export function primaryName(names: unknown): string;
9
+ export function workingSetBytes(memory: unknown): number | null;
10
+ export function sumNetwork(networks: unknown): { rx: number | null; tx: number | null };
11
+ export function sumBlockIo(blkio: unknown): { rd: number | null; wr: number | null };
12
+
13
+ export interface ContainerCounters {
14
+ t: number;
15
+ cpuNs: number;
16
+ memBytes: number | null;
17
+ pids: number | null;
18
+ rx: number | null;
19
+ tx: number | null;
20
+ rd: number | null;
21
+ wr: number | null;
22
+ }
23
+
24
+ export function readCounters(stats: unknown, atMs: number): ContainerCounters | null;
25
+
26
+ export interface ContainerRates {
27
+ cpuCores: number | null;
28
+ rxBps: number | null;
29
+ txBps: number | null;
30
+ rdBps: number | null;
31
+ wrBps: number | null;
32
+ }
33
+
34
+ export function ratesBetween(
35
+ prev: ContainerCounters | null | undefined,
36
+ cur: ContainerCounters | null | undefined,
37
+ maxWindowS?: number,
38
+ ): ContainerRates;
39
+
40
+ export interface HostCapacity {
41
+ ncpu: number;
42
+ memTotalMb: number;
43
+ }
44
+
45
+ export interface ContainerRow {
46
+ container: string;
47
+ cpu_cores: number | null;
48
+ mem_mb: number | null;
49
+ pids: number | null;
50
+ net_rx_bps: number | null;
51
+ net_tx_bps: number | null;
52
+ blk_read_bps: number | null;
53
+ blk_write_bps: number | null;
54
+ }
55
+
56
+ export interface ContainerSource {
57
+ container: string;
58
+ agent_id: string | null;
59
+ name: string;
60
+ is_agent: 0 | 1;
61
+ volumes: string;
62
+ }
63
+
64
+ export interface ContainerSample {
65
+ ts: number;
66
+ host: HostCapacity | null;
67
+ rows: ContainerRow[];
68
+ sources: ContainerSource[];
69
+ }
70
+
71
+ export function createContainerSampler(options: {
72
+ request: (method: string, path: string, timeoutMs?: number) => Promise<unknown>;
73
+ now?: () => number;
74
+ maxWindowS?: number;
75
+ }): { sample: () => Promise<ContainerSample> };
76
+
77
+ export interface VolumeUsage {
78
+ name: string;
79
+ sizeBytes: number | null;
80
+ unused: boolean;
81
+ }
82
+
83
+ export function parseStorage(df: unknown): {
84
+ volumes: VolumeUsage[];
85
+ layers: { container: string; sizeBytes: number | null }[];
86
+ images: { sizeBytes: number | null; reclaimableBytes: number | null };
87
+ buildCache: { sizeBytes: number | null; reclaimableBytes: number | null };
88
+ } | null;
@@ -0,0 +1,284 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * What each container consumes, from the Docker Engine API — the parsing and the rate
5
+ * arithmetic, free of any database or timer so it can be tested on its own. daemon.js
6
+ * owns the timers and the tables (metrics.db); this module turns Docker's answers into
7
+ * rows.
8
+ *
9
+ * Plain CommonJS for the same reason as lib/docker-socket-path.js: the daemon requires it.
10
+ *
11
+ * Why the raw API and not `docker stats`: `GET /containers/{id}/stats?one-shot=true`
12
+ * answers in 10–20 ms with cumulative counters (CPU time in ns, network and block bytes),
13
+ * while `docker stats --no-stream` waits a second per container for a second sample and
14
+ * hands back formatted strings. Two samples of the daemon's own timer give the CPU
15
+ * share over a real window.
16
+ */
17
+
18
+ /** A Docker container name, as `docker ps` prints it; anything else is never used. */
19
+ const CONTAINER_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/;
20
+
21
+ const REQUEST_TIMEOUT_MS = 10_000;
22
+ const DF_TIMEOUT_MS = 60_000;
23
+ /** `docker info` (cores, memory) changes only when Docker Desktop is reconfigured. */
24
+ const HOST_TTL_MS = 10 * 60_000;
25
+ /** Two samples closer than this give a rate dominated by timing noise. */
26
+ const MIN_WINDOW_S = 1;
27
+ const MB = 1024 * 1024;
28
+
29
+ /**
30
+ * The container's own name from Docker's `Names`: the one with a single leading slash.
31
+ * A container that is the target of a legacy link also carries `/other/alias` entries, and
32
+ * nothing promises which comes first — taking the first blindly would drop it, silently.
33
+ */
34
+ function primaryName(names) {
35
+ const own = (Array.isArray(names) ? names : []).find((n) => typeof n === 'string' && /^\/[^/]+$/.test(n));
36
+ return own ? own.slice(1) : '';
37
+ }
38
+
39
+ /** A display name without control characters (a tab or a newline in a name), bounded. */
40
+ function cleanName(value, fallback = null) {
41
+ const s = String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 80);
42
+ return s || fallback;
43
+ }
44
+
45
+ const finite = (n) => typeof n === 'number' && Number.isFinite(n);
46
+
47
+ /**
48
+ * Working-set memory: usage minus the inactive page cache, what `docker stats` shows (and
49
+ * what the kernel will not give back under pressure). cgroup v2 names it `inactive_file`,
50
+ * v1 `total_inactive_file`.
51
+ */
52
+ function workingSetBytes(memory) {
53
+ const usage = memory?.usage;
54
+ if (!finite(usage)) return null;
55
+ const inactive = memory.stats?.inactive_file ?? memory.stats?.total_inactive_file ?? 0;
56
+ return Math.max(0, usage - (finite(inactive) ? inactive : 0));
57
+ }
58
+
59
+ /** Bytes received and sent, summed over the container's networks. */
60
+ function sumNetwork(networks) {
61
+ let rx = 0;
62
+ let tx = 0;
63
+ let seen = false;
64
+ for (const n of Object.values(networks || {})) {
65
+ if (finite(n?.rx_bytes)) { rx += n.rx_bytes; seen = true; }
66
+ if (finite(n?.tx_bytes)) { tx += n.tx_bytes; seen = true; }
67
+ }
68
+ return seen ? { rx, tx } : { rx: null, tx: null };
69
+ }
70
+
71
+ /** Bytes read and written on block devices (`op` is `read`/`write` on v2, `Read`/`Write` on v1). */
72
+ function sumBlockIo(blkio) {
73
+ let rd = 0;
74
+ let wr = 0;
75
+ let seen = false;
76
+ for (const e of blkio?.io_service_bytes_recursive || []) {
77
+ if (!finite(e?.value)) continue;
78
+ const op = String(e.op || '').toLowerCase();
79
+ if (op === 'read') { rd += e.value; seen = true; }
80
+ else if (op === 'write') { wr += e.value; seen = true; }
81
+ }
82
+ return seen ? { rd, wr } : { rd: null, wr: null };
83
+ }
84
+
85
+ /**
86
+ * One stats answer as counters, or null when it holds no CPU figure (a stopped container, or
87
+ * one that has only just started, answers an empty object).
88
+ */
89
+ function readCounters(stats, atMs) {
90
+ const cpuNs = stats?.cpu_stats?.cpu_usage?.total_usage;
91
+ if (!finite(cpuNs)) return null;
92
+ const net = sumNetwork(stats.networks);
93
+ const blk = sumBlockIo(stats.blkio_stats);
94
+ return {
95
+ t: atMs,
96
+ cpuNs,
97
+ memBytes: workingSetBytes(stats.memory_stats),
98
+ pids: finite(stats.pids_stats?.current) ? stats.pids_stats.current : null,
99
+ rx: net.rx, tx: net.tx, rd: blk.rd, wr: blk.wr,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Per-second rates between two readings of one container. Each counter stands alone: one
105
+ * that went backwards (the container restarted, the network was re-attached) gives null and
106
+ * nothing else — never a negative rate, never a spike. No earlier reading, a window under a
107
+ * second, or one longer than `maxWindowS`: all null. The long window matters: after a laptop
108
+ * slept or a pass was skipped, the counters moved little over many hours, and dividing by the
109
+ * whole gap would draw a confident near-zero instead of no figure.
110
+ */
111
+ function ratesBetween(prev, cur, maxWindowS = Infinity) {
112
+ const none = { cpuCores: null, rxBps: null, txBps: null, rdBps: null, wrBps: null };
113
+ if (!prev || !cur) return none;
114
+ const dt = (cur.t - prev.t) / 1000;
115
+ if (!(dt >= MIN_WINDOW_S) || dt > maxWindowS) return none;
116
+ const rate = (a, b) => (finite(a) && finite(b) && b >= a ? (b - a) / dt : null);
117
+ const cpu = rate(prev.cpuNs, cur.cpuNs);
118
+ return {
119
+ cpuCores: cpu === null ? null : cpu / 1e9,
120
+ rxBps: rate(prev.rx, cur.rx),
121
+ txBps: rate(prev.tx, cur.tx),
122
+ rdBps: rate(prev.rd, cur.rd),
123
+ wrBps: rate(prev.wr, cur.wr),
124
+ };
125
+ }
126
+
127
+ const round = (n, digits) => (n === null ? null : Number(n.toFixed(digits)));
128
+
129
+ /**
130
+ * The container sampler: holds the previous counters of every container between calls (in
131
+ * memory only — a daemon restart primes again) and the names it had to inspect.
132
+ *
133
+ * `request(method, path, timeoutMs)` is the Docker call (lib/docker-socket-path.js in the
134
+ * daemon, a fake in a test); `now()` the clock; `maxWindowS` the longest gap between two
135
+ * readings that still gives a rate (the daemon passes three sampling intervals).
136
+ */
137
+ function createContainerSampler({ request, now = Date.now, maxWindowS = 180 }) {
138
+ const previous = new Map(); // container id → counters
139
+ const described = new Map(); // container id → display name
140
+ let host = null;
141
+ let hostAt = 0;
142
+
143
+ async function readHost() {
144
+ if (host && now() - hostAt < HOST_TTL_MS) return host;
145
+ try {
146
+ const info = await request('GET', '/info', REQUEST_TIMEOUT_MS);
147
+ if (finite(info?.NCPU) && finite(info?.MemTotal) && info.NCPU > 0) {
148
+ host = { ncpu: info.NCPU, memTotalMb: Math.round(info.MemTotal / MB) };
149
+ hostAt = now();
150
+ }
151
+ } catch { /* keep the last answer; none yet → no host row */ }
152
+ return host;
153
+ }
154
+
155
+ /** The name to show: an agent's AGENT_NAME (read once per container id), else the container's. */
156
+ async function displayName(c, container) {
157
+ if (!c.Labels?.AGENT_ID) return container;
158
+ if (described.has(c.Id)) return described.get(c.Id);
159
+ let name = container;
160
+ try {
161
+ const inspect = await request('GET', `/containers/${encodeURIComponent(c.Id)}/json`, REQUEST_TIMEOUT_MS);
162
+ const entry = (inspect?.Config?.Env || []).find((e) => typeof e === 'string' && e.startsWith('AGENT_NAME='));
163
+ name = cleanName(entry?.slice('AGENT_NAME='.length), container);
164
+ } catch { /* fall back to the container name; asked again next time */ return name; }
165
+ described.set(c.Id, name);
166
+ return name;
167
+ }
168
+
169
+ /**
170
+ * One pass over the running containers. Throws when the list itself cannot be read
171
+ * (Docker down); a container whose stats fail is skipped, the others are kept.
172
+ */
173
+ async function sample() {
174
+ const list = await request('GET', '/containers/json', REQUEST_TIMEOUT_MS);
175
+ if (!Array.isArray(list)) throw new Error('unexpected answer from Docker');
176
+ const atMs = now();
177
+ const containers = [];
178
+ for (const c of list) {
179
+ const container = primaryName(c?.Names);
180
+ if (!c?.Id || !CONTAINER_NAME_RE.test(container)) continue;
181
+ containers.push({ c, container });
182
+ }
183
+
184
+ // The name (an inspect, agents only) runs beside the stats call, not after it: a slow
185
+ // inspect must not stretch the pass by its timeout once per agent.
186
+ const answers = await Promise.allSettled(containers.map(async ({ c, container }) => {
187
+ const [stats, name] = await Promise.all([
188
+ request('GET', `/containers/${encodeURIComponent(c.Id)}/stats?stream=false&one-shot=true`, REQUEST_TIMEOUT_MS),
189
+ displayName(c, container),
190
+ ]);
191
+ return { c, container, name, counters: readCounters(stats, now()) };
192
+ }));
193
+
194
+ const rows = [];
195
+ const sources = [];
196
+ // Identity comes from the list itself — a container whose stats call failed was still
197
+ // seen, so it keeps its place on the page (as stopped) instead of vanishing. Rates come
198
+ // only from counters. Alive is what Docker lists, not what answered its stats call: one
199
+ // timeout must not make a container forget its counters (the next pass would have no
200
+ // rate) and its name.
201
+ const live = new Set(containers.map(({ c }) => c.Id));
202
+ for (let i = 0; i < containers.length; i++) {
203
+ const { c, container } = containers[i];
204
+ const a = answers[i];
205
+ const name = a.status === 'fulfilled' ? a.value.name : container;
206
+ const volumes = (c.Mounts || []).filter((m) => m?.Type === 'volume' && m.Name).map((m) => m.Name);
207
+ sources.push({
208
+ container,
209
+ agent_id: c.Labels?.AGENT_ID ? String(c.Labels.AGENT_ID) : null,
210
+ name,
211
+ is_agent: c.Labels?.AGENT_ID ? 1 : 0,
212
+ volumes: volumes.join(','),
213
+ });
214
+ if (a.status !== 'fulfilled') continue;
215
+ const { counters } = a.value;
216
+ if (!counters) continue; // just started: no CPU figure yet
217
+ const rates = ratesBetween(previous.get(c.Id), counters, maxWindowS);
218
+ previous.set(c.Id, counters);
219
+ rows.push({
220
+ container,
221
+ cpu_cores: round(rates.cpuCores, 4),
222
+ mem_mb: counters.memBytes === null ? null : Math.round(counters.memBytes / MB),
223
+ pids: counters.pids,
224
+ net_rx_bps: round(rates.rxBps, 0),
225
+ net_tx_bps: round(rates.txBps, 0),
226
+ blk_read_bps: round(rates.rdBps, 0),
227
+ blk_write_bps: round(rates.wrBps, 0),
228
+ });
229
+ }
230
+ // A container that is gone takes its counters with it (a new id starts from none).
231
+ for (const id of previous.keys()) if (!live.has(id)) previous.delete(id);
232
+ for (const id of described.keys()) if (!live.has(id)) described.delete(id);
233
+
234
+ return { ts: Math.floor(atMs / 1000), host: await readHost(), rows, sources };
235
+ }
236
+
237
+ return { sample };
238
+ }
239
+
240
+ /**
241
+ * `/system/df` as the readings worth keeping: each named volume (and what no container uses),
242
+ * each container's writable layer, the images and the build cache in total with what
243
+ * could be freed. Sizes in bytes; -1 (Docker could not size it) becomes null.
244
+ * Returns null for anything that is not a df answer (an error body parses as JSON too) —
245
+ * the caller keeps its last reading instead of writing zeros.
246
+ */
247
+ function parseStorage(df) {
248
+ if (!df || typeof df !== 'object' || !('Volumes' in df || 'Containers' in df || 'Images' in df || 'BuildCache' in df)) return null;
249
+ const size = (n) => (finite(n) && n >= 0 ? n : null);
250
+ const volumes = (df?.Volumes || [])
251
+ .filter((v) => v?.Name)
252
+ .map((v) => ({
253
+ name: String(v.Name),
254
+ sizeBytes: size(v.UsageData?.Size),
255
+ unused: (v.UsageData?.RefCount ?? 0) === 0,
256
+ }));
257
+ const layers = (df?.Containers || [])
258
+ .map((c) => ({ container: primaryName(c?.Names), sizeBytes: size(c?.SizeRw) }))
259
+ .filter((l) => CONTAINER_NAME_RE.test(l.container));
260
+ const images = df?.Images || [];
261
+ const cache = df?.BuildCache || [];
262
+ const sum = (items, pick) => items.reduce((acc, x) => acc + (size(pick(x)) ?? 0), 0);
263
+ return {
264
+ volumes,
265
+ layers,
266
+ images: {
267
+ // LayersSize counts shared layers once, unlike summing each image.
268
+ sizeBytes: size(df?.LayersSize) ?? sum(images, (i) => i.Size),
269
+ reclaimableBytes: sum(images.filter((i) => (i.Containers ?? 0) === 0), (i) => i.Size),
270
+ },
271
+ buildCache: {
272
+ sizeBytes: sum(cache, (e) => e.Size),
273
+ // Docker's own "reclaimable" figure for the cache reads a few KB next to a cache of
274
+ // 1.7 GB (observed): what `docker builder prune` can free is what no build uses.
275
+ reclaimableBytes: sum(cache.filter((e) => e.InUse === false), (e) => e.Size),
276
+ },
277
+ };
278
+ }
279
+
280
+ module.exports = {
281
+ CONTAINER_NAME_RE, DF_TIMEOUT_MS, MB,
282
+ cleanName, primaryName, workingSetBytes, sumNetwork, sumBlockIo, readCounters, ratesBetween,
283
+ createContainerSampler, parseStorage,
284
+ };
package/lib/metrics-db.ts CHANGED
@@ -4,25 +4,34 @@ import path from 'path';
4
4
  import Database from 'better-sqlite3';
5
5
  import { DB_PATH } from './db';
6
6
 
7
+ /** History ranges of the System page and their span in seconds (GET /api/metrics and /api/metrics/containers). */
8
+ export const METRIC_RANGES = { '1h': 3600, '24h': 86_400, '7d': 7 * 86_400, '30d': 30 * 86_400 } as const;
9
+ export type MetricRange = keyof typeof METRIC_RANGES;
10
+
7
11
  /** Next to events.db, as daemon.js places it; its own file so the event log stays clean. */
8
12
  export const METRICS_DB_PATH = path.join(path.dirname(DB_PATH), 'metrics.db');
9
13
 
10
14
  /**
11
15
  * Open the metrics database read-only, or null when there is nothing to read yet: no file
12
- * (first start, or a daemon that never ran) or a file without the daemon's tables (it
13
- * failed right after creating it). The daemon owns the schema.
16
+ * (first start, or a daemon that never ran), a file without the daemon's tables (it failed
17
+ * right after creating it), or a file that cannot be opened at all (corrupt, locked — the
18
+ * caller answers "no data", never a 500). The daemon owns the schema. Never throws.
14
19
  */
15
20
  export function openMetricsDb(): Database.Database | null {
16
21
  if (!fs.existsSync(METRICS_DB_PATH)) return null;
17
- const db = new Database(METRICS_DB_PATH, { readonly: true, fileMustExist: true });
22
+ let db: Database.Database;
23
+ try {
24
+ db = new Database(METRICS_DB_PATH, { readonly: true, fileMustExist: true });
25
+ } catch {
26
+ return null;
27
+ }
18
28
  try {
19
29
  const tables = db
20
30
  .prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE type = 'table' AND name IN ('system_metrics', 'system_disks')")
21
31
  .get() as { n: number };
22
32
  if (tables.n === 2) return db;
23
- } catch (e) {
24
- db.close();
25
- throw e;
33
+ } catch {
34
+ /* fall through: unreadable schema counts as nothing to read */
26
35
  }
27
36
  db.close();
28
37
  return null;
@@ -12,7 +12,7 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { loadModelsConfig, type ModelInfo } from '@/lib/model-catalogue';
14
14
  import { providerLabel } from '@/lib/provider-labels';
15
- import { getPricing } from '@/lib/model-pricing';
15
+ import { getPricing, priceScheduleFor } from '@/lib/model-pricing';
16
16
 
17
17
  /** What `scripts/refresh-model-pricing.mjs` writes per id. All optional. */
18
18
  export interface GeneratedDetails {
@@ -48,7 +48,7 @@ export interface ModelDetailsView {
48
48
  modality?: string;
49
49
  enabled: boolean;
50
50
  deprecated: boolean;
51
- price: { input: number; output: number; source: 'openrouter' | 'vendor' } | null;
51
+ price: { input: number; output: number; source: 'openrouter' | 'vendor'; scheduled: boolean } | null;
52
52
  details: GeneratedDetails | null;
53
53
  info: ModelInfo | null;
54
54
  /** When the generated part was produced. */
@@ -115,7 +115,7 @@ export function getModelDetails(id: string): ModelDetailsView | null {
115
115
  modality: entry.modality,
116
116
  enabled: entry.enabled,
117
117
  deprecated: entry.deprecated === true,
118
- price: pricing ? { ...pricing, source: id.startsWith('openrouter/') ? 'openrouter' : 'vendor' } : null,
118
+ price: pricing ? { ...pricing, source: id.startsWith('openrouter/') ? 'openrouter' : 'vendor', scheduled: !!priceScheduleFor(id) } : null,
119
119
  details,
120
120
  info,
121
121
  asOf: file.generatedAt ?? null,
@@ -108,7 +108,9 @@ export function formatPricing(p: ModelPricing | null | undefined): string {
108
108
 
109
109
  /** The time-of-day schedule a model is billed on, or null for a flat price. */
110
110
  export function priceScheduleFor(modelId: string): PriceSchedule | null {
111
- return PRICE_SCHEDULES[modelId.split('/')[0]] ?? null;
111
+ // Own keys only: an id segment like "constructor" must not find Object.prototype.
112
+ const vendor = modelId.split('/')[0];
113
+ return Object.hasOwn(PRICE_SCHEDULES, vendor) ? PRICE_SCHEDULES[vendor] : null;
112
114
  }
113
115
 
114
116
  function isPeak(schedule: PriceSchedule, at: Date): boolean {
@@ -145,9 +147,9 @@ const CACHE_WRITE_FALLBACK = 1.25;
145
147
  *
146
148
  * Null when there is nothing to write: no entry, or a dynamic router price (-1), whose
147
149
  * 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.
150
+ * rate the file does not carry falls back to the input rate for a cache read (as if the
151
+ * cache gave no discount), or CACHE_WRITE_FALLBACK × input for a cache write. These
152
+ * estimates avoid treating an unknown rate as free; they are not vendor quotes.
151
153
  */
152
154
  export function priceAt(modelId: string, at: Date = new Date()): ModelCost | null {
153
155
  const p = getPricing(modelId);
@@ -4,6 +4,32 @@ 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
+
7
33
  /**
8
34
  * Dollars with the precision a small figure needs: an agent's day can be a fraction of a
9
35
  * cent, which formatUsd would show as $0.00. Used wherever agent costs are shown.
@@ -11,9 +37,9 @@ export function formatUsd(value: number | null | undefined): string {
11
37
  export function formatCost(value: number | null | undefined): string {
12
38
  const v = Number(value ?? 0);
13
39
  if (!v) return '$0.00';
14
- if (v < 0.01) return `${v.toFixed(4)}`;
15
- if (v < 1) return `${v.toFixed(3)}`;
16
- return `${v.toFixed(2)}`;
40
+ if (v < 0.01) return `$${v.toFixed(4)}`;
41
+ if (v < 1) return `$${v.toFixed(3)}`;
42
+ return `$${v.toFixed(2)}`;
17
43
  }
18
44
 
19
45
  export function formatUsdOrDash(value: number | null | undefined): string {