@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flame0510/project-aether",
3
- "version": "1.9.1",
3
+ "version": "1.11.0",
4
4
  "description": "Rev4a — Revolution for Agents. OpenClaw agent fleet orchestrator.",
5
5
  "keywords": [
6
6
  "openclaw",
@@ -86,7 +86,12 @@ function round(n) {
86
86
  function serialisePricing(pricing) {
87
87
  const num = (n) => String(n);
88
88
  const body = Object.entries(pricing)
89
- .map(([id, p]) => ` ${JSON.stringify(id)}: {\n "input": ${num(p.input)},\n "output": ${num(p.output)}\n }`)
89
+ .map(([id, p]) => {
90
+ const rates = [['input', p.input], ['output', p.output], ['cacheRead', p.cacheRead], ['cacheWrite', p.cacheWrite]]
91
+ .filter(([, v]) => v !== undefined)
92
+ .map(([k, v]) => ` "${k}": ${num(v)}`);
93
+ return ` ${JSON.stringify(id)}: {\n${rates.join(',\n')}\n }`;
94
+ })
90
95
  .join(',\n');
91
96
  return `{\n${body}\n}\n`;
92
97
  }
@@ -243,11 +248,18 @@ async function main() {
243
248
  } else {
244
249
  const current = pricing[m.id];
245
250
  const next = { input: round(input), output: round(output) };
246
- if (!current || current.input !== next.input || current.output !== next.output) {
251
+ // Cache rates when OpenRouter publishes them: OpenClaw prices cached tokens with
252
+ // these, and without one they fall back to the input rate (lib/model-pricing.ts).
253
+ const cacheRead = perMillion(up.pricing?.input_cache_read);
254
+ const cacheWrite = perMillion(up.pricing?.input_cache_write);
255
+ if (cacheRead !== null) next.cacheRead = round(cacheRead);
256
+ if (cacheWrite !== null) next.cacheWrite = round(cacheWrite);
257
+ const show = (x) => `${x.input}/${x.output}` + (x.cacheRead !== undefined ? ` cache ${x.cacheRead}` : '');
258
+ if (!current || ['input', 'output', 'cacheRead', 'cacheWrite'].some((k) => current[k] !== next[k])) {
247
259
  priceChanges.push({
248
260
  id: m.id,
249
- from: current ? `$${current.input}/$${current.output}` : '(none)',
250
- to: `$${next.input}/$${next.output}`,
261
+ from: current ? show(current) : '(none)',
262
+ to: show(next),
251
263
  });
252
264
  pricing[m.id] = next;
253
265
  }
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Tests for lib/docker-stats.js — the parsing, the rate arithmetic and the container sampler
4
+ * behind the System page's per-container consumption. No Docker, no database: the sampler is
5
+ * given a fake Docker. Run it with
6
+ *
7
+ * node scripts/test-docker-stats.mjs
8
+ *
9
+ * and it exits non-zero on the first failing case. (Not wired into CI: the repo has no test
10
+ * runner yet — see plans for the Playwright work — so this is a script, like check-language.)
11
+ */
12
+ import assert from 'node:assert/strict';
13
+ import { createRequire } from 'node:module';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
18
+ const S = createRequire(import.meta.url)(path.join(ROOT, 'lib', 'docker-stats.js'));
19
+
20
+ let passed = 0;
21
+ async function test(name, fn) {
22
+ try {
23
+ await fn();
24
+ passed++;
25
+ console.log(`ok ${name}`);
26
+ } catch (e) {
27
+ console.error(`FAIL ${name}\n${e?.stack || e}`);
28
+ process.exit(1);
29
+ }
30
+ }
31
+
32
+ // ── parsing ──────────────────────────────────────────────────────────────────
33
+ await test('working set: usage minus inactive_file (cgroup v2)', () => {
34
+ assert.equal(S.workingSetBytes({ usage: 1000, stats: { inactive_file: 300 } }), 700);
35
+ });
36
+ await test('working set: total_inactive_file (cgroup v1)', () => {
37
+ assert.equal(S.workingSetBytes({ usage: 1000, stats: { total_inactive_file: 400 } }), 600);
38
+ });
39
+ await test('working set never negative, null when unknown', () => {
40
+ assert.equal(S.workingSetBytes({ usage: 100, stats: { inactive_file: 500 } }), 0);
41
+ assert.equal(S.workingSetBytes({}), null);
42
+ });
43
+ await test('network and block I/O sums, v1 and v2 op names', () => {
44
+ assert.deepEqual(S.sumNetwork({ a: { rx_bytes: 1, tx_bytes: 2 }, b: { rx_bytes: 10, tx_bytes: 20 } }), { rx: 11, tx: 22 });
45
+ assert.deepEqual(S.sumNetwork(undefined), { rx: null, tx: null });
46
+ assert.deepEqual(S.sumBlockIo({ io_service_bytes_recursive: [{ op: 'read', value: 5 }, { op: 'Write', value: 7 }, { op: 'sync', value: 9 }] }), { rd: 5, wr: 7 });
47
+ });
48
+ await test('stats without a CPU figure (stopped, just started) give no counters', () => {
49
+ assert.equal(S.readCounters({}, 1), null);
50
+ });
51
+ await test('cleanName strips control characters and falls back', () => {
52
+ assert.equal(S.cleanName('a\tb\nc', 'x'), 'a b c');
53
+ assert.equal(S.cleanName(' ', 'x'), 'x');
54
+ });
55
+ await test('primaryName: the name with one leading slash, whatever the order (legacy links)', () => {
56
+ assert.equal(S.primaryName(['/db']), 'db');
57
+ assert.equal(S.primaryName(['/app/db', '/db']), 'db');
58
+ assert.equal(S.primaryName(['/db', '/app/db']), 'db');
59
+ assert.equal(S.primaryName(['/app/db']), '');
60
+ assert.equal(S.primaryName(undefined), '');
61
+ });
62
+
63
+ // ── rates ────────────────────────────────────────────────────────────────────
64
+ const c1 = { t: 0, cpuNs: 0, rx: 100, tx: 100, rd: 0, wr: 0 };
65
+ const c2 = { t: 10_000, cpuNs: 2e9, rx: 600, tx: 100, rd: 1000, wr: 0 };
66
+ await test('rates: 2 CPU-seconds in 10 s is 0.2 cores; bytes per second', () => {
67
+ const r = S.ratesBetween(c1, c2);
68
+ assert.equal(r.cpuCores, 0.2);
69
+ assert.equal(r.rxBps, 50);
70
+ assert.equal(r.txBps, 0);
71
+ assert.equal(r.rdBps, 100);
72
+ });
73
+ await test('rates: no earlier reading, or a window under a second, is null', () => {
74
+ assert.deepEqual(S.ratesBetween(null, c2), { cpuCores: null, rxBps: null, txBps: null, rdBps: null, wrBps: null });
75
+ assert.equal(S.ratesBetween(c2, { ...c2, t: 10_500 }).cpuCores, null);
76
+ });
77
+ await test('rates: a counter going backwards is null on its own, the others are kept', () => {
78
+ const r = S.ratesBetween(c2, { t: 20_000, cpuNs: 1e9, rx: 700, tx: 5, rd: 2000, wr: 0 });
79
+ assert.equal(r.cpuCores, null);
80
+ assert.equal(r.txBps, null);
81
+ assert.equal(r.rxBps, 10);
82
+ });
83
+ await test('rates: a window over the limit is null (a laptop that slept is not a near-zero CPU)', () => {
84
+ const slept = { ...c2, t: 8 * 3600 * 1000, cpuNs: 2e9 + 1e9 };
85
+ assert.ok(S.ratesBetween(c2, slept).cpuCores > 0, 'without a limit it would invent a tiny figure');
86
+ assert.equal(S.ratesBetween(c2, slept, 180).cpuCores, null);
87
+ assert.equal(S.ratesBetween(c1, c2, 180).cpuCores, 0.2);
88
+ });
89
+
90
+ // ── the sampler, against a fake Docker ───────────────────────────────────────
91
+ function fakeDocker() {
92
+ const d = {
93
+ clock: 1_000_000_000_000,
94
+ calls: [],
95
+ list: [
96
+ { Id: 'A', Names: ['/agent_a'], Labels: { AGENT_ID: 'agent_a' }, Mounts: [{ Type: 'volume', Name: 'vol-a' }, { Type: 'bind' }] },
97
+ { Id: 'B', Names: ['/registry'], Labels: {}, Mounts: [] },
98
+ { Id: 'C', Names: ['/bad name!'], Labels: {} },
99
+ ],
100
+ stats: { A: { cpu: 0, rx: 0 }, B: { cpu: 0, rx: 0 } },
101
+ failStats: new Set(),
102
+ inspectDelayMs: 0,
103
+ };
104
+ d.request = async (_method, p) => {
105
+ d.calls.push(p);
106
+ if (p === '/containers/json') return d.list;
107
+ if (p === '/info') return { NCPU: 4, MemTotal: 4 * 1024 * 1024 * 1024 };
108
+ const inspect = /^\/containers\/(\w+)\/json$/.exec(p);
109
+ if (inspect) {
110
+ if (d.inspectDelayMs) await new Promise((r) => setTimeout(r, d.inspectDelayMs));
111
+ return { Config: { Env: ['X=1', 'AGENT_NAME=My\tAgent\nTwo'] } };
112
+ }
113
+ const id = /containers\/(\w+)\/stats/.exec(p)?.[1];
114
+ if (d.failStats.has(id)) throw new Error('boom');
115
+ const s = d.stats[id];
116
+ if (!s) return {};
117
+ return {
118
+ cpu_stats: { cpu_usage: { total_usage: s.cpu } },
119
+ memory_stats: { usage: 200 * 1048576, stats: { inactive_file: 100 * 1048576 } },
120
+ pids_stats: { current: 7 },
121
+ networks: { eth0: { rx_bytes: s.rx, tx_bytes: 0 } },
122
+ };
123
+ };
124
+ return d;
125
+ }
126
+
127
+ await test('sampler: the first sample primes (gauges kept, rates null); invalid names skipped; host read', async () => {
128
+ const d = fakeDocker();
129
+ const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
130
+ const a = await sampler.sample();
131
+ assert.equal(a.rows.length, 2);
132
+ assert.equal(a.rows[0].cpu_cores, null);
133
+ assert.equal(a.rows[0].mem_mb, 100);
134
+ assert.equal(a.rows[0].pids, 7);
135
+ assert.ok(!a.rows.some((r) => r.container === 'bad name!'));
136
+ assert.deepEqual(a.host, { ncpu: 4, memTotalMb: 4096 });
137
+ });
138
+ await test('sampler: agent name cleaned, volumes from mounts, non-agents flagged', async () => {
139
+ const d = fakeDocker();
140
+ const a = await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
141
+ const src = a.sources.find((x) => x.container === 'agent_a');
142
+ assert.equal(src.name, 'My Agent Two');
143
+ assert.equal(src.volumes, 'vol-a');
144
+ assert.equal(src.is_agent, 1);
145
+ assert.equal(a.sources.find((x) => x.container === 'registry').is_agent, 0);
146
+ });
147
+ await test('sampler: a container listed under a legacy-link alias is still sampled', async () => {
148
+ const d = fakeDocker();
149
+ d.list = [{ Id: 'B', Names: ['/app/registry', '/registry'], Labels: {}, Mounts: [] }];
150
+ const a = await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
151
+ assert.deepEqual(a.rows.map((r) => r.container), ['registry']);
152
+ });
153
+ await test('sampler: second sample gives the rates; an agent is inspected once', async () => {
154
+ const d = fakeDocker();
155
+ const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
156
+ await sampler.sample();
157
+ d.clock += 60_000;
158
+ d.stats.A.cpu = 6e9;
159
+ d.stats.A.rx = 6000;
160
+ const a = await sampler.sample();
161
+ const row = a.rows.find((r) => r.container === 'agent_a');
162
+ assert.equal(row.cpu_cores, 0.1);
163
+ assert.equal(row.net_rx_bps, 100);
164
+ assert.equal(d.calls.filter((p) => p === '/containers/A/json').length, 1);
165
+ });
166
+ await test('sampler: a counter reset (container restarted) gives no CPU figure, never a spike', async () => {
167
+ const d = fakeDocker();
168
+ const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
169
+ await sampler.sample();
170
+ d.clock += 60_000; d.stats.A.cpu = 6e9; await sampler.sample();
171
+ d.clock += 60_000; d.stats.A.cpu = 1e9;
172
+ const a = await sampler.sample();
173
+ assert.equal(a.rows.find((r) => r.container === 'agent_a').cpu_cores, null);
174
+ });
175
+ await test('sampler: a long gap between passes gives no CPU figure, the next pass does', async () => {
176
+ const d = fakeDocker();
177
+ const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock, maxWindowS: 180 });
178
+ await sampler.sample();
179
+ d.clock += 8 * 3600 * 1000; d.stats.A.cpu = 3e9;
180
+ assert.equal((await sampler.sample()).rows.find((r) => r.container === 'agent_a').cpu_cores, null);
181
+ d.clock += 60_000; d.stats.A.cpu = 9e9;
182
+ assert.equal((await sampler.sample()).rows.find((r) => r.container === 'agent_a').cpu_cores, 0.1);
183
+ });
184
+ await test('sampler: a stats call that fails skips that container only, and it keeps its counters', async () => {
185
+ const d = fakeDocker();
186
+ const sampler = S.createContainerSampler({ request: d.request, now: () => d.clock });
187
+ await sampler.sample();
188
+ d.clock += 60_000; d.stats.B.cpu = 1e9;
189
+ d.failStats.add('B');
190
+ const failed = await sampler.sample();
191
+ assert.deepEqual(failed.rows.map((r) => r.container), ['agent_a']);
192
+ // The failed container was still seen: it stays on the page (as stopped) instead of vanishing.
193
+ assert.deepEqual(failed.sources.map((r) => r.container), ['agent_a', 'registry']);
194
+ assert.equal(failed.sources.find((r) => r.container === 'registry').name, 'registry');
195
+ d.failStats.delete('B');
196
+ d.clock += 60_000; d.stats.B.cpu = 2e9;
197
+ const back = await sampler.sample();
198
+ // 2 CPU-seconds since the last good reading, 120 s ago: the failed pass did not lose the counters.
199
+ assert.equal(back.rows.find((r) => r.container === 'registry').cpu_cores, 0.0167);
200
+ });
201
+ await test('sampler: the agent name is fetched beside the stats call, not after it', async () => {
202
+ const d = fakeDocker();
203
+ d.list = Array.from({ length: 4 }, (_, i) => ({ Id: `A${i}`, Names: [`/agent_${i}`], Labels: { AGENT_ID: `agent_${i}` }, Mounts: [] }));
204
+ d.stats = Object.fromEntries(d.list.map((c) => [c.Id, { cpu: 0, rx: 0 }]));
205
+ d.inspectDelayMs = 150;
206
+ const t0 = Date.now();
207
+ await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample();
208
+ assert.ok(Date.now() - t0 < 4 * 150, `4 inspects of 150 ms took ${Date.now() - t0} ms: they ran one after another`);
209
+ });
210
+ await test('sampler: no containers is an empty pass; Docker down throws', async () => {
211
+ const d = fakeDocker();
212
+ d.list = [];
213
+ assert.equal((await S.createContainerSampler({ request: d.request, now: () => d.clock }).sample()).rows.length, 0);
214
+ await assert.rejects(S.createContainerSampler({ request: async () => { throw new Error('down'); } }).sample(), /down/);
215
+ });
216
+
217
+ // ── /system/df ───────────────────────────────────────────────────────────────
218
+ await test('parseStorage: volumes (unused flagged), layers by primary name, images and cache with what nothing uses', () => {
219
+ const df = S.parseStorage({
220
+ LayersSize: 5000,
221
+ Volumes: [
222
+ { Name: 'v-used', UsageData: { Size: 100, RefCount: 1 } },
223
+ { Name: 'v-unused', UsageData: { Size: 700, RefCount: 0 } },
224
+ { Name: 'v-unsized', UsageData: { Size: -1, RefCount: 1 } },
225
+ ],
226
+ Containers: [{ Names: ['/app/agent_a', '/agent_a'], SizeRw: 10 }, { Names: ['/bad name'], SizeRw: 1 }],
227
+ Images: [{ Size: 3000, Containers: 2 }, { Size: 1500, Containers: 0 }],
228
+ BuildCache: [{ Size: 400, InUse: false }, { Size: 50, InUse: true }],
229
+ });
230
+ assert.deepEqual(df.volumes.map((v) => [v.name, v.sizeBytes, v.unused]), [['v-used', 100, false], ['v-unused', 700, true], ['v-unsized', null, false]]);
231
+ assert.deepEqual(df.layers, [{ container: 'agent_a', sizeBytes: 10 }]);
232
+ assert.deepEqual(df.images, { sizeBytes: 5000, reclaimableBytes: 1500 });
233
+ assert.deepEqual(df.buildCache, { sizeBytes: 450, reclaimableBytes: 400 });
234
+ });
235
+
236
+ await test('parseStorage: an error body is not a df answer — null, never zeros', () => {
237
+ assert.equal(S.parseStorage(null), null);
238
+ assert.equal(S.parseStorage({}), null);
239
+ assert.equal(S.parseStorage({ message: 'error processing /system/df' }), null);
240
+ });
241
+
242
+ await test('dockerRequestJson: an HTTP error is not data — error status rejects, valid 200 resolves', async () => {
243
+ const http = (await import('node:http')).default;
244
+ const fs = (await import('node:fs')).default;
245
+ const os = (await import('node:os')).default;
246
+ const socket = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'docker-sock-')), 'docker.sock');
247
+ const answers = new Map([
248
+ ['/error.json', [500, '{"message":"boom"}']],
249
+ ['/missing', [404, '{"message":"no such thing"}']],
250
+ ['/good', [200, '{"fine":true}']],
251
+ ]);
252
+ const server = http.createServer((req, res) => {
253
+ const [code, body] = answers.get(req.url) ?? [500, '{}'];
254
+ res.writeHead(code, { 'Content-Type': 'application/json' });
255
+ res.end(body);
256
+ });
257
+ await new Promise((resolve) => server.listen(socket, resolve));
258
+ // First resolution of the process: the env wins, so the helper talks to this fake.
259
+ process.env.DOCKER_HOST = `unix://${socket}`;
260
+ const { dockerRequestJson } = createRequire(import.meta.url)(path.join(ROOT, 'lib', 'docker-socket-path.js'));
261
+ try {
262
+ await assert.rejects(dockerRequestJson('GET', '/error.json'), /^Error: Docker error 500: \{"message":"boom"\}$/);
263
+ await assert.rejects(dockerRequestJson('GET', '/missing'), /^Error: Docker error 404/);
264
+ assert.deepEqual(await dockerRequestJson('GET', '/good'), { fine: true });
265
+ } finally {
266
+ server.close();
267
+ }
268
+ });
269
+
270
+ console.log(`\n${passed} cases passed`);
package/lib/billing.ts DELETED
@@ -1,100 +0,0 @@
1
- import type { ModelCost } from '@/lib/types';
2
-
3
- export type BillingMode = 'usage' | 'fixed' | 'credits' | 'unknown';
4
-
5
- /**
6
- * Economic dollar costs should only include providers we actually bill per usage.
7
- * Today that is primarily OpenRouter, plus a small set of explicit pay-per-use aliases
8
- * stored without provider prefixes in older rows.
9
- */
10
- const BILLABLE_MODEL_PREFIXES = ['openrouter/'] as const;
11
- const BILLABLE_ALIAS_PREFIXES = ['deepseek', 'deepseek4', 'or-', 'openrouter', 'qwen', 'kimi', 'glm'] as const;
12
- const NON_BILLABLE_ALIAS_PATTERNS = [
13
- /^gpt-5(\.|-|$)/,
14
- /^codex(\.|-|$)/,
15
- /^claude(\.|-|$)/,
16
- /^claude-cli(\/|$)/,
17
- /^anthropic(\/|$)/,
18
- /^openai-codex(\/|$)/,
19
- /^github-copilot(\/|$)/,
20
- ] as const;
21
-
22
- export interface BillingBucket {
23
- mode: BillingMode;
24
- cost_usd: number;
25
- sessions: number;
26
- tokens_in: number;
27
- tokens_out: number;
28
- models: ModelCost[];
29
- }
30
-
31
- export interface BillingSummary {
32
- usageBasedCost: number;
33
- dbEstimatedCost: number;
34
- buckets: Record<BillingMode, BillingBucket>;
35
- usageModels: ModelCost[];
36
- fixedModels: ModelCost[];
37
- creditModels: ModelCost[];
38
- unknownModels: ModelCost[];
39
- }
40
-
41
- export function isBillableDollarModel(model?: string | null): boolean {
42
- const m = (model ?? '').trim().toLowerCase();
43
- if (!m || m === 'unknown') return false;
44
- if (BILLABLE_MODEL_PREFIXES.some((prefix) => m.startsWith(prefix))) return true;
45
- if (NON_BILLABLE_ALIAS_PATTERNS.some((pattern) => pattern.test(m))) return false;
46
- if (m.includes('/')) return false;
47
- return BILLABLE_ALIAS_PREFIXES.some((prefix) => m.startsWith(prefix));
48
- }
49
-
50
- export function classifyBillingModel(model?: string | null): BillingMode {
51
- const m = (model ?? '').toLowerCase();
52
- if (!m || m === 'unknown') return 'unknown';
53
- if (isBillableDollarModel(m)) return 'usage';
54
- if (m.startsWith('openai-codex/')) return 'fixed';
55
- if (m.startsWith('anthropic/') || m.includes('claude-cli')) return 'fixed';
56
- if (m.startsWith('github-copilot/')) return 'credits';
57
-
58
- // Rev4a DB often stores model aliases without provider prefix.
59
- if (/^gpt-5\.(2|3|4|5)(-codex|-mini|-pro)?$/.test(m)) return 'fixed';
60
- if (/^claude-(haiku|sonnet|opus)-/.test(m)) return 'fixed';
61
- if (/^gemini-.*(flash|pro)/.test(m)) return 'credits';
62
-
63
- // Known OpenRouter/free/usage-style aliases seen in this workspace.
64
- if (/^(deepseek|deepseek4|or-|openrouter|qwen|kimi|glm)/.test(m)) return 'usage';
65
- return 'unknown';
66
- }
67
-
68
- function emptyBucket(mode: BillingMode): BillingBucket {
69
- return { mode, cost_usd: 0, sessions: 0, tokens_in: 0, tokens_out: 0, models: [] };
70
- }
71
-
72
- export function summarizeBilling(models: ModelCost[]): BillingSummary {
73
- const buckets: Record<BillingMode, BillingBucket> = {
74
- usage: emptyBucket('usage'),
75
- fixed: emptyBucket('fixed'),
76
- credits: emptyBucket('credits'),
77
- unknown: emptyBucket('unknown'),
78
- };
79
-
80
- for (const model of models) {
81
- const mode = classifyBillingModel(model.model);
82
- const bucket = buckets[mode];
83
- const cost = Number(model.cost_usd ?? 0);
84
- bucket.cost_usd += cost;
85
- bucket.sessions += Number(model.sessions ?? 0);
86
- bucket.tokens_in += Number(model.tokens_in ?? 0);
87
- bucket.tokens_out += Number(model.tokens_out ?? 0);
88
- bucket.models.push(model);
89
- }
90
-
91
- return {
92
- usageBasedCost: buckets.usage.cost_usd,
93
- dbEstimatedCost: models.reduce((sum, model) => sum + Number(model.cost_usd ?? 0), 0),
94
- buckets,
95
- usageModels: buckets.usage.models,
96
- fixedModels: buckets.fixed.models,
97
- creditModels: buckets.credits.models,
98
- unknownModels: buckets.unknown.models,
99
- };
100
- }