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 +2 -2
- package/src/antigravity.ts +207 -0
- package/src/ccusage.ts +14 -5
- package/src/cli.ts +11 -7
- package/src/collect.ts +66 -16
- package/src/http.ts +6 -0
- package/src/mapping.ts +56 -1
- package/src/paths.ts +2 -0
- package/src/pricing.ts +120 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmax-collector",
|
|
3
|
-
"version": "0.1
|
|
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.
|
|
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
|
|
41
|
-
|
|
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(
|
|
47
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
package/src/mapping.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import type {
|
|
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
|
+
}
|