usagemax 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 UsageMax
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # UsageMax CLI
2
+
3
+ Connect aggregate coding-agent usage from your computer to a private UsageMax workspace.
4
+
5
+ ## Quick start
6
+
7
+ 1. Sign in at [usagemax.com/account](https://usagemax.com/account).
8
+ 2. Choose **Link a computer** and copy the one-time command.
9
+ 3. Run it locally:
10
+
11
+ ```bash
12
+ bunx usagemax link UMX-XXXX-XXXX-XXXX-XXXX
13
+ ```
14
+
15
+ The link code expires after ten minutes and can be used once. The CLI stores the resulting collector key in a user-only config file, then runs a one-shot sync.
16
+
17
+ ## Commands
18
+
19
+ ```bash
20
+ bunx usagemax # sync changed usage
21
+ bunx usagemax sync # same as above
22
+ bunx usagemax sync --full # inspect all available local history
23
+ bunx usagemax link UMX-… --no-sync # link without uploading yet
24
+ bunx usagemax status # show link and last-sync state
25
+ bunx usagemax doctor # metadata-only source check; does not parse logs
26
+ bunx usagemax doctor --deep # opt into a full local parser check
27
+ bunx usagemax report # open ccusage's local daily report
28
+ bunx usagemax report session --breakdown
29
+ bunx usagemax unlink # remove the local collector key
30
+ ```
31
+
32
+ UsageMax uses [ccusage](https://github.com/ccusage/ccusage) for local source detection, responsive reports, cached pricing, model breakdowns, date handling, and support for popular coding-agent CLIs. `report` passes its remaining arguments to ccusage.
33
+
34
+ ## Privacy and load
35
+
36
+ - The sync payload contains aggregate token counts, model/provider names, costs, source names, and dates.
37
+ - It does not upload prompts, completions, source code, file contents, project paths, or provider credentials.
38
+ - Sync is one-shot. There is no resident scanner or high-frequency polling loop.
39
+ - After the first import, normal syncs inspect only the current and previous local day; use `sync --full` to reconcile older history.
40
+ - The collector key is written with user-only permissions where the operating system supports them.
41
+
42
+ Use `USAGEMAX_CONFIG_DIR` to select another config directory. Development/self-hosted installations may set `USAGEMAX_LINK_ENDPOINT` before linking.
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "usagemax",
3
+ "version": "0.1.0",
4
+ "description": "Link local coding-agent usage to your UsageMax profile",
5
+ "license": "MIT",
6
+ "author": "UsageMax",
7
+ "homepage": "https://usagemax.com",
8
+ "bugs": {
9
+ "url": "https://github.com/SYMBaiEX/usagemax/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/SYMBaiEX/usagemax.git",
14
+ "directory": "packages/cli"
15
+ },
16
+ "type": "module",
17
+ "bin": {
18
+ "usagemax": "./src/cli.js"
19
+ },
20
+ "files": [
21
+ "src/cli.js",
22
+ "src/core.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "test": "node --test src/*.test.js",
34
+ "pack:check": "npm pack --dry-run",
35
+ "prepublishOnly": "npm test"
36
+ },
37
+ "dependencies": {
38
+ "ccusage": "20.0.20"
39
+ }
40
+ }
package/src/cli.js ADDED
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile, spawn } from "node:child_process";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { chmod, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
6
+ import { createRequire } from "node:module";
7
+ import { homedir, platform } from "node:os";
8
+ import { delimiter, dirname, extname, join } from "node:path";
9
+ import process from "node:process";
10
+ import { promisify } from "node:util";
11
+
12
+ import { batchId, buildDeltaPlan, normalizeLinkCode, sourceSummary, validHttpsUrl } from "./core.js";
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const executeFile = promisify(execFile);
16
+ const VERSION = "0.1.0";
17
+ const DEFAULT_LINK_ENDPOINT = "https://terrific-bobcat-522.convex.site/v1/devices/link";
18
+ const CONFIG_FILE = "config.json";
19
+ const MAX_REPORT_BYTES = 100 * 1024 * 1024;
20
+ const MAX_FINGERPRINT_FILES = 50_000;
21
+ const USAGE_EXTENSIONS = new Set([".db", ".json", ".jsonl", ".sqlite", ".sqlite3"]);
22
+
23
+ function configDirectory() {
24
+ if (process.env.USAGEMAX_CONFIG_DIR) return process.env.USAGEMAX_CONFIG_DIR;
25
+ if (platform() === "win32") return join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "UsageMax");
26
+ return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "usagemax");
27
+ }
28
+
29
+ function configPath() {
30
+ return join(configDirectory(), CONFIG_FILE);
31
+ }
32
+
33
+ async function readConfig() {
34
+ try {
35
+ const parsed = JSON.parse(await readFile(configPath(), "utf8"));
36
+ if (!parsed || parsed.version !== 1 || !/^umx_[a-f0-9]{64}$/.test(parsed.token || "")) return null;
37
+ if (!validHttpsUrl(parsed.ingestUrl, { allowLocalhost: true })) return null;
38
+ if (typeof parsed.deviceId !== "string" || !parsed.deviceId) return null;
39
+ parsed.snapshots = parsed.snapshots && typeof parsed.snapshots === "object" ? parsed.snapshots : {};
40
+ return parsed;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ async function writeConfig(config) {
47
+ const path = configPath();
48
+ const directory = dirname(path);
49
+ await mkdir(directory, { recursive: true, mode: 0o700 });
50
+ const temporary = join(directory, `.config.${process.pid}.${randomUUID()}.tmp`);
51
+ try {
52
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
53
+ await rename(temporary, path);
54
+ if (platform() !== "win32") await chmod(path, 0o600);
55
+ } catch (error) {
56
+ await unlink(temporary).catch(() => undefined);
57
+ throw error;
58
+ }
59
+ }
60
+
61
+ function option(args, name) {
62
+ const index = args.indexOf(name);
63
+ return index >= 0 ? args[index + 1] : undefined;
64
+ }
65
+
66
+ function deviceLabel() {
67
+ if (platform() === "darwin") return "Mac";
68
+ if (platform() === "win32") return "Windows PC";
69
+ if (platform() === "linux") return "Linux computer";
70
+ return "Computer";
71
+ }
72
+
73
+ function help() {
74
+ process.stdout.write(`UsageMax ${VERSION}\n\n`);
75
+ process.stdout.write("Link aggregate coding-agent usage to your UsageMax profile.\n\n");
76
+ process.stdout.write("Commands:\n");
77
+ process.stdout.write(" usagemax Sync changed local usage\n");
78
+ process.stdout.write(" usagemax link <one-use-code> Link and sync this computer\n");
79
+ process.stdout.write(" [--no-sync] [--name <name>]\n");
80
+ process.stdout.write(" usagemax sync [--full] [--json] Sync usage once, then exit\n");
81
+ process.stdout.write(" usagemax status Show local link status\n");
82
+ process.stdout.write(" usagemax doctor [--deep] Check sources without parsing logs\n");
83
+ process.stdout.write(" usagemax report [...args] Run a local ccusage report\n");
84
+ process.stdout.write(" usagemax unlink Remove the local collector key\n");
85
+ }
86
+
87
+ function ccusageCliPath() {
88
+ return join(dirname(require.resolve("ccusage/package.json")), "src", "cli.js");
89
+ }
90
+
91
+ function configuredPaths(variable, fallbacks) {
92
+ const configured = process.env[variable];
93
+ return configured
94
+ ? configured.split(",").flatMap((group) => group.split(delimiter)).map((value) => value.trim()).filter(Boolean)
95
+ : fallbacks;
96
+ }
97
+
98
+ function usageRoots() {
99
+ const home = homedir();
100
+ return [
101
+ ["claude", configuredPaths("CLAUDE_CONFIG_DIR", [join(home, ".config", "claude", "projects"), join(home, ".claude", "projects")])],
102
+ ["codex", configuredPaths("CODEX_HOME", [join(home, ".codex", "sessions"), join(home, ".codex", "archived_sessions")])],
103
+ ["opencode", configuredPaths("OPENCODE_DATA_DIR", [join(home, ".local", "share", "opencode")])],
104
+ ["hermes", configuredPaths("HERMES_HOME", [join(home, ".hermes", "sessions")])],
105
+ ["pi", configuredPaths("PI_AGENT_DIR", [join(home, ".pi", "agent", "sessions")])],
106
+ ["copilot", configuredPaths("COPILOT_HOME", [join(home, ".copilot")])],
107
+ ["gemini", configuredPaths("GEMINI_DATA_DIR", [join(home, ".gemini", "tmp")])],
108
+ ].flatMap(([source, paths]) => paths.map((path) => ({ source, path })));
109
+ }
110
+
111
+ async function sourceInventory() {
112
+ const hash = createHash("sha256");
113
+ const sources = new Set();
114
+ const roots = usageRoots();
115
+ let files = 0;
116
+ let truncated = false;
117
+ for (const root of roots) {
118
+ try {
119
+ const rootStat = await stat(root.path);
120
+ if (!rootStat.isDirectory() && !rootStat.isFile()) continue;
121
+ sources.add(root.source);
122
+ } catch {
123
+ continue;
124
+ }
125
+ const stack = [root.path];
126
+ while (stack.length && files < MAX_FINGERPRINT_FILES) {
127
+ const current = stack.pop();
128
+ let entries;
129
+ try {
130
+ entries = await readdir(current, { withFileTypes: true });
131
+ } catch {
132
+ try {
133
+ const metadata = await stat(current);
134
+ if (metadata.isFile() && USAGE_EXTENSIONS.has(extname(current).toLowerCase())) {
135
+ hash.update(`${root.source}\u0000${current}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
136
+ files += 1;
137
+ }
138
+ } catch {}
139
+ continue;
140
+ }
141
+ for (const entry of entries) {
142
+ const path = join(current, entry.name);
143
+ if (entry.isDirectory()) {
144
+ if (!["node_modules", ".git", "cache", "tmp"].includes(entry.name)) stack.push(path);
145
+ continue;
146
+ }
147
+ if (!entry.isFile() || !USAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
148
+ try {
149
+ const metadata = await stat(path);
150
+ hash.update(`${root.source}\u0000${path}\u0000${metadata.size}\u0000${metadata.mtimeMs}\n`);
151
+ files += 1;
152
+ } catch {}
153
+ if (files >= MAX_FINGERPRINT_FILES) {
154
+ truncated = true;
155
+ break;
156
+ }
157
+ }
158
+ }
159
+ }
160
+ return { fingerprint: hash.digest("hex"), sources: [...sources].sort(), files, truncated };
161
+ }
162
+
163
+ async function ccusageJson(config, { full = false } = {}) {
164
+ const args = [ccusageCliPath(), "daily", "--json", "--offline", "--by-agent", "--order", "asc"];
165
+ // Reconcile yesterday once after the UTC date changes. All other incremental
166
+ // scans parse only today; a metadata fingerprint avoids invoking ccusage when
167
+ // no supported local source changed at all.
168
+ if (!full && config?.lastSyncAt) {
169
+ const today = new Date().toISOString().slice(0, 10);
170
+ args.push("--last", config.lastReconciledDay === today ? "1" : "2");
171
+ }
172
+ const { stdout } = await executeFile(process.execPath, args, {
173
+ encoding: "utf8",
174
+ maxBuffer: MAX_REPORT_BYTES,
175
+ env: { ...process.env, NO_COLOR: "1" },
176
+ });
177
+ return JSON.parse(stdout);
178
+ }
179
+
180
+ async function link(args) {
181
+ const code = normalizeLinkCode(args[0]);
182
+ if (!code) throw new Error("Paste the one-use UMX link code shown at usagemax.com/account.");
183
+ const configuredEndpoint = process.env.USAGEMAX_LINK_ENDPOINT || DEFAULT_LINK_ENDPOINT;
184
+ const endpoint = validHttpsUrl(configuredEndpoint, { allowLocalhost: true });
185
+ if (!endpoint) throw new Error("USAGEMAX_LINK_ENDPOINT must use HTTPS, except for localhost development.");
186
+ const name = (option(args, "--name") || deviceLabel()).trim().slice(0, 80);
187
+ const response = await fetch(endpoint, {
188
+ method: "POST",
189
+ headers: { "content-type": "application/json" },
190
+ body: JSON.stringify({ code, name, platform: platform(), cliVersion: VERSION }),
191
+ signal: AbortSignal.timeout(15_000),
192
+ });
193
+ const body = await response.json().catch(() => ({}));
194
+ if (!response.ok) throw new Error(body?.error === "invalid_or_expired_link_code" ? "That link code is invalid, expired, or already used." : "UsageMax could not link this computer.");
195
+ const ingestUrl = validHttpsUrl(body.ingestUrl, { allowLocalhost: true });
196
+ if (!/^umx_[a-f0-9]{64}$/.test(body.token || "") || !ingestUrl) throw new Error("UsageMax returned an invalid link response.");
197
+ const config = {
198
+ version: 1,
199
+ token: body.token,
200
+ ingestUrl,
201
+ profileUrl: validHttpsUrl(body.profileUrl) || "https://usagemax.com/account",
202
+ profileHandle: typeof body.profileHandle === "string" ? body.profileHandle : undefined,
203
+ deviceId: randomUUID(),
204
+ deviceName: name,
205
+ linkedAt: new Date().toISOString(),
206
+ snapshots: {},
207
+ };
208
+ await writeConfig(config);
209
+ process.stdout.write(`Linked ${name} to ${config.profileHandle ? `@${config.profileHandle}` : "UsageMax"}.\n`);
210
+ if (args.includes("--no-sync")) {
211
+ process.stdout.write("No usage was uploaded. Run `bunx usagemax sync --full` when you are ready.\n");
212
+ return;
213
+ }
214
+ process.stdout.write("Running the first one-shot sync…\n");
215
+ await sync(["--full"], config);
216
+ }
217
+
218
+ async function sync(args, suppliedConfig) {
219
+ const config = suppliedConfig || await readConfig();
220
+ if (!config) throw new Error("This computer is not linked. Open https://usagemax.com/account and create a link code.");
221
+ const full = args.includes("--full");
222
+ const inventory = await sourceInventory();
223
+ const today = new Date().toISOString().slice(0, 10);
224
+ if (!full && config.lastReconciledDay === today && config.sourceFingerprint === inventory.fingerprint) {
225
+ const result = { accepted: 0, changedRows: 0, sources: inventory.sources, regressions: 0, scanned: false };
226
+ if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
227
+ else process.stdout.write("Already up to date. Local usage files have not changed; no logs were parsed or uploaded.\n");
228
+ return;
229
+ }
230
+ const report = await ccusageJson(config, { full });
231
+ const { plan, regressions } = buildDeltaPlan(report, config.snapshots, config.deviceId, "ccusage@20.0.20");
232
+ let accepted = 0;
233
+ for (let offset = 0; offset < plan.length; offset += 100) {
234
+ const batch = plan.slice(offset, offset + 100);
235
+ const events = batch.map((item) => item.event);
236
+ const response = await fetch(config.ingestUrl, {
237
+ method: "POST",
238
+ headers: {
239
+ authorization: `Bearer ${config.token}`,
240
+ "content-type": "application/json",
241
+ "idempotency-key": batchId(config.deviceId, events),
242
+ },
243
+ body: JSON.stringify({ events }),
244
+ signal: AbortSignal.timeout(30_000),
245
+ });
246
+ const body = await response.json().catch(() => ({}));
247
+ if (!response.ok) throw new Error(body?.error === "unauthorized" ? "This collector key is no longer valid. Link the computer again." : `UsageMax rejected a sync batch (${response.status}).`);
248
+ accepted += Number(body.accepted || 0);
249
+ for (const item of batch) config.snapshots[item.snapshotKey] = item.snapshot;
250
+ config.lastSyncAt = new Date().toISOString();
251
+ config.lastReconciledDay = today;
252
+ config.sourceFingerprint = inventory.fingerprint;
253
+ await writeConfig(config);
254
+ }
255
+ if (!plan.length) {
256
+ config.lastSyncAt = new Date().toISOString();
257
+ config.lastReconciledDay = today;
258
+ config.sourceFingerprint = inventory.fingerprint;
259
+ await writeConfig(config);
260
+ }
261
+ const result = { accepted, changedRows: plan.length, sources: sourceSummary(report), regressions: regressions.length, scanned: true };
262
+ if (args.includes("--json")) process.stdout.write(`${JSON.stringify(result)}\n`);
263
+ else {
264
+ process.stdout.write(plan.length ? `Synced ${accepted} changed usage rows from ${result.sources.join(", ") || "local agents"}.\n` : "Already up to date. No usage rows were uploaded.\n");
265
+ if (regressions.length) process.stdout.write(`${regressions.length} local row(s) moved backward; UsageMax kept the prior high-water mark to prevent double counting.\n`);
266
+ }
267
+ }
268
+
269
+ async function status() {
270
+ const config = await readConfig();
271
+ if (!config) {
272
+ process.stdout.write("Not linked. Open https://usagemax.com/account to connect this computer.\n");
273
+ return;
274
+ }
275
+ process.stdout.write(`Linked: ${config.deviceName || deviceLabel()}${config.profileHandle ? ` → @${config.profileHandle}` : ""}\n`);
276
+ process.stdout.write(`Last sync: ${config.lastSyncAt || "never"}\n`);
277
+ process.stdout.write(`Profile: ${config.profileUrl || "https://usagemax.com/account"}\n`);
278
+ }
279
+
280
+ async function doctor(args = []) {
281
+ const config = await readConfig();
282
+ const inventory = await sourceInventory();
283
+ process.stdout.write(`Collector: ${config ? "linked" : "not linked"}\n`);
284
+ process.stdout.write(`Detected sources: ${inventory.sources.join(", ") || "none"} (${inventory.files}${inventory.truncated ? "+" : ""} data files)\n`);
285
+ if (args.includes("--deep")) {
286
+ const report = await ccusageJson(config, { full: false });
287
+ process.stdout.write(`Parsed sources: ${sourceSummary(report).join(", ") || "none"}\n`);
288
+ }
289
+ process.stdout.write(`Mode: one-shot, metadata no-op check, ${args.includes("--deep") ? "deep local parse" : "no log parsing"}\n`);
290
+ }
291
+
292
+ async function report(args) {
293
+ const forwarded = args.length ? args : ["daily"];
294
+ const child = spawn(process.execPath, [ccusageCliPath(), ...forwarded], { stdio: "inherit", env: process.env });
295
+ const code = await new Promise((resolve, reject) => {
296
+ child.once("error", reject);
297
+ child.once("exit", (status) => resolve(status ?? 1));
298
+ });
299
+ if (code !== 0) process.exitCode = code;
300
+ }
301
+
302
+ async function removeLink() {
303
+ const path = configPath();
304
+ const config = await readConfig();
305
+ if (!config) {
306
+ process.stdout.write("This computer is not linked.\n");
307
+ return;
308
+ }
309
+ await unlink(path);
310
+ process.stdout.write("Removed the local UsageMax collector key. Revoke the collector in your account if this computer is no longer trusted.\n");
311
+ }
312
+
313
+ async function main() {
314
+ const args = process.argv.slice(2);
315
+ const command = args[0] || "sync";
316
+ if (["--help", "-h", "help"].includes(command)) return help();
317
+ if (["--version", "-v"].includes(command)) return process.stdout.write(`${VERSION}\n`);
318
+ if (command === "link") return link(args.slice(1));
319
+ if (command === "sync") return sync(args.slice(1));
320
+ if (command === "status") return status();
321
+ if (command === "doctor") return doctor(args.slice(1));
322
+ if (command === "report") return report(args.slice(1));
323
+ if (command === "unlink") return removeLink();
324
+ throw new Error(`Unknown command: ${command}. Run usagemax --help.`);
325
+ }
326
+
327
+ main().catch((error) => {
328
+ process.stderr.write(`UsageMax: ${error instanceof Error ? error.message : String(error)}\n`);
329
+ process.exitCode = 1;
330
+ });
package/src/core.js ADDED
@@ -0,0 +1,174 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const MAX_SAFE_COUNTER = 1_000_000_000_000;
4
+ const MAX_SAFE_COST_MICROS = 1_000_000_000_000_000;
5
+
6
+ function number(value, maximum = MAX_SAFE_COUNTER) {
7
+ return typeof value === "number" && Number.isFinite(value)
8
+ ? Math.min(maximum, Math.max(0, Math.round(value)))
9
+ : 0;
10
+ }
11
+
12
+ function moneyMicros(value) {
13
+ return typeof value === "number" && Number.isFinite(value)
14
+ ? Math.min(MAX_SAFE_COST_MICROS, Math.max(0, Math.round(value * 1_000_000)))
15
+ : 0;
16
+ }
17
+
18
+ function text(value, fallback, maximum = 120) {
19
+ if (typeof value !== "string") return fallback;
20
+ const clean = value.trim().replace(/[\u0000-\u001f\u007f]/g, " ").slice(0, maximum);
21
+ return clean || fallback;
22
+ }
23
+
24
+ function providerFor(model, source) {
25
+ const name = model.toLowerCase();
26
+ if (name.includes("claude")) return "anthropic";
27
+ if (/^(?:gpt|o[1345]|codex)/.test(name) || name.includes("openai")) return "openai";
28
+ if (name.includes("gemini")) return "google";
29
+ if (name.includes("grok")) return "xai";
30
+ if (name.includes("deepseek")) return "deepseek";
31
+ return source;
32
+ }
33
+
34
+ function sha256(value) {
35
+ return createHash("sha256").update(value).digest("hex");
36
+ }
37
+
38
+ export function normalizeLinkCode(value) {
39
+ const compact = text(value, "", 64).toUpperCase().replace(/\s+/g, "-");
40
+ return /^UMX-[A-HJ-NP-Z2-9]{4}(?:-[A-HJ-NP-Z2-9]{4}){3}$/.test(compact) ? compact : null;
41
+ }
42
+
43
+ export function validHttpsUrl(value, { allowLocalhost = false } = {}) {
44
+ try {
45
+ const url = new URL(value);
46
+ if (url.protocol === "https:") return url.toString();
47
+ if (allowLocalhost && url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname)) {
48
+ return url.toString();
49
+ }
50
+ } catch {}
51
+ return null;
52
+ }
53
+
54
+ function currentRows(report) {
55
+ if (!report || typeof report !== "object" || !Array.isArray(report.daily)) {
56
+ throw new Error("ccusage returned an unsupported report shape");
57
+ }
58
+ const rows = [];
59
+ for (const rawRow of report.daily) {
60
+ if (!rawRow || typeof rawRow !== "object") continue;
61
+ const period = text(rawRow.period, "", 10);
62
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(period)) continue;
63
+ const agentRows = Array.isArray(rawRow.agents) && rawRow.agents.length ? rawRow.agents : [rawRow];
64
+ for (const rawAgent of agentRows) {
65
+ if (!rawAgent || typeof rawAgent !== "object") continue;
66
+ const source = text(rawAgent.agent, "unknown", 60).toLowerCase();
67
+ const breakdowns = Array.isArray(rawAgent.modelBreakdowns) && rawAgent.modelBreakdowns.length
68
+ ? rawAgent.modelBreakdowns
69
+ : [{
70
+ modelName: Array.isArray(rawAgent.modelsUsed) && rawAgent.modelsUsed.length === 1 ? rawAgent.modelsUsed[0] : "mixed",
71
+ inputTokens: rawAgent.inputTokens,
72
+ outputTokens: rawAgent.outputTokens,
73
+ cacheCreationTokens: rawAgent.cacheCreationTokens,
74
+ cacheReadTokens: rawAgent.cacheReadTokens,
75
+ cost: rawAgent.totalCost,
76
+ }];
77
+ for (const rawBreakdown of breakdowns) {
78
+ if (!rawBreakdown || typeof rawBreakdown !== "object") continue;
79
+ const model = text(rawBreakdown.modelName, "unknown", 120);
80
+ const inputTokens = number(rawBreakdown.inputTokens);
81
+ const outputTokens = number(rawBreakdown.outputTokens);
82
+ const cacheWriteTokens = number(rawBreakdown.cacheCreationTokens);
83
+ const cacheReadTokens = number(rawBreakdown.cacheReadTokens);
84
+ const costMicros = moneyMicros(rawBreakdown.cost);
85
+ rows.push({
86
+ key: `${source}\u001f${period}\u001f${model}`,
87
+ source,
88
+ period,
89
+ model,
90
+ current: {
91
+ inputTokens,
92
+ outputTokens,
93
+ cacheReadTokens,
94
+ cacheWriteTokens,
95
+ totalTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
96
+ costMicros,
97
+ },
98
+ });
99
+ }
100
+ }
101
+ }
102
+ return rows;
103
+ }
104
+
105
+ export function buildDeltaPlan(report, priorSnapshots, deviceId, pricingVersion = "ccusage") {
106
+ const snapshots = priorSnapshots && typeof priorSnapshots === "object" ? priorSnapshots : {};
107
+ const plan = [];
108
+ const regressions = [];
109
+ for (const row of currentRows(report)) {
110
+ const prior = snapshots[row.key] && typeof snapshots[row.key] === "object" ? snapshots[row.key] : {};
111
+ const delta = {
112
+ inputTokens: Math.max(0, row.current.inputTokens - number(prior.inputTokens)),
113
+ outputTokens: Math.max(0, row.current.outputTokens - number(prior.outputTokens)),
114
+ cacheReadTokens: Math.max(0, row.current.cacheReadTokens - number(prior.cacheReadTokens)),
115
+ cacheWriteTokens: Math.max(0, row.current.cacheWriteTokens - number(prior.cacheWriteTokens)),
116
+ totalTokens: Math.max(0, row.current.totalTokens - number(prior.totalTokens)),
117
+ costMicros: Math.max(0, row.current.costMicros - number(prior.costMicros, MAX_SAFE_COST_MICROS)),
118
+ };
119
+ if (Object.keys(delta).some((key) => row.current[key] < number(prior[key], key === "costMicros" ? MAX_SAFE_COST_MICROS : MAX_SAFE_COUNTER))) {
120
+ regressions.push(row.key);
121
+ }
122
+ if (delta.totalTokens === 0 && delta.costMicros === 0) continue;
123
+ const snapshot = Object.fromEntries(Object.keys(row.current).map((key) => [
124
+ key,
125
+ Math.max(
126
+ row.current[key],
127
+ number(prior[key], key === "costMicros" ? MAX_SAFE_COST_MICROS : MAX_SAFE_COUNTER),
128
+ ),
129
+ ]));
130
+ const version = sha256(`${row.key}\u001f${JSON.stringify(snapshot)}`).slice(0, 24);
131
+ plan.push({
132
+ snapshotKey: row.key,
133
+ snapshot,
134
+ event: {
135
+ eventKey: `ccusage-v1:${deviceId}:${version}`,
136
+ agentId: `${deviceId}:${row.source}`,
137
+ agentName: row.source,
138
+ eventType: "model_request",
139
+ source: row.source,
140
+ provider: providerFor(row.model, row.source),
141
+ model: row.model,
142
+ inputTokens: delta.inputTokens,
143
+ outputTokens: delta.outputTokens,
144
+ cacheReadTokens: delta.cacheReadTokens,
145
+ cacheWriteTokens: delta.cacheWriteTokens,
146
+ totalTokens: delta.totalTokens,
147
+ costMicros: delta.costMicros,
148
+ costBasis: "estimated",
149
+ pricingSource: "ccusage / LiteLLM",
150
+ pricingVersion,
151
+ status: "ok",
152
+ state: "synced",
153
+ occurredAt: `${row.period}T12:00:00.000Z`,
154
+ completeness: "estimated",
155
+ },
156
+ });
157
+ }
158
+ plan.sort((left, right) => left.event.occurredAt.localeCompare(right.event.occurredAt));
159
+ return { plan, regressions };
160
+ }
161
+
162
+ export function batchId(deviceId, events) {
163
+ const identity = events.map((event) => event.eventKey).join("\u001f");
164
+ return `cli:${deviceId}:${sha256(identity).slice(0, 32)}`;
165
+ }
166
+
167
+ export function sourceSummary(report) {
168
+ if (!report || typeof report !== "object" || !Array.isArray(report.daily)) return [];
169
+ const sources = report.daily.flatMap((row) => {
170
+ const agentRows = Array.isArray(row?.agents) && row.agents.length ? row.agents : [row];
171
+ return agentRows.map((agent) => text(agent?.agent, "", 60).toLowerCase()).filter(Boolean);
172
+ });
173
+ return [...new Set(sources)].sort();
174
+ }