tokenmax-collector 0.2.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.2.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",
@@ -11,6 +11,11 @@ export interface AntigravityStep {
11
11
  output: number;
12
12
  }
13
13
 
14
+ export interface AntigravityUsage {
15
+ failures: string[];
16
+ steps: AntigravityStep[];
17
+ }
18
+
14
19
  interface ProtoField {
15
20
  number: number;
16
21
  value: bigint | Uint8Array;
@@ -43,10 +48,16 @@ export function antigravityConversationsDir(home: string): string {
43
48
  function decodeMessage(bytes: Uint8Array): ProtoField[] {
44
49
  const fields: ProtoField[] = [];
45
50
  let offset = 0;
51
+ const truncated = (): never => {
52
+ throw new Error("truncated protobuf message");
53
+ };
46
54
  const varint = (): bigint => {
47
55
  let result = 0n;
48
56
  let shift = 0n;
49
57
  for (;;) {
58
+ if (offset >= bytes.length) {
59
+ truncated();
60
+ }
50
61
  const byte = bytes[offset++];
51
62
  result |= BigInt(byte & 0x7f) << shift;
52
63
  if (byte < 0x80) {
@@ -56,6 +67,9 @@ function decodeMessage(bytes: Uint8Array): ProtoField[] {
56
67
  }
57
68
  };
58
69
  const bytesOf = (length: number): Uint8Array => {
70
+ if (offset + length > bytes.length) {
71
+ truncated();
72
+ }
59
73
  const slice = bytes.subarray(offset, offset + length);
60
74
  offset += length;
61
75
  return slice;
@@ -108,7 +122,7 @@ function text(fields: ProtoField[], number: number): string | null {
108
122
  function openReadOnly(file: string): DatabaseSync {
109
123
  const location = existsSync(`${file}-wal`)
110
124
  ? file
111
- : `file:${file}?immutable=1`;
125
+ : `file:${file.split("/").map(encodeURIComponent).join("/")}?immutable=1`;
112
126
  return new DatabaseSync(location, { readOnly: true });
113
127
  }
114
128
 
@@ -159,23 +173,35 @@ function readConversation(
159
173
 
160
174
  export async function readAntigravitySteps(
161
175
  dir: string,
162
- ): Promise<AntigravityStep[]> {
176
+ ): Promise<AntigravityUsage> {
163
177
  let files: string[];
164
178
  try {
165
179
  files = await readdir(dir);
166
180
  } catch (error) {
167
181
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
168
- return [];
182
+ return { failures: [], steps: [] };
169
183
  }
170
184
  throw error;
171
185
  }
172
186
  const names = new Map<number, string>();
173
187
  const steps: RawStep[] = [];
188
+ const failures: string[] = [];
174
189
  for (const file of files.filter((name) => name.endsWith(".db")).sort()) {
175
- readConversation(resolve(dir, file), names, steps);
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
+ }
176
199
  }
177
- return steps.map(({ modelCode, ...step }) => ({
178
- ...step,
179
- model: names.get(modelCode) ?? unknownModel,
180
- }));
200
+ return {
201
+ failures,
202
+ steps: steps.map(({ modelCode, ...step }) => ({
203
+ ...step,
204
+ model: names.get(modelCode) ?? unknownModel,
205
+ })),
206
+ };
181
207
  }
package/src/cli.ts CHANGED
@@ -36,12 +36,15 @@ async function runCollect(io: CliIo): Promise<number> {
36
36
  runner: io.runner,
37
37
  today: io.today,
38
38
  });
39
- if (result.kind === "reported") {
40
- io.stdout(`accepted ${result.accepted} days for ${result.machine}`);
41
- return 0;
42
- }
43
- if (result.kind === "empty") {
44
- 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
+ );
45
48
  return 0;
46
49
  }
47
50
  if (result.kind === "missing-config") {
package/src/collect.ts CHANGED
@@ -27,8 +27,8 @@ export interface CollectOptions {
27
27
  }
28
28
 
29
29
  export type CollectResult =
30
- | { kind: "reported"; accepted: number; machine: string }
31
- | { kind: "empty" }
30
+ | { kind: "reported"; accepted: number; machine: string; warnings: string[] }
31
+ | { kind: "empty"; warnings: string[] }
32
32
  | { kind: "missing-config"; configFile: string }
33
33
  | { kind: "failed"; message: string };
34
34
 
@@ -56,11 +56,17 @@ function acceptedCount(body: string): number | null {
56
56
  return typeof accepted === "number" ? accepted : null;
57
57
  }
58
58
 
59
+ interface AntigravityRows {
60
+ days: UsageDay[];
61
+ warnings: string[];
62
+ }
63
+
59
64
  async function report(
60
65
  fetcher: Fetcher,
61
66
  url: string,
62
67
  key: string,
63
68
  usage: UsageReport,
69
+ warnings: string[],
64
70
  ): Promise<CollectResult> {
65
71
  const response = await fetcher(reportUrl(url), {
66
72
  body: JSON.stringify(usage),
@@ -84,7 +90,7 @@ async function report(
84
90
  message: `tokenmax responded an unexpected body: ${body}`,
85
91
  };
86
92
  }
87
- return { accepted, kind: "reported", machine: usage.machine };
93
+ return { accepted, kind: "reported", machine: usage.machine, warnings };
88
94
  }
89
95
 
90
96
  async function antigravityDays(
@@ -93,16 +99,20 @@ async function antigravityDays(
93
99
  timezone: string,
94
100
  fetcher: Fetcher,
95
101
  pricesFile: string,
96
- ): Promise<UsageDay[]> {
102
+ ): Promise<AntigravityRows> {
97
103
  const since = windowStart(today, timezone);
98
- const steps = (
99
- await readAntigravitySteps(antigravityConversationsDir(home))
100
- ).filter((step) => calendarDate(step.at, timezone) >= since);
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
+ );
101
111
  if (steps.length === 0) {
102
- return [];
112
+ return { days: [], warnings };
103
113
  }
104
114
  const prices = await loadPrices(fetcher, pricesFile, today);
105
- return mapAntigravitySteps(steps, timezone, prices);
115
+ return { days: mapAntigravitySteps(steps, timezone, prices), warnings };
106
116
  }
107
117
 
108
118
  export async function collect(options: CollectOptions): Promise<CollectResult> {
@@ -133,29 +143,36 @@ export async function collect(options: CollectOptions): Promise<CollectResult> {
133
143
  sinceArgument(today, timezone),
134
144
  timezone,
135
145
  );
136
- days = [
137
- ...mapCcusageDays(daily),
138
- ...(await antigravityDays(
139
- env.home,
140
- today,
141
- timezone,
142
- fetcher,
143
- paths.pricesFile,
144
- )),
145
- ];
146
+ days = mapCcusageDays(daily);
146
147
  } catch (error) {
147
148
  return { kind: "failed", message: messageOf(error) };
148
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);
149
164
  if (days.length === 0) {
150
- return { kind: "empty" };
165
+ return { kind: "empty", warnings: antigravity.warnings };
151
166
  }
152
167
 
153
168
  try {
154
- return await report(fetcher, config.config.url, config.config.key, {
155
- days,
156
- machine,
157
- timezone,
158
- });
169
+ return await report(
170
+ fetcher,
171
+ config.config.url,
172
+ config.config.key,
173
+ { days, machine, timezone },
174
+ antigravity.warnings,
175
+ );
159
176
  } catch (error) {
160
177
  return { kind: "failed", message: messageOf(error) };
161
178
  }
package/src/pricing.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
4
  import type { Fetcher } from "./http";
@@ -57,19 +57,32 @@ export function costOf(price: ModelPrice, usage: PricedUsage): number {
57
57
 
58
58
  async function readCache(
59
59
  pricesFile: string,
60
- ): Promise<{ modifiedAt: number; source: string } | null> {
60
+ ): Promise<{ modifiedAt: number; table: PriceTable } | null> {
61
+ let source: string;
62
+ let modifiedAt: number;
61
63
  try {
62
- const [source, info] = await Promise.all([
64
+ [source, modifiedAt] = await Promise.all([
63
65
  readFile(pricesFile, "utf8"),
64
- stat(pricesFile),
66
+ stat(pricesFile).then((info) => info.mtimeMs),
65
67
  ]);
66
- return { modifiedAt: info.mtimeMs, source };
67
68
  } catch (error) {
68
69
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
69
70
  return null;
70
71
  }
71
72
  throw error;
72
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);
73
86
  }
74
87
 
75
88
  async function fetchPrices(fetcher: Fetcher): Promise<string> {
@@ -88,7 +101,7 @@ export async function loadPrices(
88
101
  ): Promise<PriceTable> {
89
102
  const cached = await readCache(pricesFile);
90
103
  if (cached !== null && now.getTime() - cached.modifiedAt < maxAgeMs) {
91
- return parseLitellmPrices(cached.source);
104
+ return cached.table;
92
105
  }
93
106
  let source: string;
94
107
  let table: PriceTable;
@@ -97,12 +110,11 @@ export async function loadPrices(
97
110
  table = parseLitellmPrices(source);
98
111
  } catch (error) {
99
112
  if (cached !== null) {
100
- return parseLitellmPrices(cached.source);
113
+ return cached.table;
101
114
  }
102
115
  const message = error instanceof Error ? error.message : String(error);
103
116
  throw new Error(`could not load model prices: ${message}`);
104
117
  }
105
- await mkdir(dirname(pricesFile), { recursive: true });
106
- await writeFile(pricesFile, source);
118
+ await writeCache(pricesFile, source);
107
119
  return table;
108
120
  }