tokenmax-collector 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 +21 -0
- package/package.json +40 -0
- package/src/ccusage.ts +80 -0
- package/src/cli.ts +113 -0
- package/src/collect.ts +129 -0
- package/src/command.ts +38 -0
- package/src/config.ts +82 -0
- package/src/install.ts +128 -0
- package/src/machine.ts +55 -0
- package/src/mapping.ts +55 -0
- package/src/paths.ts +62 -0
- package/src/schedule.ts +68 -0
- package/src/usage.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlos Garavito Quispe
|
|
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/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokenmax-collector",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Report local ccusage token usage to a tokenmax instance",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokenmax": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"LICENSE",
|
|
12
|
+
"!src/**/*.spec.ts",
|
|
13
|
+
"!src/test/**"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/cgaravitoq/tokenmax.git",
|
|
19
|
+
"directory": "packages/collector"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"bun": ">=1.3.14"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"check-types": "tsc --noEmit",
|
|
26
|
+
"test": "vitest run --exclude src/package.e2e.spec.ts",
|
|
27
|
+
"test:package": "vitest run src/package.e2e.spec.ts",
|
|
28
|
+
"test:watch": "vitest --exclude src/package.e2e.spec.ts"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"ccusage": "20.0.20",
|
|
32
|
+
"commander": "^15.0.0",
|
|
33
|
+
"zod": "^4.4.3"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^24.13.3",
|
|
37
|
+
"typescript": "^6.0.3",
|
|
38
|
+
"vitest": "^4.1.11"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/ccusage.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { CommandRunner } from "./command";
|
|
4
|
+
|
|
5
|
+
const modelBreakdown = z.object({
|
|
6
|
+
cacheCreationTokens: z.int(),
|
|
7
|
+
cacheReadTokens: z.int(),
|
|
8
|
+
cost: z.number(),
|
|
9
|
+
inputTokens: z.int(),
|
|
10
|
+
modelName: z.string(),
|
|
11
|
+
outputTokens: z.int(),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const agentUsage = z.object({
|
|
15
|
+
agent: z.string(),
|
|
16
|
+
modelBreakdowns: z.array(modelBreakdown),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const dailyUsage = z.object({
|
|
20
|
+
agents: z.array(agentUsage),
|
|
21
|
+
period: z.string(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const ccusageDaily = z.object({
|
|
25
|
+
daily: z.array(dailyUsage),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type CcusageDaily = z.infer<typeof ccusageDaily>;
|
|
29
|
+
|
|
30
|
+
const windowDays = 14;
|
|
31
|
+
|
|
32
|
+
export function parseCcusageDaily(source: string): CcusageDaily {
|
|
33
|
+
return ccusageDaily.parse(JSON.parse(source));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ccusageCliPath(): string {
|
|
37
|
+
return createRequire(import.meta.url).resolve("ccusage/src/cli.js");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sinceArgument(today: Date, timezone: string): string {
|
|
41
|
+
const calendarDate = new Intl.DateTimeFormat("en-CA", {
|
|
42
|
+
timeZone: timezone,
|
|
43
|
+
year: "numeric",
|
|
44
|
+
month: "2-digit",
|
|
45
|
+
day: "2-digit",
|
|
46
|
+
}).format(today);
|
|
47
|
+
const [year, month, day] = calendarDate.split("-").map(Number);
|
|
48
|
+
const start = new Date(Date.UTC(year, month - 1, day - (windowDays - 1)));
|
|
49
|
+
return start.toISOString().slice(0, 10).replaceAll("-", "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ccusageArguments(since: string, timezone: string): string[] {
|
|
53
|
+
return [
|
|
54
|
+
"daily",
|
|
55
|
+
"--json",
|
|
56
|
+
"--breakdown",
|
|
57
|
+
"--by-agent",
|
|
58
|
+
"-z",
|
|
59
|
+
timezone,
|
|
60
|
+
"--since",
|
|
61
|
+
since,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readCcusageDaily(
|
|
66
|
+
runner: CommandRunner,
|
|
67
|
+
since: string,
|
|
68
|
+
timezone: string,
|
|
69
|
+
): Promise<CcusageDaily> {
|
|
70
|
+
const result = await runner(process.execPath, [
|
|
71
|
+
ccusageCliPath(),
|
|
72
|
+
...ccusageArguments(since, timezone),
|
|
73
|
+
]);
|
|
74
|
+
if (result.exitCode !== 0) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`ccusage exited with ${result.exitCode}: ${result.stderr.trim()}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return parseCcusageDaily(result.stdout);
|
|
80
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { collect, type Fetcher } from "./collect";
|
|
4
|
+
import type { CommandRunner } from "./command";
|
|
5
|
+
import { install } from "./install";
|
|
6
|
+
import { type MachineIdentity, readMachineIdentity } from "./machine";
|
|
7
|
+
import { type CollectorEnv, processEnv } from "./paths";
|
|
8
|
+
|
|
9
|
+
export interface CliIo {
|
|
10
|
+
stderr: (line: string) => void;
|
|
11
|
+
stdout: (line: string) => void;
|
|
12
|
+
cliPath?: string;
|
|
13
|
+
env?: CollectorEnv;
|
|
14
|
+
execPath?: string;
|
|
15
|
+
fetcher?: Fetcher;
|
|
16
|
+
identity?: MachineIdentity;
|
|
17
|
+
platform?: NodeJS.Platform;
|
|
18
|
+
runner?: CommandRunner;
|
|
19
|
+
today?: Date;
|
|
20
|
+
uid?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface InstallCommandOptions {
|
|
24
|
+
dryRun: boolean;
|
|
25
|
+
key: string;
|
|
26
|
+
timezone?: string;
|
|
27
|
+
url: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function runCollect(io: CliIo): Promise<number> {
|
|
31
|
+
const result = await collect({
|
|
32
|
+
env: io.env ?? processEnv(),
|
|
33
|
+
fetcher: io.fetcher,
|
|
34
|
+
identity: io.identity ?? (await readMachineIdentity()),
|
|
35
|
+
runner: io.runner,
|
|
36
|
+
today: io.today,
|
|
37
|
+
});
|
|
38
|
+
if (result.kind === "reported") {
|
|
39
|
+
io.stdout(`accepted ${result.accepted} days for ${result.machine}`);
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
if (result.kind === "empty") {
|
|
43
|
+
io.stdout("nothing to report");
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
if (result.kind === "missing-config") {
|
|
47
|
+
io.stderr(
|
|
48
|
+
`no tokenmax config at ${result.configFile}; run: tokenmax install --url <url> --key <key>`,
|
|
49
|
+
);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
io.stderr(result.message);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function runInstall(
|
|
57
|
+
options: InstallCommandOptions,
|
|
58
|
+
io: CliIo,
|
|
59
|
+
): Promise<number> {
|
|
60
|
+
try {
|
|
61
|
+
await install({
|
|
62
|
+
cliPath: io.cliPath,
|
|
63
|
+
dryRun: options.dryRun,
|
|
64
|
+
env: io.env ?? processEnv(),
|
|
65
|
+
execPath: io.execPath,
|
|
66
|
+
key: options.key,
|
|
67
|
+
log: io.stdout,
|
|
68
|
+
platform: io.platform,
|
|
69
|
+
timezone: options.timezone,
|
|
70
|
+
uid: io.uid,
|
|
71
|
+
url: options.url,
|
|
72
|
+
});
|
|
73
|
+
return 0;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
io.stderr(error instanceof Error ? error.message : String(error));
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function runCli(argv: string[], io: CliIo): Promise<number> {
|
|
81
|
+
let exitCode = 0;
|
|
82
|
+
const program = new Command()
|
|
83
|
+
.name("tokenmax-collector")
|
|
84
|
+
.description("Report local token usage to a tokenmax instance");
|
|
85
|
+
|
|
86
|
+
program
|
|
87
|
+
.command("collect")
|
|
88
|
+
.description("Report local usage from the last 14 days")
|
|
89
|
+
.action(async () => {
|
|
90
|
+
exitCode = await runCollect(io);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
program
|
|
94
|
+
.command("install")
|
|
95
|
+
.description("Write the tokenmax config and the local schedule")
|
|
96
|
+
.requiredOption("--url <url>", "tokenmax base url")
|
|
97
|
+
.requiredOption("--key <key>", "tokenmax api key")
|
|
98
|
+
.option("--timezone <zone>", "IANA timezone, defaults to the machine zone")
|
|
99
|
+
.option("--dry-run", "print the files without writing them", false)
|
|
100
|
+
.action(async (options: InstallCommandOptions) => {
|
|
101
|
+
exitCode = await runInstall(options, io);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await program.parseAsync(argv, { from: "user" });
|
|
105
|
+
return exitCode;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (import.meta.main) {
|
|
109
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
110
|
+
stderr: console.error,
|
|
111
|
+
stdout: console.log,
|
|
112
|
+
});
|
|
113
|
+
}
|
package/src/collect.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { readCcusageDaily, sinceArgument } from "./ccusage";
|
|
2
|
+
import { type CommandRunner, runCommand } from "./command";
|
|
3
|
+
import { readConfig, runtimeTimezone } from "./config";
|
|
4
|
+
import { type MachineIdentity, machineId } from "./machine";
|
|
5
|
+
import { mapCcusageDays } from "./mapping";
|
|
6
|
+
import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
|
|
7
|
+
import type { UsageDay, UsageReport } from "./usage";
|
|
8
|
+
|
|
9
|
+
export interface HttpResponse {
|
|
10
|
+
status: number;
|
|
11
|
+
text(): Promise<string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Fetcher = (url: string, init: RequestInit) => Promise<HttpResponse>;
|
|
15
|
+
|
|
16
|
+
export interface CollectOptions {
|
|
17
|
+
identity: MachineIdentity;
|
|
18
|
+
env?: CollectorEnv;
|
|
19
|
+
fetcher?: Fetcher;
|
|
20
|
+
runner?: CommandRunner;
|
|
21
|
+
today?: Date;
|
|
22
|
+
timezone?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CollectResult =
|
|
26
|
+
| { kind: "reported"; accepted: number; machine: string }
|
|
27
|
+
| { kind: "empty" }
|
|
28
|
+
| { kind: "missing-config"; configFile: string }
|
|
29
|
+
| { kind: "failed"; message: string };
|
|
30
|
+
|
|
31
|
+
const messageOf = (error: unknown): string =>
|
|
32
|
+
error instanceof Error ? error.message : String(error);
|
|
33
|
+
|
|
34
|
+
const reportUrl = (baseUrl: string): string =>
|
|
35
|
+
`${baseUrl.replace(/\/+$/, "")}/api/report`;
|
|
36
|
+
|
|
37
|
+
function acceptedCount(body: string): number | null {
|
|
38
|
+
let payload: unknown;
|
|
39
|
+
try {
|
|
40
|
+
payload = JSON.parse(body);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (
|
|
45
|
+
typeof payload !== "object" ||
|
|
46
|
+
payload === null ||
|
|
47
|
+
!("accepted" in payload)
|
|
48
|
+
) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const accepted = payload.accepted;
|
|
52
|
+
return typeof accepted === "number" ? accepted : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function report(
|
|
56
|
+
fetcher: Fetcher,
|
|
57
|
+
url: string,
|
|
58
|
+
key: string,
|
|
59
|
+
usage: UsageReport,
|
|
60
|
+
): Promise<CollectResult> {
|
|
61
|
+
const response = await fetcher(reportUrl(url), {
|
|
62
|
+
body: JSON.stringify(usage),
|
|
63
|
+
headers: {
|
|
64
|
+
Authorization: `Bearer ${key}`,
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
},
|
|
67
|
+
method: "POST",
|
|
68
|
+
});
|
|
69
|
+
const body = await response.text();
|
|
70
|
+
if (response.status !== 200) {
|
|
71
|
+
return {
|
|
72
|
+
kind: "failed",
|
|
73
|
+
message: `tokenmax responded ${response.status}: ${body}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const accepted = acceptedCount(body);
|
|
77
|
+
if (accepted === null) {
|
|
78
|
+
return {
|
|
79
|
+
kind: "failed",
|
|
80
|
+
message: `tokenmax responded an unexpected body: ${body}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return { accepted, kind: "reported", machine: usage.machine };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function collect(options: CollectOptions): Promise<CollectResult> {
|
|
87
|
+
const paths = collectorPaths(options.env ?? processEnv());
|
|
88
|
+
const config = await readConfig(paths.configFile);
|
|
89
|
+
if (config.kind === "missing") {
|
|
90
|
+
return { configFile: paths.configFile, kind: "missing-config" };
|
|
91
|
+
}
|
|
92
|
+
if (config.kind === "invalid") {
|
|
93
|
+
return { kind: "failed", message: config.message };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const machine = machineId(
|
|
97
|
+
options.identity.hostname,
|
|
98
|
+
options.identity.platformUuid,
|
|
99
|
+
);
|
|
100
|
+
const timezone =
|
|
101
|
+
config.config.timezone ?? options.timezone ?? runtimeTimezone();
|
|
102
|
+
const runner = options.runner ?? runCommand;
|
|
103
|
+
|
|
104
|
+
let days: UsageDay[];
|
|
105
|
+
try {
|
|
106
|
+
const daily = await readCcusageDaily(
|
|
107
|
+
runner,
|
|
108
|
+
sinceArgument(options.today ?? new Date(), timezone),
|
|
109
|
+
timezone,
|
|
110
|
+
);
|
|
111
|
+
days = mapCcusageDays(daily);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { kind: "failed", message: messageOf(error) };
|
|
114
|
+
}
|
|
115
|
+
if (days.length === 0) {
|
|
116
|
+
return { kind: "empty" };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
return await report(
|
|
121
|
+
options.fetcher ?? fetch,
|
|
122
|
+
config.config.url,
|
|
123
|
+
config.config.key,
|
|
124
|
+
{ days, machine, timezone },
|
|
125
|
+
);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return { kind: "failed", message: messageOf(error) };
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
exitCode: number;
|
|
5
|
+
stderr: string;
|
|
6
|
+
stdout: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CommandRunner = (
|
|
10
|
+
command: string,
|
|
11
|
+
args: string[],
|
|
12
|
+
) => Promise<CommandResult>;
|
|
13
|
+
|
|
14
|
+
const toText = (chunks: Buffer[]): string =>
|
|
15
|
+
Buffer.concat(chunks).toString("utf8");
|
|
16
|
+
|
|
17
|
+
export function runCommand(
|
|
18
|
+
command: string,
|
|
19
|
+
args: string[],
|
|
20
|
+
): Promise<CommandResult> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
23
|
+
const stdout: Buffer[] = [];
|
|
24
|
+
const stderr: Buffer[] = [];
|
|
25
|
+
child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
26
|
+
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
27
|
+
child.on("error", (error: Error) => {
|
|
28
|
+
resolve({ exitCode: 1, stderr: error.message, stdout: "" });
|
|
29
|
+
});
|
|
30
|
+
child.on("close", (exitCode: number | null) => {
|
|
31
|
+
resolve({
|
|
32
|
+
exitCode: exitCode ?? 1,
|
|
33
|
+
stderr: toText(stderr),
|
|
34
|
+
stdout: toText(stdout),
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const collectorConfig = z.object({
|
|
6
|
+
key: z.string().min(1),
|
|
7
|
+
timezone: z.string().min(1).optional(),
|
|
8
|
+
url: z.url({ protocol: /^https?$/ }),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type CollectorConfig = z.infer<typeof collectorConfig>;
|
|
12
|
+
|
|
13
|
+
export function canonicalTimezone(value: string): string | null {
|
|
14
|
+
try {
|
|
15
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
16
|
+
timeZone: value,
|
|
17
|
+
}).resolvedOptions().timeZone;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function runtimeTimezone(): string {
|
|
24
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ConfigReadResult =
|
|
28
|
+
| { kind: "ok"; config: CollectorConfig }
|
|
29
|
+
| { kind: "missing" }
|
|
30
|
+
| { kind: "invalid"; message: string };
|
|
31
|
+
|
|
32
|
+
const issueText = (error: z.ZodError): string =>
|
|
33
|
+
error.issues
|
|
34
|
+
.map((issue) => `${issue.path.join(".") || "config"}: ${issue.message}`)
|
|
35
|
+
.join("; ");
|
|
36
|
+
|
|
37
|
+
export async function readConfig(
|
|
38
|
+
configFile: string,
|
|
39
|
+
): Promise<ConfigReadResult> {
|
|
40
|
+
let source: string;
|
|
41
|
+
try {
|
|
42
|
+
source = await readFile(configFile, "utf8");
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
45
|
+
return { kind: "missing" };
|
|
46
|
+
}
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return {
|
|
49
|
+
kind: "invalid",
|
|
50
|
+
message: `could not read tokenmax config at ${configFile}: ${message}`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let payload: unknown;
|
|
55
|
+
try {
|
|
56
|
+
payload = JSON.parse(source);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
59
|
+
return {
|
|
60
|
+
kind: "invalid",
|
|
61
|
+
message: `could not parse tokenmax config at ${configFile}: ${message}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsed = collectorConfig.safeParse(payload);
|
|
66
|
+
if (!parsed.success) {
|
|
67
|
+
return {
|
|
68
|
+
kind: "invalid",
|
|
69
|
+
message: `invalid tokenmax config at ${configFile}: ${issueText(parsed.error)}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { kind: "ok", config: parsed.data };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function writeConfig(
|
|
76
|
+
configFile: string,
|
|
77
|
+
config: CollectorConfig,
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
await mkdir(dirname(configFile), { recursive: true });
|
|
80
|
+
await writeFile(configFile, `${JSON.stringify(config, null, 2)}\n`);
|
|
81
|
+
await chmod(configFile, 0o600);
|
|
82
|
+
}
|
package/src/install.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
type CollectorConfig,
|
|
6
|
+
canonicalTimezone,
|
|
7
|
+
collectorConfig,
|
|
8
|
+
runtimeTimezone,
|
|
9
|
+
writeConfig,
|
|
10
|
+
} from "./config";
|
|
11
|
+
import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
|
|
12
|
+
import {
|
|
13
|
+
launchAgentPlist,
|
|
14
|
+
loadCommand,
|
|
15
|
+
type ScheduleOptions,
|
|
16
|
+
type SupportedPlatform,
|
|
17
|
+
systemdService,
|
|
18
|
+
systemdTimer,
|
|
19
|
+
} from "./schedule";
|
|
20
|
+
|
|
21
|
+
export interface ScheduleFile {
|
|
22
|
+
contents: string;
|
|
23
|
+
path: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InstallPlan {
|
|
27
|
+
config: CollectorConfig;
|
|
28
|
+
configFile: string;
|
|
29
|
+
files: ScheduleFile[];
|
|
30
|
+
loadCommand: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface InstallOptions {
|
|
34
|
+
key: string;
|
|
35
|
+
url: string;
|
|
36
|
+
cliPath?: string;
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
env?: CollectorEnv;
|
|
39
|
+
execPath?: string;
|
|
40
|
+
log?: (line: string) => void;
|
|
41
|
+
platform?: NodeJS.Platform;
|
|
42
|
+
timezone?: string;
|
|
43
|
+
uid?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const resolvePlatform = (value: NodeJS.Platform): SupportedPlatform => {
|
|
47
|
+
if (value === "darwin" || value === "linux") {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`unsupported platform: ${value}`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const resolveCliPath = (): string =>
|
|
54
|
+
fileURLToPath(new URL("./cli.ts", import.meta.url));
|
|
55
|
+
|
|
56
|
+
function scheduleFiles(
|
|
57
|
+
platform: SupportedPlatform,
|
|
58
|
+
paths: { plist: string; service: string; timer: string },
|
|
59
|
+
options: ScheduleOptions,
|
|
60
|
+
): ScheduleFile[] {
|
|
61
|
+
if (platform === "darwin") {
|
|
62
|
+
return [{ contents: launchAgentPlist(options), path: paths.plist }];
|
|
63
|
+
}
|
|
64
|
+
return [
|
|
65
|
+
{ contents: systemdService(options), path: paths.service },
|
|
66
|
+
{ contents: systemdTimer(), path: paths.timer },
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function install(options: InstallOptions): Promise<InstallPlan> {
|
|
71
|
+
const env = options.env ?? processEnv();
|
|
72
|
+
const platform = resolvePlatform(options.platform ?? process.platform);
|
|
73
|
+
const paths = collectorPaths(env);
|
|
74
|
+
const requestedTimezone = options.timezone ?? runtimeTimezone();
|
|
75
|
+
const timezone = canonicalTimezone(requestedTimezone);
|
|
76
|
+
if (timezone === null) {
|
|
77
|
+
throw new Error(`invalid timezone: ${requestedTimezone}`);
|
|
78
|
+
}
|
|
79
|
+
const config = collectorConfig.parse({
|
|
80
|
+
key: options.key,
|
|
81
|
+
timezone,
|
|
82
|
+
url: options.url,
|
|
83
|
+
});
|
|
84
|
+
const cliPath = options.cliPath ?? resolveCliPath();
|
|
85
|
+
if (
|
|
86
|
+
options.dryRun !== true &&
|
|
87
|
+
(cliPath.includes("/install/cache/") || /\/bunx-\d+-[^/]+\//.test(cliPath))
|
|
88
|
+
) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
"refusing to schedule from a bunx path; install globally: bun add -g tokenmax-collector, then run: tokenmax install --url <url> --key <key>",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const files = scheduleFiles(platform, paths, {
|
|
94
|
+
cliPath,
|
|
95
|
+
execPath: options.execPath ?? process.execPath,
|
|
96
|
+
stderrLog: paths.stderrLog,
|
|
97
|
+
stdoutLog: paths.stdoutLog,
|
|
98
|
+
});
|
|
99
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
100
|
+
const plan: InstallPlan = {
|
|
101
|
+
config,
|
|
102
|
+
configFile: paths.configFile,
|
|
103
|
+
files,
|
|
104
|
+
loadCommand: loadCommand(platform, paths.plist, uid),
|
|
105
|
+
};
|
|
106
|
+
const log = options.log ?? console.log;
|
|
107
|
+
|
|
108
|
+
log(`config: ${plan.configFile} (600, key redacted)`);
|
|
109
|
+
for (const file of plan.files) {
|
|
110
|
+
log(`schedule: ${file.path}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (options.dryRun === true) {
|
|
114
|
+
for (const file of plan.files) {
|
|
115
|
+
log(`--- ${file.path} ---`);
|
|
116
|
+
log(file.contents.trimEnd());
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
await writeConfig(plan.configFile, plan.config);
|
|
120
|
+
for (const file of plan.files) {
|
|
121
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
122
|
+
await writeFile(file.path, file.contents);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
log(`load: ${plan.loadCommand}`);
|
|
127
|
+
return plan;
|
|
128
|
+
}
|
package/src/machine.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { runCommand } from "./command";
|
|
4
|
+
|
|
5
|
+
export interface MachineIdentity {
|
|
6
|
+
hostname: string;
|
|
7
|
+
platformUuid: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const disallowed = /[^A-Za-z0-9._-]/g;
|
|
11
|
+
|
|
12
|
+
const platformUuidPattern = /"IOPlatformUUID"\s*=\s*"([^"]+)"/;
|
|
13
|
+
|
|
14
|
+
export function machineId(
|
|
15
|
+
hostnameValue: string,
|
|
16
|
+
platformUuid: string | null,
|
|
17
|
+
): string {
|
|
18
|
+
const raw =
|
|
19
|
+
platformUuid === null ? hostnameValue : `${hostnameValue}-${platformUuid}`;
|
|
20
|
+
return raw.replace(disallowed, "-").slice(0, 64);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function platformUuidFromIoreg(source: string): string | null {
|
|
24
|
+
return platformUuidPattern.exec(source)?.[1] ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function readDarwinPlatformUuid(): Promise<string | null> {
|
|
28
|
+
const result = await runCommand("ioreg", [
|
|
29
|
+
"-rd1",
|
|
30
|
+
"-c",
|
|
31
|
+
"IOPlatformExpertDevice",
|
|
32
|
+
]);
|
|
33
|
+
return result.exitCode === 0 ? platformUuidFromIoreg(result.stdout) : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readLinuxPlatformUuid(): Promise<string | null> {
|
|
37
|
+
try {
|
|
38
|
+
const value = (await readFile("/etc/machine-id", "utf8")).trim();
|
|
39
|
+
return value === "" ? null : value;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function readMachineIdentity(
|
|
46
|
+
platform: NodeJS.Platform = process.platform,
|
|
47
|
+
): Promise<MachineIdentity> {
|
|
48
|
+
const platformUuid =
|
|
49
|
+
platform === "darwin"
|
|
50
|
+
? await readDarwinPlatformUuid()
|
|
51
|
+
: platform === "linux"
|
|
52
|
+
? await readLinuxPlatformUuid()
|
|
53
|
+
: null;
|
|
54
|
+
return { hostname: hostname(), platformUuid };
|
|
55
|
+
}
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { CcusageDaily } from "./ccusage";
|
|
2
|
+
import type { UsageDay } from "./usage";
|
|
3
|
+
|
|
4
|
+
const agentModelPrefix = /^\[[^\]]*\]\s*/;
|
|
5
|
+
|
|
6
|
+
const rowKey = (day: UsageDay): string =>
|
|
7
|
+
`${day.date}\u0000${day.provider}\u0000${day.model}`;
|
|
8
|
+
|
|
9
|
+
const isEmptyRow = (row: UsageDay): boolean =>
|
|
10
|
+
row.input === 0 &&
|
|
11
|
+
row.output === 0 &&
|
|
12
|
+
row.cache_create === 0 &&
|
|
13
|
+
row.cache_read === 0;
|
|
14
|
+
|
|
15
|
+
export function mapCcusageDays(output: CcusageDaily): UsageDay[] {
|
|
16
|
+
const rows = new Map<string, UsageDay>();
|
|
17
|
+
|
|
18
|
+
for (const day of output.daily) {
|
|
19
|
+
for (const agent of day.agents) {
|
|
20
|
+
for (const breakdown of agent.modelBreakdowns) {
|
|
21
|
+
const row: UsageDay = {
|
|
22
|
+
cache_create: breakdown.cacheCreationTokens,
|
|
23
|
+
cache_read: breakdown.cacheReadTokens,
|
|
24
|
+
cost_usd: breakdown.cost,
|
|
25
|
+
date: day.period,
|
|
26
|
+
input: breakdown.inputTokens,
|
|
27
|
+
model: breakdown.modelName.replace(agentModelPrefix, ""),
|
|
28
|
+
output: breakdown.outputTokens,
|
|
29
|
+
provider: agent.agent,
|
|
30
|
+
};
|
|
31
|
+
if (isEmptyRow(row)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const key = rowKey(row);
|
|
35
|
+
const existing = rows.get(key);
|
|
36
|
+
if (existing === undefined) {
|
|
37
|
+
rows.set(key, row);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
existing.cache_create += row.cache_create;
|
|
41
|
+
existing.cache_read += row.cache_read;
|
|
42
|
+
existing.cost_usd += row.cost_usd;
|
|
43
|
+
existing.input += row.input;
|
|
44
|
+
existing.output += row.output;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return [...rows.values()].sort(
|
|
50
|
+
(a, b) =>
|
|
51
|
+
a.date.localeCompare(b.date) ||
|
|
52
|
+
a.provider.localeCompare(b.provider) ||
|
|
53
|
+
a.model.localeCompare(b.model),
|
|
54
|
+
);
|
|
55
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface CollectorEnv {
|
|
5
|
+
home: string;
|
|
6
|
+
platform?: NodeJS.Platform;
|
|
7
|
+
tokenmaxHome?: string;
|
|
8
|
+
xdgConfigHome?: string;
|
|
9
|
+
xdgStateHome?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface CollectorPaths {
|
|
13
|
+
configFile: string;
|
|
14
|
+
plist: string;
|
|
15
|
+
service: string;
|
|
16
|
+
stderrLog: string;
|
|
17
|
+
stdoutLog: string;
|
|
18
|
+
timer: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function processEnv(): CollectorEnv {
|
|
22
|
+
return {
|
|
23
|
+
home: homedir(),
|
|
24
|
+
platform: process.platform,
|
|
25
|
+
tokenmaxHome: process.env.TOKENMAX_HOME,
|
|
26
|
+
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
27
|
+
xdgStateHome: process.env.XDG_STATE_HOME,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function logDirFor(env: CollectorEnv, home: string): string {
|
|
32
|
+
if (env.platform !== "linux") {
|
|
33
|
+
return resolve(home, "Library", "Logs", "tokenmax");
|
|
34
|
+
}
|
|
35
|
+
if (env.tokenmaxHome === undefined && env.xdgStateHome !== undefined) {
|
|
36
|
+
return resolve(env.xdgStateHome, "tokenmax", "logs");
|
|
37
|
+
}
|
|
38
|
+
return resolve(home, ".local", "state", "tokenmax", "logs");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function collectorPaths(env: CollectorEnv): CollectorPaths {
|
|
42
|
+
const home = resolve(env.tokenmaxHome ?? env.home);
|
|
43
|
+
const configHome =
|
|
44
|
+
env.tokenmaxHome === undefined && env.xdgConfigHome !== undefined
|
|
45
|
+
? resolve(env.xdgConfigHome)
|
|
46
|
+
: resolve(home, ".config");
|
|
47
|
+
const logDir = logDirFor(env, home);
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
configFile: resolve(configHome, "tokenmax", "config.json"),
|
|
51
|
+
plist: resolve(
|
|
52
|
+
home,
|
|
53
|
+
"Library",
|
|
54
|
+
"LaunchAgents",
|
|
55
|
+
"dev.tokenmax.collector.plist",
|
|
56
|
+
),
|
|
57
|
+
service: resolve(home, ".config", "systemd", "user", "tokenmax.service"),
|
|
58
|
+
stderrLog: resolve(logDir, "tokenmax.err.log"),
|
|
59
|
+
stdoutLog: resolve(logDir, "tokenmax.log"),
|
|
60
|
+
timer: resolve(home, ".config", "systemd", "user", "tokenmax.timer"),
|
|
61
|
+
};
|
|
62
|
+
}
|
package/src/schedule.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface ScheduleOptions {
|
|
2
|
+
cliPath: string;
|
|
3
|
+
execPath: string;
|
|
4
|
+
stderrLog: string;
|
|
5
|
+
stdoutLog: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type SupportedPlatform = "darwin" | "linux";
|
|
9
|
+
|
|
10
|
+
export function launchAgentPlist(options: ScheduleOptions): string {
|
|
11
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
12
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
13
|
+
<plist version="1.0">
|
|
14
|
+
<dict>
|
|
15
|
+
<key>Label</key>
|
|
16
|
+
<string>dev.tokenmax.collector</string>
|
|
17
|
+
<key>ProgramArguments</key>
|
|
18
|
+
<array>
|
|
19
|
+
<string>${options.execPath}</string>
|
|
20
|
+
<string>${options.cliPath}</string>
|
|
21
|
+
<string>collect</string>
|
|
22
|
+
</array>
|
|
23
|
+
<key>StartInterval</key>
|
|
24
|
+
<integer>300</integer>
|
|
25
|
+
<key>RunAtLoad</key>
|
|
26
|
+
<true/>
|
|
27
|
+
<key>StandardOutPath</key>
|
|
28
|
+
<string>${options.stdoutLog}</string>
|
|
29
|
+
<key>StandardErrorPath</key>
|
|
30
|
+
<string>${options.stderrLog}</string>
|
|
31
|
+
</dict>
|
|
32
|
+
</plist>
|
|
33
|
+
`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function systemdService(options: ScheduleOptions): string {
|
|
37
|
+
return `[Unit]
|
|
38
|
+
Description=Report local token usage to tokenmax
|
|
39
|
+
|
|
40
|
+
[Service]
|
|
41
|
+
Type=oneshot
|
|
42
|
+
ExecStart="${options.execPath}" "${options.cliPath}" collect
|
|
43
|
+
`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function systemdTimer(): string {
|
|
47
|
+
return `[Unit]
|
|
48
|
+
Description=Report local token usage to tokenmax every five minutes
|
|
49
|
+
|
|
50
|
+
[Timer]
|
|
51
|
+
Unit=tokenmax.service
|
|
52
|
+
OnBootSec=1min
|
|
53
|
+
OnUnitActiveSec=5min
|
|
54
|
+
|
|
55
|
+
[Install]
|
|
56
|
+
WantedBy=timers.target
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function loadCommand(
|
|
61
|
+
platform: SupportedPlatform,
|
|
62
|
+
plistPath: string,
|
|
63
|
+
uid: number,
|
|
64
|
+
): string {
|
|
65
|
+
return platform === "darwin"
|
|
66
|
+
? `launchctl bootstrap gui/${uid} ${plistPath}`
|
|
67
|
+
: "systemctl --user enable --now tokenmax.timer";
|
|
68
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface UsageDay {
|
|
2
|
+
cache_create: number;
|
|
3
|
+
cache_read: number;
|
|
4
|
+
cost_usd: number;
|
|
5
|
+
date: string;
|
|
6
|
+
input: number;
|
|
7
|
+
model: string;
|
|
8
|
+
output: number;
|
|
9
|
+
provider: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface UsageReport {
|
|
13
|
+
days: UsageDay[];
|
|
14
|
+
machine: string;
|
|
15
|
+
timezone: string;
|
|
16
|
+
}
|