@cgaravitoq/aiusage 0.4.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 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/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # @cgaravitoq/aiusage
2
+
3
+ Report local token usage to an [aiusage](https://github.com/cgaravitoq/aiusage) instance.
4
+
5
+ The collector requires Bun 1.4.0 or newer, which ships `node:sqlite`, and supports macOS and Linux.
6
+ Windows is unsupported, and npm refuses the install there.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ bun add -g @cgaravitoq/aiusage
12
+ aiusage install --url <url> --key <key>
13
+ ```
14
+
15
+ `install` writes `~/.config/aiusage/config.json` with mode 600 and a launchd agent on macOS or a systemd user timer on Linux that runs `aiusage collect` every five minutes.
16
+ Pass `--timezone <zone>` to override the machine's IANA timezone, or `--dry-run` to print the files without writing them.
17
+ Non-dry `install` refuses Bun paths containing `/install/cache/` or a `bunx-<digits>-<package>` directory segment, because those paths can disappear while a schedule still points to them.
18
+
19
+ Running `install` with a new url adds a target and keeps the existing ones, and running it again with a configured url replaces that target's key, so one machine can feed a personal instance and a team board at once.
20
+ The config is `{ "targets": [{ "url": "...", "key": "..." }], "timezone": "..." }`, and the single-target shape written before targets existed still reads.
21
+
22
+ ## Report
23
+
24
+ `aiusage collect` reports the last 14 calendar days to `POST /api/report` of every configured target.
25
+ The rows come from ccusage for every agent it detects, plus two sources ccusage has no adapter for: the Antigravity CLI conversations under `~/.gemini/antigravity-cli/conversations`, reported as `antigravity`, and the Devin CLI transcripts under `~/.local/share/devin/cli/transcripts`, reported as `devin` with the effort suffix of each model collapsed into its LiteLLM name.
26
+ Both are priced from the LiteLLM table cached for a day at `~/.config/aiusage/litellm-prices.json`.
27
+
28
+ `collect` prints one `accepted <n> days for <machine> at <url>` line per target, where `<n>` is the number of usage rows the instance stored rather than a count of calendar days, and exits 1 when any target rejects the report.
29
+
30
+ ## Upgrade
31
+
32
+ ```bash
33
+ bun add -g @cgaravitoq/aiusage@latest
34
+ aiusage install --url <url> --key <existing-key>
35
+ ```
36
+
37
+ On macOS, run `launchctl bootout gui/<uid>/dev.aiusage.collector`, then run the printed `launchctl bootstrap` command.
38
+ On Linux, run `systemctl --user daemon-reload`, then run the printed `systemctl --user enable --now aiusage.timer` command.
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@cgaravitoq/aiusage",
3
+ "version": "0.4.0",
4
+ "description": "Report local ccusage token usage to an aiusage instance",
5
+ "type": "module",
6
+ "bin": {
7
+ "aiusage": "./src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "LICENSE",
12
+ "!src/**/*.spec.ts",
13
+ "!src/test/**"
14
+ ],
15
+ "os": [
16
+ "darwin",
17
+ "linux"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/cgaravitoq/aiusage.git",
23
+ "directory": "packages/collector"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.4.0"
27
+ },
28
+ "scripts": {
29
+ "check-types": "tsc --noEmit",
30
+ "test": "vitest run --exclude src/package.e2e.spec.ts",
31
+ "test:package": "vitest run src/package.e2e.spec.ts",
32
+ "test:watch": "vitest --exclude src/package.e2e.spec.ts"
33
+ },
34
+ "dependencies": {
35
+ "ccusage": "20.0.20",
36
+ "commander": "^15.0.0",
37
+ "zod": "^4.4.3"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^24.13.6",
41
+ "typescript": "^6.0.3",
42
+ "vitest": "^4.1.11"
43
+ }
44
+ }
@@ -0,0 +1,191 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { type ReaderSteps, readDirectorySteps } from "./devin";
5
+
6
+ export interface AntigravityStep {
7
+ at: Date;
8
+ cacheRead: number;
9
+ input: number;
10
+ model: string;
11
+ output: number;
12
+ }
13
+
14
+ export interface AntigravityUsage {
15
+ failures: string[];
16
+ steps: AntigravityStep[];
17
+ }
18
+
19
+ interface ProtoField {
20
+ number: number;
21
+ value: bigint | Uint8Array;
22
+ }
23
+
24
+ interface RawStep {
25
+ at: Date;
26
+ cacheRead: number;
27
+ input: number;
28
+ modelCode: number;
29
+ output: number;
30
+ }
31
+
32
+ const unknownModel = "gemini-unknown";
33
+
34
+ const usageField = 9;
35
+ const createdField = 1;
36
+ const generationField = 1;
37
+ const generationUsageField = 4;
38
+ const generationModelField = 19;
39
+ const modelCodeField = 1;
40
+ const inputField = 2;
41
+ const outputField = 3;
42
+ const cacheReadField = 5;
43
+
44
+ export function antigravityConversationsDir(home: string): string {
45
+ return resolve(home, ".gemini", "antigravity-cli", "conversations");
46
+ }
47
+
48
+ function decodeMessage(bytes: Uint8Array): ProtoField[] {
49
+ const fields: ProtoField[] = [];
50
+ let offset = 0;
51
+ const truncated = (): never => {
52
+ throw new Error("truncated protobuf message");
53
+ };
54
+ const varint = (): bigint => {
55
+ let result = 0n;
56
+ let shift = 0n;
57
+ for (;;) {
58
+ if (offset >= bytes.length) {
59
+ truncated();
60
+ }
61
+ const byte = bytes[offset++];
62
+ result |= BigInt(byte & 0x7f) << shift;
63
+ if (byte < 0x80) {
64
+ return result;
65
+ }
66
+ shift += 7n;
67
+ }
68
+ };
69
+ const bytesOf = (length: number): Uint8Array => {
70
+ if (offset + length > bytes.length) {
71
+ truncated();
72
+ }
73
+ const slice = bytes.subarray(offset, offset + length);
74
+ offset += length;
75
+ return slice;
76
+ };
77
+ while (offset < bytes.length) {
78
+ const tag = varint();
79
+ const number = Number(tag >> 3n);
80
+ const wireType = Number(tag & 7n);
81
+ if (wireType === 0) {
82
+ fields.push({ number, value: varint() });
83
+ } else if (wireType === 1) {
84
+ fields.push({ number, value: bytesOf(8) });
85
+ } else if (wireType === 2) {
86
+ fields.push({ number, value: bytesOf(Number(varint())) });
87
+ } else if (wireType === 5) {
88
+ fields.push({ number, value: bytesOf(4) });
89
+ } else {
90
+ throw new Error(`unsupported protobuf wire type ${wireType}`);
91
+ }
92
+ }
93
+ return fields;
94
+ }
95
+
96
+ function nested(fields: ProtoField[], number: number): ProtoField[] | null {
97
+ const field = fields.find(
98
+ (candidate) =>
99
+ candidate.number === number && candidate.value instanceof Uint8Array,
100
+ );
101
+ return field === undefined ? null : decodeMessage(field.value as Uint8Array);
102
+ }
103
+
104
+ function integer(fields: ProtoField[], number: number): number | null {
105
+ const field = fields.find(
106
+ (candidate) =>
107
+ candidate.number === number && typeof candidate.value === "bigint",
108
+ );
109
+ return field === undefined ? null : Number(field.value);
110
+ }
111
+
112
+ function text(fields: ProtoField[], number: number): string | null {
113
+ const field = fields.find(
114
+ (candidate) =>
115
+ candidate.number === number && candidate.value instanceof Uint8Array,
116
+ );
117
+ return field === undefined
118
+ ? null
119
+ : new TextDecoder().decode(field.value as Uint8Array);
120
+ }
121
+
122
+ function openReadOnly(file: string): DatabaseSync {
123
+ const location = existsSync(`${file}-wal`)
124
+ ? file
125
+ : `file:${file.split("/").map(encodeURIComponent).join("/")}?immutable=1`;
126
+ const db = new DatabaseSync(location, { readOnly: true });
127
+ db.exec("PRAGMA busy_timeout = 5000");
128
+ return db;
129
+ }
130
+
131
+ function readConversation(
132
+ file: string,
133
+ names: Map<number, string>,
134
+ ): ReaderSteps<RawStep> {
135
+ const db = openReadOnly(file);
136
+ try {
137
+ const generations = db.prepare("SELECT data FROM gen_metadata").all() as {
138
+ data: Uint8Array;
139
+ }[];
140
+ for (const row of generations) {
141
+ const generation = nested(decodeMessage(row.data), generationField);
142
+ const usage =
143
+ generation === null ? null : nested(generation, generationUsageField);
144
+ const code = usage === null ? null : integer(usage, modelCodeField);
145
+ const name =
146
+ generation === null ? null : text(generation, generationModelField);
147
+ if (code !== null && name !== null) {
148
+ names.set(code, name);
149
+ }
150
+ }
151
+ const steps: RawStep[] = [];
152
+ const rows = db
153
+ .prepare("SELECT metadata FROM steps WHERE metadata IS NOT NULL")
154
+ .all() as { metadata: Uint8Array }[];
155
+ for (const row of rows) {
156
+ const fields = decodeMessage(row.metadata);
157
+ const usage = nested(fields, usageField);
158
+ const created = nested(fields, createdField);
159
+ const seconds = created === null ? null : integer(created, 1);
160
+ if (usage === null || seconds === null) {
161
+ continue;
162
+ }
163
+ steps.push({
164
+ at: new Date(seconds * 1000),
165
+ cacheRead: integer(usage, cacheReadField) ?? 0,
166
+ input: integer(usage, inputField) ?? 0,
167
+ modelCode: integer(usage, modelCodeField) ?? 0,
168
+ output: integer(usage, outputField) ?? 0,
169
+ });
170
+ }
171
+ return { failures: [], steps };
172
+ } finally {
173
+ db.close();
174
+ }
175
+ }
176
+
177
+ export async function readAntigravitySteps(
178
+ dir: string,
179
+ ): Promise<AntigravityUsage> {
180
+ const names = new Map<number, string>();
181
+ const read = await readDirectorySteps(dir, ".db", async (file) =>
182
+ readConversation(file, names),
183
+ );
184
+ return {
185
+ failures: read.failures,
186
+ steps: read.steps.map(({ modelCode, ...step }) => ({
187
+ ...step,
188
+ model: names.get(modelCode) ?? unknownModel,
189
+ })),
190
+ };
191
+ }
package/src/ccusage.ts ADDED
@@ -0,0 +1,91 @@
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
+ const commandTimeoutMs = 60_000;
32
+
33
+ export function parseCcusageDaily(source: string): CcusageDaily {
34
+ return ccusageDaily.parse(JSON.parse(source));
35
+ }
36
+
37
+ export function ccusageCliPath(): string {
38
+ return createRequire(import.meta.url).resolve("ccusage/src/cli.js");
39
+ }
40
+
41
+ export function calendarDate(at: Date, timezone: string): string {
42
+ return new Intl.DateTimeFormat("en-CA", {
43
+ timeZone: timezone,
44
+ year: "numeric",
45
+ month: "2-digit",
46
+ day: "2-digit",
47
+ }).format(at);
48
+ }
49
+
50
+ export function windowStart(today: Date, timezone: string): string {
51
+ const [year, month, day] = calendarDate(today, timezone)
52
+ .split("-")
53
+ .map(Number);
54
+ const start = new Date(Date.UTC(year, month - 1, day - (windowDays - 1)));
55
+ return start.toISOString().slice(0, 10);
56
+ }
57
+
58
+ export function sinceArgument(today: Date, timezone: string): string {
59
+ return windowStart(today, timezone).replaceAll("-", "");
60
+ }
61
+
62
+ export function ccusageArguments(since: string, timezone: string): string[] {
63
+ return [
64
+ "daily",
65
+ "--json",
66
+ "--breakdown",
67
+ "--by-agent",
68
+ "-z",
69
+ timezone,
70
+ "--since",
71
+ since,
72
+ ];
73
+ }
74
+
75
+ export async function readCcusageDaily(
76
+ runner: CommandRunner,
77
+ since: string,
78
+ timezone: string,
79
+ ): Promise<CcusageDaily> {
80
+ const result = await runner(
81
+ process.execPath,
82
+ [ccusageCliPath(), ...ccusageArguments(since, timezone)],
83
+ commandTimeoutMs,
84
+ );
85
+ if (result.exitCode !== 0) {
86
+ throw new Error(
87
+ `ccusage exited with ${result.exitCode}: ${result.stderr.trim()}`,
88
+ );
89
+ }
90
+ return parseCcusageDaily(result.stdout);
91
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env bun
2
+ import { Command } from "commander";
3
+ import { collect } from "./collect";
4
+ import type { CommandRunner } from "./command";
5
+ import type { Fetcher } from "./http";
6
+ import { install } from "./install";
7
+ import { type MachineIdentity, readMachineIdentity } from "./machine";
8
+ import { type CollectorEnv, processEnv } from "./paths";
9
+
10
+ export interface CliIo {
11
+ stderr: (line: string) => void;
12
+ stdout: (line: string) => void;
13
+ cliPath?: string;
14
+ env?: CollectorEnv;
15
+ execPath?: string;
16
+ fetcher?: Fetcher;
17
+ identity?: MachineIdentity;
18
+ platform?: NodeJS.Platform;
19
+ runner?: CommandRunner;
20
+ today?: Date;
21
+ uid?: number;
22
+ }
23
+
24
+ interface InstallCommandOptions {
25
+ dryRun: boolean;
26
+ key: string;
27
+ timezone?: string;
28
+ url: string;
29
+ }
30
+
31
+ async function runCollect(io: CliIo): Promise<number> {
32
+ const result = await collect({
33
+ env: io.env ?? processEnv(),
34
+ fetcher: io.fetcher,
35
+ identity: io.identity ?? (await readMachineIdentity()),
36
+ runner: io.runner,
37
+ today: io.today,
38
+ });
39
+ if (result.kind === "reported" || result.kind === "empty") {
40
+ for (const warning of result.warnings) {
41
+ io.stderr(warning);
42
+ }
43
+ if (result.kind === "empty") {
44
+ io.stdout("nothing to report");
45
+ return 0;
46
+ }
47
+ let exitCode = 0;
48
+ for (const target of result.targets) {
49
+ if ("accepted" in target) {
50
+ io.stdout(
51
+ `accepted ${target.accepted} days for ${result.machine} at ${target.url}`,
52
+ );
53
+ } else {
54
+ io.stderr(`${target.url}: ${target.message}`);
55
+ exitCode = 1;
56
+ }
57
+ }
58
+ return exitCode;
59
+ }
60
+ if (result.kind === "missing-config") {
61
+ io.stderr(
62
+ `no aiusage config at ${result.configFile}; run: aiusage install --url <url> --key <key>`,
63
+ );
64
+ return 2;
65
+ }
66
+ for (const warning of result.warnings) {
67
+ io.stderr(warning);
68
+ }
69
+ io.stderr(result.message);
70
+ return 1;
71
+ }
72
+
73
+ async function runInstall(
74
+ options: InstallCommandOptions,
75
+ io: CliIo,
76
+ ): Promise<number> {
77
+ try {
78
+ await install({
79
+ cliPath: io.cliPath,
80
+ dryRun: options.dryRun,
81
+ env: io.env ?? processEnv(),
82
+ execPath: io.execPath,
83
+ key: options.key,
84
+ log: io.stdout,
85
+ platform: io.platform,
86
+ timezone: options.timezone,
87
+ uid: io.uid,
88
+ url: options.url,
89
+ });
90
+ return 0;
91
+ } catch (error) {
92
+ io.stderr(error instanceof Error ? error.message : String(error));
93
+ return 1;
94
+ }
95
+ }
96
+
97
+ export async function runCli(argv: string[], io: CliIo): Promise<number> {
98
+ let exitCode = 0;
99
+ const program = new Command()
100
+ .name("@cgaravitoq/aiusage")
101
+ .description("Report local token usage to an aiusage instance");
102
+
103
+ program
104
+ .command("collect")
105
+ .description("Report local usage from the last 14 days")
106
+ .action(async () => {
107
+ exitCode = await runCollect(io);
108
+ });
109
+
110
+ program
111
+ .command("install")
112
+ .description("Write the aiusage config and the local schedule")
113
+ .requiredOption("--url <url>", "aiusage base url")
114
+ .requiredOption("--key <key>", "aiusage api key")
115
+ .option("--timezone <zone>", "IANA timezone, defaults to the machine zone")
116
+ .option("--dry-run", "print the files without writing them", false)
117
+ .action(async (options: InstallCommandOptions) => {
118
+ exitCode = await runInstall(options, io);
119
+ });
120
+
121
+ await program.parseAsync(argv, { from: "user" });
122
+ return exitCode;
123
+ }
124
+
125
+ if (import.meta.main) {
126
+ process.exitCode = await runCli(process.argv.slice(2), {
127
+ stderr: console.error,
128
+ stdout: console.log,
129
+ });
130
+ }