tokenmax-collector 0.1.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmax-collector",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Report local ccusage token usage to a tokenmax instance",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "directory": "packages/collector"
20
20
  },
21
21
  "engines": {
22
- "bun": ">=1.3.14"
22
+ "bun": ">=1.4.0"
23
23
  },
24
24
  "scripts": {
25
25
  "check-types": "tsc --noEmit",
@@ -0,0 +1,207 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { DatabaseSync } from "node:sqlite";
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
+ return new DatabaseSync(location, { readOnly: true });
127
+ }
128
+
129
+ function readConversation(
130
+ file: string,
131
+ names: Map<number, string>,
132
+ steps: RawStep[],
133
+ ): void {
134
+ const db = openReadOnly(file);
135
+ try {
136
+ const generations = db.prepare("SELECT data FROM gen_metadata").all() as {
137
+ data: Uint8Array;
138
+ }[];
139
+ for (const row of generations) {
140
+ const generation = nested(decodeMessage(row.data), generationField);
141
+ const usage =
142
+ generation === null ? null : nested(generation, generationUsageField);
143
+ const code = usage === null ? null : integer(usage, modelCodeField);
144
+ const name =
145
+ generation === null ? null : text(generation, generationModelField);
146
+ if (code !== null && name !== null) {
147
+ names.set(code, name);
148
+ }
149
+ }
150
+ const rows = db
151
+ .prepare("SELECT metadata FROM steps WHERE metadata IS NOT NULL")
152
+ .all() as { metadata: Uint8Array }[];
153
+ for (const row of rows) {
154
+ const fields = decodeMessage(row.metadata);
155
+ const usage = nested(fields, usageField);
156
+ const created = nested(fields, createdField);
157
+ const seconds = created === null ? null : integer(created, 1);
158
+ if (usage === null || seconds === null) {
159
+ continue;
160
+ }
161
+ steps.push({
162
+ at: new Date(seconds * 1000),
163
+ cacheRead: integer(usage, cacheReadField) ?? 0,
164
+ input: integer(usage, inputField) ?? 0,
165
+ modelCode: integer(usage, modelCodeField) ?? 0,
166
+ output: integer(usage, outputField) ?? 0,
167
+ });
168
+ }
169
+ } finally {
170
+ db.close();
171
+ }
172
+ }
173
+
174
+ export async function readAntigravitySteps(
175
+ dir: string,
176
+ ): Promise<AntigravityUsage> {
177
+ let files: string[];
178
+ try {
179
+ files = await readdir(dir);
180
+ } catch (error) {
181
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
182
+ return { failures: [], steps: [] };
183
+ }
184
+ throw error;
185
+ }
186
+ const names = new Map<number, string>();
187
+ const steps: RawStep[] = [];
188
+ const failures: string[] = [];
189
+ for (const file of files.filter((name) => name.endsWith(".db")).sort()) {
190
+ const conversation = resolve(dir, file);
191
+ const before = steps.length;
192
+ try {
193
+ readConversation(conversation, names, steps);
194
+ } catch (error) {
195
+ steps.length = before;
196
+ const message = error instanceof Error ? error.message : String(error);
197
+ failures.push(`${conversation}: ${message}`);
198
+ }
199
+ }
200
+ return {
201
+ failures,
202
+ steps: steps.map(({ modelCode, ...step }) => ({
203
+ ...step,
204
+ model: names.get(modelCode) ?? unknownModel,
205
+ })),
206
+ };
207
+ }
package/src/ccusage.ts CHANGED
@@ -37,16 +37,25 @@ export function ccusageCliPath(): string {
37
37
  return createRequire(import.meta.url).resolve("ccusage/src/cli.js");
38
38
  }
39
39
 
40
- export function sinceArgument(today: Date, timezone: string): string {
41
- const calendarDate = new Intl.DateTimeFormat("en-CA", {
40
+ export function calendarDate(at: Date, timezone: string): string {
41
+ return new Intl.DateTimeFormat("en-CA", {
42
42
  timeZone: timezone,
43
43
  year: "numeric",
44
44
  month: "2-digit",
45
45
  day: "2-digit",
46
- }).format(today);
47
- const [year, month, day] = calendarDate.split("-").map(Number);
46
+ }).format(at);
47
+ }
48
+
49
+ export function windowStart(today: Date, timezone: string): string {
50
+ const [year, month, day] = calendarDate(today, timezone)
51
+ .split("-")
52
+ .map(Number);
48
53
  const start = new Date(Date.UTC(year, month - 1, day - (windowDays - 1)));
49
- return start.toISOString().slice(0, 10).replaceAll("-", "");
54
+ return start.toISOString().slice(0, 10);
55
+ }
56
+
57
+ export function sinceArgument(today: Date, timezone: string): string {
58
+ return windowStart(today, timezone).replaceAll("-", "");
50
59
  }
51
60
 
52
61
  export function ccusageArguments(since: string, timezone: string): string[] {
package/src/cli.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
- import { collect, type Fetcher } from "./collect";
3
+ import { collect } from "./collect";
4
4
  import type { CommandRunner } from "./command";
5
+ import type { Fetcher } from "./http";
5
6
  import { install } from "./install";
6
7
  import { type MachineIdentity, readMachineIdentity } from "./machine";
7
8
  import { type CollectorEnv, processEnv } from "./paths";
@@ -35,12 +36,15 @@ async function runCollect(io: CliIo): Promise<number> {
35
36
  runner: io.runner,
36
37
  today: io.today,
37
38
  });
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");
39
+ if (result.kind === "reported" || result.kind === "empty") {
40
+ for (const warning of result.warnings) {
41
+ io.stderr(warning);
42
+ }
43
+ io.stdout(
44
+ result.kind === "reported"
45
+ ? `accepted ${result.accepted} days for ${result.machine}`
46
+ : "nothing to report",
47
+ );
44
48
  return 0;
45
49
  }
46
50
  if (result.kind === "missing-config") {
package/src/collect.ts CHANGED
@@ -1,18 +1,22 @@
1
- import { readCcusageDaily, sinceArgument } from "./ccusage";
1
+ import {
2
+ antigravityConversationsDir,
3
+ readAntigravitySteps,
4
+ } from "./antigravity";
5
+ import {
6
+ calendarDate,
7
+ readCcusageDaily,
8
+ sinceArgument,
9
+ windowStart,
10
+ } from "./ccusage";
2
11
  import { type CommandRunner, runCommand } from "./command";
3
12
  import { readConfig, runtimeTimezone } from "./config";
13
+ import type { Fetcher } from "./http";
4
14
  import { type MachineIdentity, machineId } from "./machine";
5
- import { mapCcusageDays } from "./mapping";
15
+ import { mapAntigravitySteps, mapCcusageDays } from "./mapping";
6
16
  import { type CollectorEnv, collectorPaths, processEnv } from "./paths";
17
+ import { loadPrices } from "./pricing";
7
18
  import type { UsageDay, UsageReport } from "./usage";
8
19
 
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
20
  export interface CollectOptions {
17
21
  identity: MachineIdentity;
18
22
  env?: CollectorEnv;
@@ -23,8 +27,8 @@ export interface CollectOptions {
23
27
  }
24
28
 
25
29
  export type CollectResult =
26
- | { kind: "reported"; accepted: number; machine: string }
27
- | { kind: "empty" }
30
+ | { kind: "reported"; accepted: number; machine: string; warnings: string[] }
31
+ | { kind: "empty"; warnings: string[] }
28
32
  | { kind: "missing-config"; configFile: string }
29
33
  | { kind: "failed"; message: string };
30
34
 
@@ -52,11 +56,17 @@ function acceptedCount(body: string): number | null {
52
56
  return typeof accepted === "number" ? accepted : null;
53
57
  }
54
58
 
59
+ interface AntigravityRows {
60
+ days: UsageDay[];
61
+ warnings: string[];
62
+ }
63
+
55
64
  async function report(
56
65
  fetcher: Fetcher,
57
66
  url: string,
58
67
  key: string,
59
68
  usage: UsageReport,
69
+ warnings: string[],
60
70
  ): Promise<CollectResult> {
61
71
  const response = await fetcher(reportUrl(url), {
62
72
  body: JSON.stringify(usage),
@@ -80,11 +90,34 @@ async function report(
80
90
  message: `tokenmax responded an unexpected body: ${body}`,
81
91
  };
82
92
  }
83
- return { accepted, kind: "reported", machine: usage.machine };
93
+ return { accepted, kind: "reported", machine: usage.machine, warnings };
94
+ }
95
+
96
+ async function antigravityDays(
97
+ home: string,
98
+ today: Date,
99
+ timezone: string,
100
+ fetcher: Fetcher,
101
+ pricesFile: string,
102
+ ): Promise<AntigravityRows> {
103
+ const since = windowStart(today, timezone);
104
+ const usage = await readAntigravitySteps(antigravityConversationsDir(home));
105
+ const warnings = usage.failures.map(
106
+ (failure) => `antigravity: skipped ${failure}`,
107
+ );
108
+ const steps = usage.steps.filter(
109
+ (step) => calendarDate(step.at, timezone) >= since,
110
+ );
111
+ if (steps.length === 0) {
112
+ return { days: [], warnings };
113
+ }
114
+ const prices = await loadPrices(fetcher, pricesFile, today);
115
+ return { days: mapAntigravitySteps(steps, timezone, prices), warnings };
84
116
  }
85
117
 
86
118
  export async function collect(options: CollectOptions): Promise<CollectResult> {
87
- const paths = collectorPaths(options.env ?? processEnv());
119
+ const env = options.env ?? processEnv();
120
+ const paths = collectorPaths(env);
88
121
  const config = await readConfig(paths.configFile);
89
122
  if (config.kind === "missing") {
90
123
  return { configFile: paths.configFile, kind: "missing-config" };
@@ -100,28 +133,45 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
100
133
  const timezone =
101
134
  config.config.timezone ?? options.timezone ?? runtimeTimezone();
102
135
  const runner = options.runner ?? runCommand;
136
+ const fetcher = options.fetcher ?? fetch;
137
+ const today = options.today ?? new Date();
103
138
 
104
139
  let days: UsageDay[];
105
140
  try {
106
141
  const daily = await readCcusageDaily(
107
142
  runner,
108
- sinceArgument(options.today ?? new Date(), timezone),
143
+ sinceArgument(today, timezone),
109
144
  timezone,
110
145
  );
111
146
  days = mapCcusageDays(daily);
112
147
  } catch (error) {
113
148
  return { kind: "failed", message: messageOf(error) };
114
149
  }
150
+
151
+ let antigravity: AntigravityRows;
152
+ try {
153
+ antigravity = await antigravityDays(
154
+ env.home,
155
+ today,
156
+ timezone,
157
+ fetcher,
158
+ paths.pricesFile,
159
+ );
160
+ } catch (error) {
161
+ antigravity = { days: [], warnings: [`antigravity: ${messageOf(error)}`] };
162
+ }
163
+ days.push(...antigravity.days);
115
164
  if (days.length === 0) {
116
- return { kind: "empty" };
165
+ return { kind: "empty", warnings: antigravity.warnings };
117
166
  }
118
167
 
119
168
  try {
120
169
  return await report(
121
- options.fetcher ?? fetch,
170
+ fetcher,
122
171
  config.config.url,
123
172
  config.config.key,
124
173
  { days, machine, timezone },
174
+ antigravity.warnings,
125
175
  );
126
176
  } catch (error) {
127
177
  return { kind: "failed", message: messageOf(error) };
package/src/http.ts ADDED
@@ -0,0 +1,6 @@
1
+ export interface HttpResponse {
2
+ status: number;
3
+ text(): Promise<string>;
4
+ }
5
+
6
+ export type Fetcher = (url: string, init: RequestInit) => Promise<HttpResponse>;
package/src/mapping.ts CHANGED
@@ -1,6 +1,10 @@
1
- import type { CcusageDaily } from "./ccusage";
1
+ import type { AntigravityStep } from "./antigravity";
2
+ import { type CcusageDaily, calendarDate } from "./ccusage";
3
+ import { costOf, type PriceTable } from "./pricing";
2
4
  import type { UsageDay } from "./usage";
3
5
 
6
+ export const antigravityProvider = "antigravity";
7
+
4
8
  const agentModelPrefix = /^\[[^\]]*\]\s*/;
5
9
 
6
10
  const rowKey = (day: UsageDay): string =>
@@ -46,6 +50,57 @@ export function mapCcusageDays(output: CcusageDaily): UsageDay[] {
46
50
  }
47
51
  }
48
52
 
53
+ return sortedRows(rows);
54
+ }
55
+
56
+ export function mapAntigravitySteps(
57
+ steps: AntigravityStep[],
58
+ timezone: string,
59
+ prices: PriceTable,
60
+ ): UsageDay[] {
61
+ const rows = new Map<string, UsageDay>();
62
+
63
+ for (const step of steps) {
64
+ const row: UsageDay = {
65
+ cache_create: 0,
66
+ cache_read: step.cacheRead,
67
+ cost_usd: 0,
68
+ date: calendarDate(step.at, timezone),
69
+ input: step.input,
70
+ model: step.model,
71
+ output: step.output,
72
+ provider: antigravityProvider,
73
+ };
74
+ if (isEmptyRow(row)) {
75
+ continue;
76
+ }
77
+ const key = rowKey(row);
78
+ const existing = rows.get(key);
79
+ if (existing === undefined) {
80
+ rows.set(key, row);
81
+ continue;
82
+ }
83
+ existing.cache_read += row.cache_read;
84
+ existing.input += row.input;
85
+ existing.output += row.output;
86
+ }
87
+
88
+ for (const row of rows.values()) {
89
+ const price = prices.get(row.model) ?? prices.get(`gemini/${row.model}`);
90
+ row.cost_usd =
91
+ price === undefined
92
+ ? 0
93
+ : costOf(price, {
94
+ cacheRead: row.cache_read,
95
+ input: row.input,
96
+ output: row.output,
97
+ });
98
+ }
99
+
100
+ return sortedRows(rows);
101
+ }
102
+
103
+ function sortedRows(rows: Map<string, UsageDay>): UsageDay[] {
49
104
  return [...rows.values()].sort(
50
105
  (a, b) =>
51
106
  a.date.localeCompare(b.date) ||
package/src/paths.ts CHANGED
@@ -12,6 +12,7 @@ export interface CollectorEnv {
12
12
  export interface CollectorPaths {
13
13
  configFile: string;
14
14
  plist: string;
15
+ pricesFile: string;
15
16
  service: string;
16
17
  stderrLog: string;
17
18
  stdoutLog: string;
@@ -54,6 +55,7 @@ export function collectorPaths(env: CollectorEnv): CollectorPaths {
54
55
  "LaunchAgents",
55
56
  "dev.tokenmax.collector.plist",
56
57
  ),
58
+ pricesFile: resolve(configHome, "tokenmax", "litellm-prices.json"),
57
59
  service: resolve(home, ".config", "systemd", "user", "tokenmax.service"),
58
60
  stderrLog: resolve(logDir, "tokenmax.err.log"),
59
61
  stdoutLog: resolve(logDir, "tokenmax.log"),
package/src/pricing.ts ADDED
@@ -0,0 +1,120 @@
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
+ cacheRead: number;
8
+ input: number;
9
+ output: number;
10
+ }
11
+
12
+ export type PriceTable = Map<string, ModelPrice>;
13
+
14
+ export interface PricedUsage {
15
+ cacheRead: number;
16
+ input: number;
17
+ output: number;
18
+ }
19
+
20
+ export const litellmPricesUrl =
21
+ "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json";
22
+
23
+ const maxAgeMs = 24 * 60 * 60 * 1000;
24
+
25
+ const pricedEntry = z.object({
26
+ cache_read_input_token_cost: z.number().optional(),
27
+ input_cost_per_token: z.number(),
28
+ output_cost_per_token: z.number(),
29
+ });
30
+
31
+ const litellmPrices = z.record(z.string(), z.unknown());
32
+
33
+ export function parseLitellmPrices(source: string): PriceTable {
34
+ const table: PriceTable = new Map();
35
+ for (const [model, entry] of Object.entries(
36
+ litellmPrices.parse(JSON.parse(source)),
37
+ )) {
38
+ const priced = pricedEntry.safeParse(entry);
39
+ if (priced.success) {
40
+ table.set(model, {
41
+ cacheRead: priced.data.cache_read_input_token_cost ?? 0,
42
+ input: priced.data.input_cost_per_token,
43
+ output: priced.data.output_cost_per_token,
44
+ });
45
+ }
46
+ }
47
+ return table;
48
+ }
49
+
50
+ export function costOf(price: ModelPrice, usage: PricedUsage): number {
51
+ return (
52
+ usage.input * price.input +
53
+ usage.output * price.output +
54
+ usage.cacheRead * price.cacheRead
55
+ );
56
+ }
57
+
58
+ async function readCache(
59
+ pricesFile: string,
60
+ ): Promise<{ modifiedAt: number; table: PriceTable } | null> {
61
+ let source: string;
62
+ let modifiedAt: number;
63
+ try {
64
+ [source, modifiedAt] = await Promise.all([
65
+ readFile(pricesFile, "utf8"),
66
+ stat(pricesFile).then((info) => info.mtimeMs),
67
+ ]);
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
70
+ return null;
71
+ }
72
+ throw error;
73
+ }
74
+ try {
75
+ return { modifiedAt, table: parseLitellmPrices(source) };
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ async function writeCache(pricesFile: string, source: string): Promise<void> {
82
+ await mkdir(dirname(pricesFile), { recursive: true });
83
+ const partial = `${pricesFile}.${process.pid}.tmp`;
84
+ await writeFile(partial, source);
85
+ await rename(partial, pricesFile);
86
+ }
87
+
88
+ async function fetchPrices(fetcher: Fetcher): Promise<string> {
89
+ const response = await fetcher(litellmPricesUrl, { method: "GET" });
90
+ const source = await response.text();
91
+ if (response.status !== 200) {
92
+ throw new Error(`LiteLLM responded ${response.status}`);
93
+ }
94
+ return source;
95
+ }
96
+
97
+ export async function loadPrices(
98
+ fetcher: Fetcher,
99
+ pricesFile: string,
100
+ now: Date,
101
+ ): Promise<PriceTable> {
102
+ const cached = await readCache(pricesFile);
103
+ if (cached !== null && now.getTime() - cached.modifiedAt < maxAgeMs) {
104
+ return cached.table;
105
+ }
106
+ let source: string;
107
+ let table: PriceTable;
108
+ try {
109
+ source = await fetchPrices(fetcher);
110
+ table = parseLitellmPrices(source);
111
+ } catch (error) {
112
+ if (cached !== null) {
113
+ return cached.table;
114
+ }
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ throw new Error(`could not load model prices: ${message}`);
117
+ }
118
+ await writeCache(pricesFile, source);
119
+ return table;
120
+ }