@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/src/install.ts ADDED
@@ -0,0 +1,162 @@
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
+ type CollectorTarget,
7
+ canonicalTimezone,
8
+ collectorConfig,
9
+ readConfig,
10
+ runtimeTimezone,
11
+ writeConfig,
12
+ } from "./config";
13
+ import {
14
+ type CollectorEnv,
15
+ collectorPaths,
16
+ processEnv,
17
+ systemdUserUnitDir,
18
+ } from "./paths";
19
+ import {
20
+ launchAgentPlist,
21
+ loadCommand,
22
+ type ScheduleOptions,
23
+ type SupportedPlatform,
24
+ systemdService,
25
+ systemdTimer,
26
+ } from "./schedule";
27
+
28
+ export interface ScheduleFile {
29
+ contents: string;
30
+ path: string;
31
+ }
32
+
33
+ export interface InstallPlan {
34
+ config: CollectorConfig;
35
+ configFile: string;
36
+ files: ScheduleFile[];
37
+ loadCommand: string;
38
+ }
39
+
40
+ export interface InstallOptions {
41
+ key: string;
42
+ url: string;
43
+ cliPath?: string;
44
+ dryRun?: boolean;
45
+ env?: CollectorEnv;
46
+ execPath?: string;
47
+ log?: (line: string) => void;
48
+ platform?: NodeJS.Platform;
49
+ timezone?: string;
50
+ uid?: number;
51
+ }
52
+
53
+ const resolvePlatform = (value: NodeJS.Platform): SupportedPlatform => {
54
+ if (value === "darwin" || value === "linux") {
55
+ return value;
56
+ }
57
+ throw new Error(`unsupported platform: ${value}`);
58
+ };
59
+
60
+ const resolveCliPath = (): string =>
61
+ fileURLToPath(new URL("./cli.ts", import.meta.url));
62
+
63
+ const canonicalUrl = (url: string): string => url.replace(/\/+$/, "");
64
+
65
+ function scheduleFiles(
66
+ platform: SupportedPlatform,
67
+ paths: { plist: string; service: string; timer: string },
68
+ options: ScheduleOptions,
69
+ ): ScheduleFile[] {
70
+ if (platform === "darwin") {
71
+ return [{ contents: launchAgentPlist(options), path: paths.plist }];
72
+ }
73
+ return [
74
+ { contents: systemdService(options), path: paths.service },
75
+ { contents: systemdTimer(), path: paths.timer },
76
+ ];
77
+ }
78
+
79
+ async function configuredTargets(
80
+ configFile: string,
81
+ ): Promise<CollectorTarget[]> {
82
+ const existing = await readConfig(configFile);
83
+ if (existing.kind === "invalid") {
84
+ throw new Error(existing.message);
85
+ }
86
+ return existing.kind === "ok" ? existing.config.targets : [];
87
+ }
88
+
89
+ export async function install(options: InstallOptions): Promise<InstallPlan> {
90
+ const env = options.env ?? processEnv();
91
+ const platform = resolvePlatform(options.platform ?? process.platform);
92
+ const paths = collectorPaths(env);
93
+ const requestedTimezone = options.timezone ?? runtimeTimezone();
94
+ const timezone = canonicalTimezone(requestedTimezone);
95
+ if (timezone === null) {
96
+ throw new Error(`invalid timezone: ${requestedTimezone}`);
97
+ }
98
+ const url = canonicalUrl(options.url);
99
+ const others = (await configuredTargets(paths.configFile)).filter(
100
+ (target) => canonicalUrl(target.url) !== url,
101
+ );
102
+ const config = collectorConfig.parse({
103
+ targets: [...others, { key: options.key, url }],
104
+ timezone,
105
+ });
106
+ const cliPath = options.cliPath ?? resolveCliPath();
107
+ if (
108
+ options.dryRun !== true &&
109
+ (cliPath.includes("/install/cache/") || /\/bunx-\d+-[^/]+\//.test(cliPath))
110
+ ) {
111
+ throw new Error(
112
+ "refusing to schedule from a bunx path; install globally: bun add -g @cgaravitoq/aiusage, then run: aiusage install --url <url> --key <key>",
113
+ );
114
+ }
115
+ const files = scheduleFiles(platform, paths, {
116
+ cliPath,
117
+ execPath: options.execPath ?? process.execPath,
118
+ stderrLog: paths.stderrLog,
119
+ stdoutLog: paths.stdoutLog,
120
+ });
121
+ const uid = options.uid ?? process.getuid?.() ?? 0;
122
+ const plan: InstallPlan = {
123
+ config,
124
+ configFile: paths.configFile,
125
+ files,
126
+ loadCommand: loadCommand(platform, paths.plist, uid),
127
+ };
128
+ const log = options.log ?? console.log;
129
+
130
+ log(`config: ${plan.configFile} (600, key redacted)`);
131
+ for (const target of plan.config.targets) {
132
+ log(`target: ${target.url}`);
133
+ }
134
+ for (const file of plan.files) {
135
+ log(`schedule: ${file.path}`);
136
+ }
137
+ if (platform === "linux") {
138
+ const unitDir = dirname(paths.service);
139
+ const systemdDir = systemdUserUnitDir(env);
140
+ if (unitDir !== systemdDir) {
141
+ log(
142
+ `warning: systemd will not read units from ${unitDir}; it reads them from ${systemdDir}`,
143
+ );
144
+ }
145
+ }
146
+
147
+ if (options.dryRun === true) {
148
+ for (const file of plan.files) {
149
+ log(`--- ${file.path} ---`);
150
+ log(file.contents.trimEnd());
151
+ }
152
+ } else {
153
+ await writeConfig(plan.configFile, plan.config);
154
+ for (const file of plan.files) {
155
+ await mkdir(dirname(file.path), { recursive: true });
156
+ await writeFile(file.path, file.contents);
157
+ }
158
+ }
159
+
160
+ log(`load: ${plan.loadCommand}`);
161
+ return plan;
162
+ }
package/src/machine.ts ADDED
@@ -0,0 +1,56 @@
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
+ /** The hostname changes with the network, and every spelling of it used to open
15
+ * a new machine on the server, so the platform identifier travels alone. */
16
+ export function machineId(
17
+ hostnameValue: string,
18
+ platformUuid: string | null,
19
+ ): string {
20
+ const raw = platformUuid ?? hostnameValue;
21
+ return raw.replace(disallowed, "-").slice(0, 64);
22
+ }
23
+
24
+ export function platformUuidFromIoreg(source: string): string | null {
25
+ return platformUuidPattern.exec(source)?.[1] ?? null;
26
+ }
27
+
28
+ async function readDarwinPlatformUuid(): Promise<string | null> {
29
+ const result = await runCommand("ioreg", [
30
+ "-rd1",
31
+ "-c",
32
+ "IOPlatformExpertDevice",
33
+ ]);
34
+ return result.exitCode === 0 ? platformUuidFromIoreg(result.stdout) : null;
35
+ }
36
+
37
+ async function readLinuxPlatformUuid(): Promise<string | null> {
38
+ try {
39
+ const value = (await readFile("/etc/machine-id", "utf8")).trim();
40
+ return value === "" ? null : value;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export async function readMachineIdentity(
47
+ platform: NodeJS.Platform = process.platform,
48
+ ): Promise<MachineIdentity> {
49
+ const platformUuid =
50
+ platform === "darwin"
51
+ ? await readDarwinPlatformUuid()
52
+ : platform === "linux"
53
+ ? await readLinuxPlatformUuid()
54
+ : null;
55
+ return { hostname: hostname(), platformUuid };
56
+ }
package/src/mapping.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { AntigravityStep } from "./antigravity";
2
+ import { type CcusageDaily, calendarDate } from "./ccusage";
3
+ import type { DevinStep } from "./devin";
4
+ import { costOf, type ModelPrice, type PriceTable } from "./pricing";
5
+ import type { UsageDay } from "./usage";
6
+
7
+ export const antigravityProvider = "antigravity";
8
+ export const devinProvider = "devin";
9
+
10
+ const agentModelPrefix = /^\[[^\]]*\]\s*/;
11
+ const effortSuffix = /-(low|medium|high|xhigh)$/;
12
+ const gptMinorVersion = /^gpt-(\d+)-(\d+)-/;
13
+
14
+ const litellmModel = (devinModel: string): string =>
15
+ devinModel.replace(effortSuffix, "").replace(gptMinorVersion, "gpt-$1.$2-");
16
+
17
+ const rowKey = (day: UsageDay): string =>
18
+ `${day.date}\u0000${day.provider}\u0000${day.model}`;
19
+
20
+ const isEmptyRow = (row: UsageDay): boolean =>
21
+ row.input === 0 &&
22
+ row.output === 0 &&
23
+ row.cache_create === 0 &&
24
+ row.cache_read === 0;
25
+
26
+ function addRow(rows: Map<string, UsageDay>, row: UsageDay): void {
27
+ const key = rowKey(row);
28
+ const existing = rows.get(key);
29
+ if (existing === undefined) {
30
+ rows.set(key, row);
31
+ return;
32
+ }
33
+ existing.cache_create += row.cache_create;
34
+ existing.cache_read += row.cache_read;
35
+ existing.cost_usd += row.cost_usd;
36
+ existing.input += row.input;
37
+ existing.output += row.output;
38
+ }
39
+
40
+ export function ccusageProviders(output: CcusageDaily): string[] {
41
+ const providers = new Set<string>();
42
+ for (const day of output.daily) {
43
+ for (const agent of day.agents) {
44
+ providers.add(agent.agent);
45
+ }
46
+ }
47
+ return [...providers].sort();
48
+ }
49
+
50
+ export function mapCcusageDays(output: CcusageDaily): UsageDay[] {
51
+ const rows = new Map<string, UsageDay>();
52
+
53
+ for (const day of output.daily) {
54
+ for (const agent of day.agents) {
55
+ for (const breakdown of agent.modelBreakdowns) {
56
+ const row: UsageDay = {
57
+ cache_create: breakdown.cacheCreationTokens,
58
+ cache_read: breakdown.cacheReadTokens,
59
+ cost_usd: breakdown.cost,
60
+ date: day.period,
61
+ input: breakdown.inputTokens,
62
+ model: breakdown.modelName.replace(agentModelPrefix, ""),
63
+ output: breakdown.outputTokens,
64
+ provider: agent.agent,
65
+ };
66
+ if (isEmptyRow(row)) {
67
+ continue;
68
+ }
69
+ addRow(rows, row);
70
+ }
71
+ }
72
+ }
73
+
74
+ return sortedRows(rows);
75
+ }
76
+
77
+ interface LocalStep {
78
+ at: Date;
79
+ cacheCreate?: number;
80
+ cacheRead: number;
81
+ input: number;
82
+ model: string;
83
+ output: number;
84
+ }
85
+
86
+ export interface MappedDays {
87
+ days: UsageDay[];
88
+ warnings: string[];
89
+ }
90
+
91
+ function mapLocalSteps(
92
+ steps: LocalStep[],
93
+ provider: string,
94
+ timezone: string,
95
+ priceOf: (model: string) => ModelPrice | undefined,
96
+ ): MappedDays {
97
+ const rows = new Map<string, UsageDay>();
98
+
99
+ for (const step of steps) {
100
+ const row: UsageDay = {
101
+ cache_create: step.cacheCreate ?? 0,
102
+ cache_read: step.cacheRead,
103
+ cost_usd: 0,
104
+ date: calendarDate(step.at, timezone),
105
+ input: step.input,
106
+ model: step.model,
107
+ output: step.output,
108
+ provider,
109
+ };
110
+ if (isEmptyRow(row)) {
111
+ continue;
112
+ }
113
+ addRow(rows, row);
114
+ }
115
+
116
+ const unpriced = new Set<string>();
117
+ for (const row of rows.values()) {
118
+ const price = priceOf(row.model);
119
+ if (price === undefined) {
120
+ unpriced.add(row.model);
121
+ continue;
122
+ }
123
+ row.cost_usd = costOf(price, {
124
+ cacheCreate: row.cache_create,
125
+ cacheRead: row.cache_read,
126
+ input: row.input,
127
+ output: row.output,
128
+ });
129
+ }
130
+
131
+ return {
132
+ days: sortedRows(rows),
133
+ warnings: [...unpriced].map(
134
+ (model) => `${provider}: no price for ${model}`,
135
+ ),
136
+ };
137
+ }
138
+
139
+ export function mapAntigravitySteps(
140
+ steps: AntigravityStep[],
141
+ timezone: string,
142
+ prices: PriceTable,
143
+ ): MappedDays {
144
+ return mapLocalSteps(
145
+ steps,
146
+ antigravityProvider,
147
+ timezone,
148
+ (model) => prices.get(model) ?? prices.get(`gemini/${model}`),
149
+ );
150
+ }
151
+
152
+ export function mapDevinSteps(
153
+ steps: DevinStep[],
154
+ timezone: string,
155
+ prices: PriceTable,
156
+ ): MappedDays {
157
+ return mapLocalSteps(
158
+ steps.map((step) => ({ ...step, model: litellmModel(step.model) })),
159
+ devinProvider,
160
+ timezone,
161
+ (model) => prices.get(model),
162
+ );
163
+ }
164
+
165
+ function sortedRows(rows: Map<string, UsageDay>): UsageDay[] {
166
+ return [...rows.values()].sort(
167
+ (a, b) =>
168
+ a.date.localeCompare(b.date) ||
169
+ a.provider.localeCompare(b.provider) ||
170
+ a.model.localeCompare(b.model),
171
+ );
172
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,70 @@
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
+ aiusageHome?: string;
8
+ xdgConfigHome?: string;
9
+ xdgStateHome?: string;
10
+ }
11
+
12
+ export interface CollectorPaths {
13
+ configFile: string;
14
+ plist: string;
15
+ pricesFile: string;
16
+ service: string;
17
+ stderrLog: string;
18
+ stdoutLog: string;
19
+ timer: string;
20
+ }
21
+
22
+ export function processEnv(): CollectorEnv {
23
+ return {
24
+ home: homedir(),
25
+ platform: process.platform,
26
+ aiusageHome: process.env.AIUSAGE_HOME,
27
+ xdgConfigHome: process.env.XDG_CONFIG_HOME,
28
+ xdgStateHome: process.env.XDG_STATE_HOME,
29
+ };
30
+ }
31
+
32
+ function logDirFor(env: CollectorEnv, home: string): string {
33
+ if (env.platform !== "linux") {
34
+ return resolve(home, "Library", "Logs", "aiusage");
35
+ }
36
+ if (env.aiusageHome === undefined && env.xdgStateHome !== undefined) {
37
+ return resolve(env.xdgStateHome, "aiusage", "logs");
38
+ }
39
+ return resolve(home, ".local", "state", "aiusage", "logs");
40
+ }
41
+
42
+ export function systemdUserUnitDir(env: CollectorEnv): string {
43
+ return env.xdgConfigHome === undefined
44
+ ? resolve(env.home, ".config", "systemd", "user")
45
+ : resolve(env.xdgConfigHome, "systemd", "user");
46
+ }
47
+
48
+ export function collectorPaths(env: CollectorEnv): CollectorPaths {
49
+ const home = resolve(env.aiusageHome ?? env.home);
50
+ const configHome =
51
+ env.aiusageHome === undefined && env.xdgConfigHome !== undefined
52
+ ? resolve(env.xdgConfigHome)
53
+ : resolve(home, ".config");
54
+ const logDir = logDirFor(env, home);
55
+
56
+ return {
57
+ configFile: resolve(configHome, "aiusage", "config.json"),
58
+ plist: resolve(
59
+ home,
60
+ "Library",
61
+ "LaunchAgents",
62
+ "dev.aiusage.collector.plist",
63
+ ),
64
+ pricesFile: resolve(configHome, "aiusage", "litellm-prices.json"),
65
+ service: resolve(configHome, "systemd", "user", "aiusage.service"),
66
+ stderrLog: resolve(logDir, "aiusage.err.log"),
67
+ stdoutLog: resolve(logDir, "aiusage.log"),
68
+ timer: resolve(configHome, "systemd", "user", "aiusage.timer"),
69
+ };
70
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { z } from "zod";
4
+ import type { Fetcher } from "./http";
5
+
6
+ export interface ModelPrice {
7
+ cacheCreate: number;
8
+ cacheRead: number;
9
+ input: number;
10
+ output: number;
11
+ }
12
+
13
+ export type PriceTable = Map<string, ModelPrice>;
14
+
15
+ export interface PricedUsage {
16
+ cacheCreate?: number;
17
+ cacheRead: number;
18
+ input: number;
19
+ output: number;
20
+ }
21
+
22
+ export const litellmPricesUrl =
23
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
24
+
25
+ const maxAgeMs = 24 * 60 * 60 * 1000;
26
+ const fetchTimeoutMs = 30_000;
27
+
28
+ const pricedEntry = z.object({
29
+ cache_creation_input_token_cost: z.number().optional(),
30
+ cache_read_input_token_cost: z.number().optional(),
31
+ input_cost_per_token: z.number(),
32
+ output_cost_per_token: z.number(),
33
+ });
34
+
35
+ const litellmPrices = z.record(z.string(), z.unknown());
36
+
37
+ export function parseLitellmPrices(source: string): PriceTable {
38
+ const table: PriceTable = new Map();
39
+ for (const [model, entry] of Object.entries(
40
+ litellmPrices.parse(JSON.parse(source)),
41
+ )) {
42
+ const priced = pricedEntry.safeParse(entry);
43
+ if (priced.success) {
44
+ table.set(model, {
45
+ cacheCreate: priced.data.cache_creation_input_token_cost ?? 0,
46
+ cacheRead: priced.data.cache_read_input_token_cost ?? 0,
47
+ input: priced.data.input_cost_per_token,
48
+ output: priced.data.output_cost_per_token,
49
+ });
50
+ }
51
+ }
52
+ return table;
53
+ }
54
+
55
+ export function costOf(price: ModelPrice, usage: PricedUsage): number {
56
+ return (
57
+ usage.input * price.input +
58
+ usage.output * price.output +
59
+ usage.cacheRead * price.cacheRead +
60
+ (usage.cacheCreate ?? 0) * price.cacheCreate
61
+ );
62
+ }
63
+
64
+ async function readCache(
65
+ pricesFile: string,
66
+ ): Promise<{ modifiedAt: number; table: PriceTable } | null> {
67
+ let source: string;
68
+ let modifiedAt: number;
69
+ try {
70
+ [source, modifiedAt] = await Promise.all([
71
+ readFile(pricesFile, "utf8"),
72
+ stat(pricesFile).then((info) => info.mtimeMs),
73
+ ]);
74
+ } catch (error) {
75
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
76
+ return null;
77
+ }
78
+ throw error;
79
+ }
80
+ try {
81
+ const table = parseLitellmPrices(source);
82
+ return table.size === 0 ? null : { modifiedAt, table };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ async function writeCache(pricesFile: string, source: string): Promise<void> {
89
+ await mkdir(dirname(pricesFile), { recursive: true });
90
+ const partial = `${pricesFile}.${process.pid}.tmp`;
91
+ await writeFile(partial, source);
92
+ await rename(partial, pricesFile);
93
+ }
94
+
95
+ async function fetchPrices(
96
+ fetcher: Fetcher,
97
+ timeoutMs: number,
98
+ ): Promise<string> {
99
+ const response = await fetcher(litellmPricesUrl, {
100
+ method: "GET",
101
+ signal: AbortSignal.timeout(timeoutMs),
102
+ });
103
+ const source = await response.text();
104
+ if (response.status !== 200) {
105
+ throw new Error(`LiteLLM responded ${response.status}`);
106
+ }
107
+ return source;
108
+ }
109
+
110
+ export async function loadPrices(
111
+ fetcher: Fetcher,
112
+ pricesFile: string,
113
+ now: Date,
114
+ timeoutMs = fetchTimeoutMs,
115
+ ): Promise<PriceTable> {
116
+ const cached = await readCache(pricesFile);
117
+ if (cached !== null && now.getTime() - cached.modifiedAt < maxAgeMs) {
118
+ return cached.table;
119
+ }
120
+ let source: string;
121
+ let table: PriceTable;
122
+ try {
123
+ source = await fetchPrices(fetcher, timeoutMs);
124
+ table = parseLitellmPrices(source);
125
+ if (table.size === 0) {
126
+ throw new Error("LiteLLM returned an empty price table");
127
+ }
128
+ } catch (error) {
129
+ if (cached !== null) {
130
+ return cached.table;
131
+ }
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`could not load model prices: ${message}`);
134
+ }
135
+ await writeCache(pricesFile, source);
136
+ return table;
137
+ }
@@ -0,0 +1,86 @@
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
+ const escapeXml = (value: string): string =>
11
+ value
12
+ .replaceAll("&", "&amp;")
13
+ .replaceAll("<", "&lt;")
14
+ .replaceAll(">", "&gt;");
15
+
16
+ const shellSafe = /^[A-Za-z0-9/._:@+,=^-]+$/;
17
+
18
+ const quoteForShell = (value: string): string =>
19
+ shellSafe.test(value) ? value : `'${value.replaceAll("'", () => "'\\''")}'`;
20
+
21
+ const escapeSystemd = (value: string): string =>
22
+ value
23
+ .replaceAll("\\", () => "\\\\")
24
+ .replaceAll("%", "%%")
25
+ .replaceAll("$", () => "$$")
26
+ .replaceAll('"', '\\"');
27
+
28
+ export function launchAgentPlist(options: ScheduleOptions): string {
29
+ return `<?xml version="1.0" encoding="UTF-8"?>
30
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
31
+ <plist version="1.0">
32
+ <dict>
33
+ <key>Label</key>
34
+ <string>dev.aiusage.collector</string>
35
+ <key>ProgramArguments</key>
36
+ <array>
37
+ <string>${escapeXml(options.execPath)}</string>
38
+ <string>${escapeXml(options.cliPath)}</string>
39
+ <string>collect</string>
40
+ </array>
41
+ <key>StartInterval</key>
42
+ <integer>300</integer>
43
+ <key>RunAtLoad</key>
44
+ <true/>
45
+ <key>StandardOutPath</key>
46
+ <string>${escapeXml(options.stdoutLog)}</string>
47
+ <key>StandardErrorPath</key>
48
+ <string>${escapeXml(options.stderrLog)}</string>
49
+ </dict>
50
+ </plist>
51
+ `;
52
+ }
53
+
54
+ export function systemdService(options: ScheduleOptions): string {
55
+ return `[Unit]
56
+ Description=Report local token usage to aiusage
57
+
58
+ [Service]
59
+ Type=oneshot
60
+ ExecStart="${escapeSystemd(options.execPath)}" "${escapeSystemd(options.cliPath)}" collect
61
+ `;
62
+ }
63
+
64
+ export function systemdTimer(): string {
65
+ return `[Unit]
66
+ Description=Report local token usage to aiusage every five minutes
67
+
68
+ [Timer]
69
+ Unit=aiusage.service
70
+ OnBootSec=1min
71
+ OnUnitActiveSec=5min
72
+
73
+ [Install]
74
+ WantedBy=timers.target
75
+ `;
76
+ }
77
+
78
+ export function loadCommand(
79
+ platform: SupportedPlatform,
80
+ plistPath: string,
81
+ uid: number,
82
+ ): string {
83
+ return platform === "darwin"
84
+ ? `launchctl bootstrap gui/${uid} ${quoteForShell(plistPath)}`
85
+ : "systemctl --user enable --now aiusage.timer";
86
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,17 @@
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
+ providers?: string[];
16
+ timezone?: string;
17
+ }